"""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 _servers(): """Servers block for the spec: the relative mount, plus this site's own URL if one was supplied. SHOPDB_PUBLIC_URL is read from the environment rather than stored, because the generated spec is published to a public wiki - a site URL baked into the generator ends up in everyone's documentation, including sites it is wrong for. """ servers = [] siteurl = (os.environ.get('SHOPDB_PUBLIC_URL') or '').strip().rstrip('/') if siteurl: servers.append({'url': siteurl, 'description': 'this site'}) servers.append({'url': '/', 'description': 'relative to the deployed mount'}) return servers def _product_version(): """Read __version__ out of shopdb/__init__.py without importing the app. Importing shopdb here would pull in Flask, the plugin loader and a database configuration, none of which this generator needs. """ here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) init = os.path.join(here, 'shopdb', '__init__.py') with open(init, 'r', encoding='utf-8') as handle: match = re.search(r"^__version__ = '([^']+)'", handle.read(), re.M) if not match: raise SystemExit('could not read __version__ from ' + init) return match.group(1) def _norm_path(p): # Flask / -> 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', # Read from the code, not restated. A hardcoded copy here had # already drifted a release behind, and the same mistake in the # installer script shipped an exe stamped with the wrong version. 'version': _product_version(), '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.'), }, # One site's production hostname was hardcoded here, so every generated # spec published it to the public wiki and offered a second site a # server it cannot reach. The relative mount is the only server this # generator can honestly name; a site that wants its own in the spec # sets SHOPDB_PUBLIC_URL when regenerating. 'servers': _servers(), '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()