openvibers/openvibe.fun Commits
/openapi.json: the API as OpenAPI 3.1 (SEO-3) (#72)
Changes
@@@ -10,6 +10,7 @@ module.exports = {site: 'openvibe.fun',api: '/api/v1',docs: '/developers',++ openapi: '/openapi.json',policy: {auth: ['none', 'Network OIDC'],rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',@@@ -167,6 +167,18 @@ async function createApp(opts = {}) {// ── API ─────────────────────────────────────────────────app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.FUN_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}/developers`,++ 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 ───────────────────────────────────────────────nnew file mode 100644@@@ -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 };@@@ -16,7 +16,7 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(m.api, 'https://openvibe.fun/api/v1');assert.strictEqual(m.docs, 'https://openvibe.fun/developers');assert.deepStrictEqual(m.policy.auth, ['none', 'Network OIDC']);-- assert.strictEqual(m.openapi, undefined);++ assert.strictEqual(m.openapi, 'https://openvibe.fun/openapi.json', 'the API document it serves (SEO-3)');assert.strictEqual(m.mcp, undefined);});nnew file mode 100644@@@ -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);++})();
@@ -10,6 +10,7 @@ module.exports = {
@@ -10,6 +10,7 @@ module.exports = {
site: 'openvibe.fun',
site: 'openvibe.fun',
api: '/api/v1',
api: '/api/v1',
docs: '/developers',
docs: '/developers',
+ openapi: '/openapi.json',
policy: {
policy: {
auth: ['none', 'Network OIDC'],
auth: ['none', 'Network OIDC'],
rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',
rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',
@@ -167,6 +167,18 @@ async function createApp(opts = {}) {
@@ -167,6 +167,18 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.FUN_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', rateLimit({ windowMs: 60_000, limit: Number(process.env.FUN_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));
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}/developers`,
+ 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 }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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 };
@@ -16,7 +16,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -16,7 +16,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(m.api, 'https://openvibe.fun/api/v1');
assert.strictEqual(m.api, 'https://openvibe.fun/api/v1');
assert.strictEqual(m.docs, 'https://openvibe.fun/developers');
assert.strictEqual(m.docs, 'https://openvibe.fun/developers');
assert.deepStrictEqual(m.policy.auth, ['none', 'Network OIDC']);
assert.deepStrictEqual(m.policy.auth, ['none', 'Network OIDC']);
- assert.strictEqual(m.openapi, undefined);
+ assert.strictEqual(m.openapi, 'https://openvibe.fun/openapi.json', 'the API document it serves (SEO-3)');
assert.strictEqual(m.mcp, undefined);
assert.strictEqual(m.mcp, undefined);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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);
+})();