openvibers/openvibe.rent Commits

/openapi.json: the API as OpenAPI 3.1 (SEO-3) (#209)

OP OpenVibers committed 10 days ago · bd4ef63cdd · 4 files, +153 −0

It answered 404. The API is not in openvibe-contracts, so server/http/openapi-routes.js reads the document from the
routes the app mounts under /api/, with the summaries the API files' header comments give them.
The agent descriptor names it.

Changes

ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
iindex f68b75a..05704ae 100644
---- a/server/agent-descriptor.js
++++ b/server/agent-descriptor.js
@@@ -11,6 +11,7 @@ module.exports = {
home: 'https://openvibe.rent',
site: 'openvibe.rent',
api: '/api/v1',
++ openapi: '/openapi.json',
policy: {
auth: ['none', 'Network OIDC'],
},
ddiff --git a/server/app.js b/server/app.js
iindex bc6e5e4..629f755 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -192,6 +192,18 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.RENT_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', createApi(ctx));
++ // GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
++ // the routes this app mounts under /api/, with the summaries the API files' header comments give them
++ // (server/http/openapi-routes.js).
++ {
++ const d = require('./agent-descriptor');
++ const site = String(config.baseUrl).replace(/\/+$/, '');
++ app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
++ title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
++ docs: `${site}/llms-full.txt`,
++ files: ['http/api.js'].map((f) => require('path').join(__dirname, f)),
++ }));
++ }
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Pages ───────────────────────────────────────────────
ddiff --git a/server/http/openapi-routes.js b/server/http/openapi-routes.js
nnew file mode 100644
iindex 0000000..be8b243
---- /dev/null
++++ b/server/http/openapi-routes.js
@@@ -0,0 +1,93 @@
++'use strict';
++// GET /openapi.json (SEO-3): this service's API as OpenAPI 3.1, read from the routes the app really mounts.
++//
++// The API is not in openvibe-contracts, so there is no generated document to serve. This one cannot drift from the
++// code: its paths and methods are the app's own routes under /api/, read from Express's router when first asked
++// (every route is mounted by then), with each path's parameters. A route's summary is its line in the header comment
++// of the API files ("GET /x what it does"); the request and response bodies are on the developer page the
++// document names.
++const fs = require('fs');
++const path = require('path');
++
++const METHODS = ['get', 'put', 'post', 'delete', 'patch'];
++// Not the API: health and readiness probes, and this document.
++const SKIP = /^\/api\/(?:health|ready|metrics)(?:\/|$)|\/openapi\.json$/;
++
++/** The mount path of a nested router (Express 4 keeps it only as a regexp): '/x', '/x/:id', or null when not a plain path. */
++function mountPathOf(layer) {
++ if (layer.regexp && layer.regexp.fast_slash) return '';
++ let src = layer.regexp && layer.regexp.source;
++ if (!src) return null;
++ src = src.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\(\?=\\\/\|\$\)$/i, '');
++ let k = 0;
++ // A parameter: (?:\/([^/]+?)) in a mount path, (?:([^\/]+?)) after a slash.
++ src = src.replace(/\(\?:(\\\/)?\(\[\^\\?\/\]\+\?\)\)/g, (_, slash) => `${slash ? '\\/' : ''}:${(layer.keys[k++] || {}).name || 'param'}`);
++ src = src.replace(/\\\//g, '/');
++ return /^[A-Za-z0-9_\-/:.]*$/.test(src) ? src : null;
++}
++
++/** Every route of a router (or app), as { method, path }. */
++function routesOf(router, prefix = '', out = []) {
++ for (const layer of (router && router.stack) || []) {
++ if (layer.route && typeof layer.route.path === 'string') {
++ for (const m of METHODS) if (layer.route.methods[m]) out.push({ method: m, path: prefix + layer.route.path });
++ } else if (layer.handle && Array.isArray(layer.handle.stack)) {
++ const at = mountPathOf(layer);
++ if (at != null) routesOf(layer.handle, prefix + at, out);
++ }
++ }
++ return out;
++}
++
++/** "GET /x/:id what it does" lines from the header comments of the API files: { 'get /api/v1/x/{id}': 'what it does' }. */
++function summariesFrom(files, apiBase) {
++ const out = {};
++ for (const file of files) {
++ let src = '';
++ try { src = fs.readFileSync(file, 'utf8'); } catch { continue; }
++ for (const m of src.matchAll(/^\s*\*\s+(GET|PUT|POST|DELETE|PATCH)\s+(\/[^\s?]*)(?:\?\S*)?\s+(.+?)\s*$/gm)) {
++ const p = m[2].startsWith('/api/') ? m[2] : `${apiBase}${m[2] === '/' ? '' : m[2]}`;
++ out[`${m[1].toLowerCase()} ${p.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`] = m[3].replace(/\s{2,}/g, ' ').slice(0, 200);
++ }
++ }
++ return out;
++}
++
++/**
++ * The route handler. app: the Express app; o: { title, description, version, baseUrl, docs, apiBase = '/api/v1',
++ * files: the API source files whose header comments name the routes }.
++ */
++function openapiRoute(app, o) {
++ let doc = null;
++ const build = () => {
++ const apiBase = o.apiBase || '/api/v1';
++ const said = summariesFrom(o.files || [], apiBase);
++ const paths = {};
++ for (const r of routesOf(app._router)) {
++ if (!r.path.startsWith('/api/') || SKIP.test(r.path)) continue;
++ const p = r.path.replace(/:([A-Za-z0-9_]+)\??/g, '{$1}').replace(/\/+$/, '') || '/';
++ const params = [...p.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((x) => ({ name: x[1], in: 'path', required: true, schema: { type: 'string' } }));
++ const op = {
++ operationId: `${r.method}${p.replace(/\{([^}]+)\}/g, 'By_$1').replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))}`,
++ ...(said[`${r.method} ${p}`] ? { summary: said[`${r.method} ${p}`] } : {}),
++ ...(params.length ? { parameters: params } : {}),
++ responses: { default: { description: o.docs ? `See ${o.docs}` : 'See the developer page' } },
++ };
++ paths[p] = paths[p] || {};
++ if (!paths[p][r.method]) paths[p][r.method] = op;
++ }
++ return {
++ openapi: '3.1.0',
++ info: { title: o.title, version: String(o.version || '1'), description: `${o.description || ''} The paths and methods are the routes this service mounts${o.docs ? `; the request and response bodies are on ${o.docs}` : ''}.`.trim() },
++ servers: [{ url: String(o.baseUrl).replace(/\/+$/, '') }],
++ ...(o.docs ? { externalDocs: { url: o.docs } } : {}),
++ paths: Object.fromEntries(Object.keys(paths).sort().map((k) => [k, paths[k]])),
++ };
++ };
++ return (_req, res) => {
++ if (!doc) doc = build();
++ res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json(doc);
++ };
++}
++
++module.exports = { openapiRoute, routesOf, summariesFrom, mountPathOf };
ddiff --git a/test/openapi-root.test.js b/test/openapi-root.test.js
nnew file mode 100644
iindex 0000000..d80e562
---- /dev/null
++++ b/test/openapi-root.test.js
@@@ -0,0 +1,47 @@
++'use strict';
++// SEO-3: /openapi.json answered 404 here (11-03). It is this service's API as OpenAPI 3.1 (routes: 'serve' is the
++// document /api/v1/openapi.json already served; 'routes' is read from the routes the app mounts).
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ let failed = 0;
++ try {
++ const r = await t.get('/openapi.json');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.match(String(r.headers.get('content-type')), /^application\/json/);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any agent may read it');
++ const doc = JSON.parse(r.text);
++ assert.match(doc.openapi, /^3\.1/);
++ assert.ok(doc.info && doc.info.title && doc.info.version, 'a title and a version');
++ const paths = Object.keys(doc.paths || {});
++ assert.ok(paths.length >= 2, `paths: ${paths.join(' ')}`);
++ for (const p of paths) {
++ for (const [m, op] of Object.entries(doc.paths[p])) {
++ if (!['get', 'put', 'post', 'delete', 'patch'].includes(m)) continue;
++ assert.ok(op.responses && Object.keys(op.responses).length, `${m} ${p} has responses`);
++ for (const name of [...p.matchAll(/\{([^}]+)\}/g)].map((x) => x[1])) {
++ assert.ok((op.parameters || []).some((q) => q.name === name && q.in === 'path') || (doc.paths[p].parameters || []).some((q) => q.name === name), `${m} ${p} declares {${name}}`);
++ }
++ }
++ }
++ if ('routes' === 'routes') {
++ assert.ok(paths.every((p) => p.startsWith('/api/')), 'only the API');
++ assert.ok(!paths.some((p) => /\/(?:health|ready)$|openapi\.json$/.test(p)), 'no probes, not itself');
++ assert.ok(Object.values(doc.paths).some((item) => Object.values(item).some((op) => op.summary)), 'the header comments give summaries');
++ const again = JSON.parse((await t.get('/openapi.json')).text);
++ assert.deepStrictEqual(Object.keys(again.paths), paths, 'the same document each time');
++ } else {
++ const api = await t.get('/api/v1/openapi.json');
++ assert.deepStrictEqual(JSON.parse(api.text), doc, 'the same document as /api/v1/openapi.json');
++ }
++ console.log(` ✓ /openapi.json: ${paths.length} paths`);
++ } catch (e) {
++ failed++;
++ console.log(` ✗ /openapi.json\n ${String(e.stack || e).split('\n').slice(0, 6).join('\n ')}`);
++ } finally {
++ await t.close();
++ }
++ process.exit(failed ? 1 : 0);
++})();