api: serve interactive OpenAPI docs at /api/docs (offline) + llms.txt
Generate docs/openapi.json (3.1, 362 operations) from the API inventory via scripts/gen_openapi.py, and serve it with a self-hosted Redoc bundle at /api/docs - no CDN, works on the air-gapped box. Also serve docs/llms.txt (a concise LLM entrypoint) at /api/docs/llms.txt. New core 'docs' blueprint; staticdocs/ excluded from the naming check (vendored minified JS).
This commit is contained in:
2978
docs/api-inventory.json
Normal file
2978
docs/api-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
58
docs/llms.txt
Normal file
58
docs/llms.txt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ShopDB Flask API - LLM guide
|
||||||
|
|
||||||
|
ShopDB is a plugin-based asset-management system (PCs, printers, machines,
|
||||||
|
network devices, measuring tools, applications, knowledge base, USB, warranties)
|
||||||
|
for GE Aerospace sites. This file is the quick entrypoint; the full machine
|
||||||
|
spec is the OpenAPI at `/api/docs/openapi.json` (browse it at `/api/docs`).
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
Prod (West Jefferson): `https://tsgwp00525.wjs.geaerospace.net/shopdb`
|
||||||
|
All API paths are under `/api` (e.g. `<base>/api/assets`). Dev: `http://localhost:5001`.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
Three schemes:
|
||||||
|
- **Bearer JWT** - most endpoints. Get one by logging in, or use a managed
|
||||||
|
Personal Access Token (PAT). Send `Authorization: Bearer <token>`.
|
||||||
|
- Login: `POST /api/auth/login` `{ "username": "...", "password": "..." }`
|
||||||
|
-> `data.access_token`. Refresh: `POST /api/auth/refresh`.
|
||||||
|
- PATs are minted in the UI (Settings > API Tokens); a *scoped* PAT is limited
|
||||||
|
to named permissions and suspends the admin bypass.
|
||||||
|
- **X-API-Key** - unattended/service endpoints (collector ingest, GE-Enforce
|
||||||
|
fetch). Send `X-API-Key: <managed-token>`.
|
||||||
|
- **Public** - some read endpoints (e.g. printer install-list, employee search,
|
||||||
|
dashboards) need no auth.
|
||||||
|
|
||||||
|
Auth level per endpoint is in the OpenAPI `security` field: `bearerAuth`,
|
||||||
|
`apiKeyAuth`, or none. Admin-only and permission-gated routes both use bearer.
|
||||||
|
|
||||||
|
## Response envelope
|
||||||
|
JSON endpoints return `{ "status": "success", "data": <payload>, "meta": {...} }`.
|
||||||
|
Errors: `{ "status": "error", "message": "...", "code": "..." }` with an HTTP 4xx/5xx.
|
||||||
|
Lists include `meta.total` / pagination. A few feed endpoints (screensaver, some
|
||||||
|
installer text formats) return raw text/JSON without the envelope - noted per route.
|
||||||
|
|
||||||
|
## Common recipes
|
||||||
|
- Search everything: `GET /api/search?q=<term>` (multi-word = AND across words).
|
||||||
|
- List assets on the map: `GET /api/assets/map`.
|
||||||
|
- List a type: `GET /api/printers`, `/api/computers`, `/api/machines`,
|
||||||
|
`/api/network`, `/api/measuringtools` (paginated: `?page=&perpage=`).
|
||||||
|
- Get one: `GET /api/printers/<id>` etc.
|
||||||
|
- Create (bearer): `POST /api/printers` `{assetnumber, windowsname, vendorid, ...}`.
|
||||||
|
- Reports: `GET /api/reports` (list), `GET /api/reports/pc-relationships` (PC<->machine).
|
||||||
|
- Printer installer data: `GET /api/printers/install-list` (public; add
|
||||||
|
`?format=text` for a pipe-delimited variant); `GET /api/printers/pc-default?machine=<n>`.
|
||||||
|
- Collector ingest (X-API-Key): `POST /api/collector/computers`.
|
||||||
|
- GE-Enforce: `GET /api/geenforce/manifest?pctype=<scope>`,
|
||||||
|
`GET /api/geenforce/payload/<sha256>`, `POST /api/geenforce/report`.
|
||||||
|
- Import (admin PAT, preserves timestamps with `X-Import-Mode`): see docs/IMPORT-API.md.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- DB-mirrored params/fields use lowercase concatenated names (no underscores):
|
||||||
|
`locationid`, `vendorid`, `windowsname` - match them exactly.
|
||||||
|
- IDs in paths are integers.
|
||||||
|
- Plugin endpoints live under the plugin's prefix (`/api/<plugin>/...`).
|
||||||
|
|
||||||
|
## Full reference
|
||||||
|
- Machine spec: `GET /api/docs/openapi.json` (OpenAPI 3.1, 362 operations).
|
||||||
|
- Interactive: `GET /api/docs` (Redoc).
|
||||||
|
- Human reference: `docs/API-REFERENCE.md`.
|
||||||
6620
docs/openapi.json
Normal file
6620
docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ EXCLUDES=(
|
|||||||
--exclude-dir=dist
|
--exclude-dir=dist
|
||||||
--exclude-dir=.git
|
--exclude-dir=.git
|
||||||
--exclude-dir=versions
|
--exclude-dir=versions
|
||||||
|
--exclude-dir=staticdocs
|
||||||
)
|
)
|
||||||
|
|
||||||
INCLUDES_CODE=(
|
INCLUDES_CODE=(
|
||||||
|
|||||||
104
scripts/gen_openapi.py
Normal file
104
scripts/gen_openapi.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Generate docs/openapi.json from docs/api-inventory.json.
|
||||||
|
|
||||||
|
The inventory is a list of {surface, endpoints:[{method,path,auth,params,purpose,
|
||||||
|
example}]} objects (one per API surface). Re-run after adding/changing endpoints
|
||||||
|
(update api-inventory.json first, or regenerate it). Served interactively at
|
||||||
|
/api/docs (see shopdb/core/api/docs.py).
|
||||||
|
|
||||||
|
venv/bin/python scripts/gen_openapi.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(HERE)
|
||||||
|
INVENTORY = os.path.join(REPO, 'docs', 'api-inventory.json')
|
||||||
|
OUT = os.path.join(REPO, 'docs', 'openapi.json')
|
||||||
|
|
||||||
|
VERBS = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_path(p):
|
||||||
|
# Flask <int:id> / <id> -> OpenAPI {id}
|
||||||
|
return re.sub(r'<(?:[^:>]+:)?([^>]+)>', r'{\1}', p or '')
|
||||||
|
|
||||||
|
|
||||||
|
def _security(auth):
|
||||||
|
a = (auth or '').lower()
|
||||||
|
if a in ('none', 'public', ''):
|
||||||
|
return []
|
||||||
|
if 'api-key' in a or 'api_key' in a or 'x-api-key' in a:
|
||||||
|
return [{'apiKeyAuth': []}]
|
||||||
|
return [{'bearerAuth': []}]
|
||||||
|
|
||||||
|
|
||||||
|
def build(surfaces):
|
||||||
|
paths = {}
|
||||||
|
tags = []
|
||||||
|
for s in surfaces:
|
||||||
|
tags.append({'name': s['surface']})
|
||||||
|
for e in s.get('endpoints', []):
|
||||||
|
for verb in re.split(r'[\/,]', (e.get('method') or 'GET')):
|
||||||
|
verb = verb.strip().lower()
|
||||||
|
if verb not in VERBS:
|
||||||
|
continue
|
||||||
|
path = _norm_path(e.get('path'))
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
desc = []
|
||||||
|
if e.get('purpose'):
|
||||||
|
desc.append(e['purpose'])
|
||||||
|
if e.get('auth'):
|
||||||
|
desc.append('\n\n**Auth:** ' + e['auth'])
|
||||||
|
if e.get('params'):
|
||||||
|
desc.append('\n\n**Params:** ' + e['params'])
|
||||||
|
if e.get('example'):
|
||||||
|
desc.append('\n\n**Example:**\n```\n' + e['example'] + '\n```')
|
||||||
|
paths.setdefault(path, {})[verb] = {
|
||||||
|
'tags': [s['surface']],
|
||||||
|
'summary': (e.get('purpose') or path)[:120],
|
||||||
|
'description': ''.join(desc),
|
||||||
|
'security': _security(e.get('auth')),
|
||||||
|
'responses': {'200': {
|
||||||
|
'description': 'Success (success_response envelope)'}},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'openapi': '3.1.0',
|
||||||
|
'info': {
|
||||||
|
'title': 'ShopDB Flask API',
|
||||||
|
'version': '0.7.0',
|
||||||
|
'description': (
|
||||||
|
'Asset-management API (core + plugins). Responses use a '
|
||||||
|
'`success_response` envelope: `{status, data, meta}`. Auth: Bearer '
|
||||||
|
'JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; '
|
||||||
|
'`X-API-Key` for collector/managed-token endpoints; public endpoints '
|
||||||
|
'need neither.'),
|
||||||
|
},
|
||||||
|
'servers': [
|
||||||
|
{'url': 'https://tsgwp00525.wjs.geaerospace.net/shopdb', 'description': 'WJ prod'},
|
||||||
|
{'url': '/', 'description': 'relative to the deployed mount'},
|
||||||
|
],
|
||||||
|
'components': {'securitySchemes': {
|
||||||
|
'bearerAuth': {'type': 'http', 'scheme': 'bearer', 'bearerFormat': 'JWT',
|
||||||
|
'description': 'Login token or a managed Personal Access Token (scoped).'},
|
||||||
|
'apiKeyAuth': {'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key',
|
||||||
|
'description': 'Managed service token (e.g. collector.ingest, geenforce.fetch).'},
|
||||||
|
}},
|
||||||
|
'tags': tags,
|
||||||
|
'paths': paths,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
surfaces = json.load(open(INVENTORY))
|
||||||
|
spec = build(surfaces)
|
||||||
|
json.dump(spec, open(OUT, 'w'), indent=1)
|
||||||
|
ops = sum(len(v) for v in spec['paths'].values())
|
||||||
|
print('wrote %s: %d paths, %d operations from %d surfaces'
|
||||||
|
% (OUT, len(spec['paths']), ops, len(surfaces)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -143,6 +143,7 @@ CORE_BLUEPRINT_NAMES = (
|
|||||||
'setup',
|
'setup',
|
||||||
'pluginui',
|
'pluginui',
|
||||||
'apitokens',
|
'apitokens',
|
||||||
|
'docs',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from .customfields import customfields_bp
|
|||||||
from .setup import setup_bp
|
from .setup import setup_bp
|
||||||
from .pluginui import pluginui_bp
|
from .pluginui import pluginui_bp
|
||||||
from .apitokens import apitokens_bp
|
from .apitokens import apitokens_bp
|
||||||
|
from .docs import docs_bp
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'auth_bp',
|
'auth_bp',
|
||||||
@@ -48,4 +49,5 @@ __all__ = [
|
|||||||
'setup_bp',
|
'setup_bp',
|
||||||
'pluginui_bp',
|
'pluginui_bp',
|
||||||
'apitokens_bp',
|
'apitokens_bp',
|
||||||
|
'docs_bp',
|
||||||
]
|
]
|
||||||
|
|||||||
61
shopdb/core/api/docs.py
Normal file
61
shopdb/core/api/docs.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Interactive API docs: self-hosted Redoc over the generated OpenAPI spec.
|
||||||
|
|
||||||
|
Served at /api/docs (relative to the mount). The Redoc bundle is vendored in
|
||||||
|
staticdocs/ so this works fully offline on the air-gapped prod box - no CDN.
|
||||||
|
The spec is docs/openapi.json in the repo (regenerate with
|
||||||
|
scripts/gen_openapi.py after API changes).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, send_file, url_for
|
||||||
|
|
||||||
|
docs_bp = Blueprint('docs', __name__)
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
# shopdb/core/api -> repo root
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(_HERE)))
|
||||||
|
_SPEC = os.path.join(_REPO_ROOT, 'docs', 'openapi.json')
|
||||||
|
_LLMS = os.path.join(_REPO_ROOT, 'docs', 'llms.txt')
|
||||||
|
_REDOC = os.path.join(_HERE, 'staticdocs', 'redoc.standalone.js')
|
||||||
|
|
||||||
|
|
||||||
|
@docs_bp.route('/', strict_slashes=False)
|
||||||
|
def docs_index():
|
||||||
|
"""Redoc page. url_for keeps the asset URLs correct under any mount."""
|
||||||
|
spec_url = url_for('docs.openapi_spec')
|
||||||
|
redoc_url = url_for('docs.redoc_js')
|
||||||
|
page = (
|
||||||
|
'<!doctype html><html><head>'
|
||||||
|
'<title>ShopDB API</title>'
|
||||||
|
'<meta charset="utf-8">'
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||||
|
'<style>body{margin:0;padding:0}</style></head><body>'
|
||||||
|
'<redoc spec-url="%s"></redoc>'
|
||||||
|
'<script src="%s"></script>'
|
||||||
|
'</body></html>' % (spec_url, redoc_url)
|
||||||
|
)
|
||||||
|
return Response(page, mimetype='text/html')
|
||||||
|
|
||||||
|
|
||||||
|
@docs_bp.route('/openapi.json')
|
||||||
|
def openapi_spec():
|
||||||
|
"""The generated OpenAPI 3.1 spec (machine + LLM readable)."""
|
||||||
|
if not os.path.isfile(_SPEC):
|
||||||
|
return Response('{"error":"openapi.json not generated"}',
|
||||||
|
status=404, mimetype='application/json')
|
||||||
|
return send_file(_SPEC, mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
|
@docs_bp.route('/redoc.standalone.js')
|
||||||
|
def redoc_js():
|
||||||
|
"""Vendored Redoc bundle (offline)."""
|
||||||
|
return send_file(_REDOC, mimetype='application/javascript')
|
||||||
|
|
||||||
|
|
||||||
|
@docs_bp.route('/llms.txt')
|
||||||
|
def llms_txt():
|
||||||
|
"""Concise LLM-oriented API guide."""
|
||||||
|
if not os.path.isfile(_LLMS):
|
||||||
|
return Response('llms.txt not found', status=404, mimetype='text/plain')
|
||||||
|
return send_file(_LLMS, mimetype='text/plain')
|
||||||
1838
shopdb/core/api/staticdocs/redoc.standalone.js
Normal file
1838
shopdb/core/api/staticdocs/redoc.standalone.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user