openvibers/openvibe.services Commits

API: publish OpenAPI 3.1 at /openapi.json (#142)

OP OpenVibers committed 15 days ago · 6be00ff635 · 3 files, +345 −0

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 0341517..00988e9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -54,6 +54,7 @@ const { createToolRoutes } = require('./http/tools');
const { createPageRoutes } = require('./http/pages');
const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
const { createApi } = require('./http/api');
++const openapi = require('./http/openapi');
const { createServicesReadiness, registerServicesGauges } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createIndexNow } = require('openvibe-shared/indexnow');
@@@ -177,6 +178,8 @@ async function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
const readiness = createServicesReadiness({ store, network, outbox, docs, config, authorities, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
++ // GET /openapi.json: the public OpenAPI 3.1 document (server/http/openapi.js); /api/v1/openapi.json redirects here.
++ openapi.mount(app);
// ── The merged resource index (ADR-048: services.resource.read) ──
// Its own auth (a service token, or a person's token scoped to one project) and JSON-only answers; mounted
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
nnew file mode 100644
iindex 0000000..e91f52c
---- /dev/null
++++ b/server/http/openapi.js
@@@ -0,0 +1,234 @@
++'use strict';
++/**
++ * OpenAPI 3.1 for OpenVibe.Services' JSON API, from the route table below.
++ *
++ * ROUTES is the table: every entry is one method on one path under /api/v1. server/app.js mounts the
++ * resource-index router (server/api/v1.js) and the release/docs router (server/http/api.js), and
++ * test/openapi.test.js walks the mounted stack and fails when the table and the app differ in either
++ * direction — so the document cannot advertise an address this service does not answer.
++ *
++ * GET /openapi.json this document (public, Cache-Control 5 minutes)
++ * GET /api/v1/openapi.json 308 to /openapi.json
++ *
++ * /api/health and /api/ready are probes; the developer console (/projects), the docs pages (/docs),
++ * the release action pages (/releases/:id/*) and SSO (/auth/*) are pages, not this API.
++ *
++ * Request fields are what server/api/v1.js and server/http/api.js read; response properties are the
++ * shapes those handlers and server/domain/*.js serialize. Nothing is inferred.
++ */
++const pkg = require('../../package.json');
++
++const MOUNT = '/api/v1';
++const SITE = 'https://openvibe.services';
++
++const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
++const str = (extra = {}) => ({ type: 'string', ...extra });
++const nullable = (type, extra = {}) => ({ type: [type, 'null'], ...extra });
++const arrayOf = (items) => ({ type: 'array', items });
++const integer = (extra = {}) => ({ type: 'integer', ...extra });
++
++const problem = {
++ description: 'A problem (RFC 9457, application/problem+json): type, title, status and a stable code',
++ content: {
++ 'application/problem+json': {
++ schema: {
++ type: 'object',
++ required: ['type', 'title', 'status'],
++ properties: { type: str(), title: str(), status: integer(), code: str(), detail: str(), request_id: str() },
++ },
++ },
++ },
++};
++const errors = (codes = [400, 401, 403, 404, 409, 413, 422, 429]) => ({
++ ...Object.fromEntries(codes.map((code) => [code, problem])),
++ default: problem,
++});
++
++const path = (name, schema, description) => ({ name, in: 'path', required: true, schema, ...(description && { description }) });
++const query = (name, schema, description) => ({ name, in: 'query', required: false, schema, ...(description && { description }) });
++
++const ANONYMOUS = [{}, { bearer: [] }];
++const SERVICE = [{ bearer: [] }];
++const APP = [{ bearer: [] }];
++
++const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });
++const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
++
++const appId = str({ pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$' });
++const releaseId = str({ pattern: '^rel_[0-9A-HJKMNP-TV-Z]{26}$' });
++
++const PATHS = {};
++function route(method, p, operation) {
++ (PATHS[p] = PATHS[p] || {})[method] = operation;
++}
++
++// ── Resource index ───────────────────────────────────────────────────────────────────────────
++route('get', '/resources', {
++ operationId: 'listResources', summary: 'One merged page of resources over every authority', tags: ['resources'], security: SERVICE,
++ description: 'services.resource.read: a Network service token (any project; ?project= only narrows) or a person\'s user token scoped to one project they own or belong to. A failing authority\'s rows are omitted and named in `partial`.',
++ parameters: [
++ query('project', str({ pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$' }), 'The tenancy boundary'), query('kind', str({ pattern: '^[a-z][a-z0-9-]{0,31}\\.[a-z][a-z0-9-]{0,39}$' })),
++ query('service', str({ pattern: '^[a-z][a-z0-9-]{0,31}$' })), query('cursor', str({ maxLength: 2048 })), query('limit', integer({ minimum: 1 })),
++ ],
++ responses: { 200: json('common.resource-list-result@1', { type: 'object', required: ['resources', 'next_cursor'], properties: {
++ resources: arrayOf(ref('ResourceSummary')), next_cursor: nullable('string'),
++ partial: arrayOf({ type: 'object', properties: { service: str(), code: str() } }),
++ } }), ...errors([400, 401, 403, 404, 429, 502, 503]) },
++});
++route('post', '/resources/control', {
++ operationId: 'controlResource', summary: 'One control operation, routed to the authority that owns the resource', tags: ['resources'], security: SERVICE,
++ description: 'services.resource.control: a service token (any project) or a person\'s user token for a project they own or administer. Body common.resource-control-request@1; the answer is the owning authority\'s common.resource-control-result@1 (200, or 202 while pending). An authority refusal is a result with state refused or failed, never an HTTP error.',
++ requestBody: body(ref('ResourceControlRequest')),
++ responses: { 200: json('common.resource-control-result@1', ref('ResourceControlResult')),
++ 202: json('Pending at the authority', ref('ResourceControlResult')), ...errors([400, 401, 403, 404, 409, 429, 502, 503]) },
++});
++route('get', '/resources/{ovrn}', {
++ operationId: 'getResource', summary: 'One resource by its OVRN, from the authority that owns it', tags: ['resources'], security: SERVICE,
++ description: 'services.resource.read. A malformed OVRN is 400; a person must be a member of the project it names.',
++ parameters: [path('ovrn', str())],
++ responses: { 200: json('common.resource-summary@1', ref('ResourceSummary')), ...errors([400, 401, 403, 404, 429, 502, 503]) },
++});
++route('get', '/authorities', {
++ operationId: 'listAuthorities', summary: 'The authorities this index reads and where they listen', tags: ['resources'], security: SERVICE,
++ description: 'services.resource.read, service principals only. First-party operational data (loopback origins); Services-local, no contract yet.',
++ responses: { 200: json('Authorities', { type: 'object', required: ['authorities'], properties: { authorities: arrayOf(ref('Authority')) } }), ...errors([401, 403, 429]) },
++});
++
++// ── Docs versions and manifest validation ───────────────────────────────────────────────────
++route('get', '/docs/versions', {
++ operationId: 'docsVersions', summary: 'The pinned versions the docs were generated from', tags: ['docs'], security: [{}],
++ responses: { 200: json('Versions and counts', { type: 'object', properties: {
++ contracts: str(), contracts_tag: str(), sdk: str(), sdk_tag: str(), generated_at: str({ format: 'date-time' }),
++ contracts_count: integer(), capabilities_count: integer(),
++ } }), ...errors([429]) },
++});
++route('post', '/manifests/validate', {
++ operationId: 'validateManifest', summary: 'Validate an app or mod manifest', tags: ['docs'], security: [{}],
++ description: 'Rate limited. kind is app or mod; 200 when valid, 422 when not.',
++ requestBody: body({ type: 'object', required: ['kind', 'manifest'], properties: {
++ kind: { type: 'string', enum: ['app', 'mod'] }, manifest: { type: 'object' },
++ } }),
++ responses: { 200: json('Valid, with warnings', ref('ManifestValidation')), 422: { ...problem, description: 'Not valid' }, ...errors([429]) },
++});
++
++// ── Releases ────────────────────────────────────────────────────────────────────────────────
++route('get', '/apps/{app}/releases', {
++ operationId: 'listAppReleases', summary: 'An app\'s public releases and trust tier', tags: ['releases'], security: ANONYMOUS,
++ description: 'services.release.read. Anonymous callers pass; a presented token must be valid and hold services.release.read. Drafts never appear.',
++ parameters: [path('app', appId)],
++ responses: { 200: json('The app, its trust tier and public releases', { type: 'object', required: ['app_id', 'trust', 'releases'], properties: {
++ app_id: str(), trust: ref('Trust'), releases: arrayOf(ref('Release')),
++ } }), ...errors([401, 403, 404, 429]) },
++});
++route('get', '/apps/{app}/trust', {
++ operationId: 'getAppTrust', summary: 'An app\'s trust tier (metadata only)', tags: ['releases'], security: ANONYMOUS,
++ description: 'services.release.read. Grants in OpenVibe.Network are the authority.',
++ parameters: [path('app', appId)],
++ responses: { 200: json('The app and its trust tier', { allOf: [ref('Trust'), { type: 'object', properties: { app_id: str(), note_on_authority: str() } }] }), ...errors([401, 403, 404, 429]) },
++});
++route('get', '/releases/{id}', {
++ operationId: 'getRelease', summary: 'One public release with its manifest', tags: ['releases'], security: ANONYMOUS,
++ description: 'services.release.read. A draft answers 404.',
++ parameters: [path('id', releaseId)],
++ responses: { 200: json('The release and its manifest', { type: 'object', required: ['release'], properties: {
++ release: ref('Release'), manifest: nullable('object'),
++ } }), ...errors([401, 403, 404, 429]) },
++});
++route('post', '/apps/{app}/releases', {
++ operationId: 'createRelease', summary: 'An app creates its own release (draft, or published)', tags: ['releases'], security: APP,
++ description: 'services.release.manage: only an app token, and only for its own app. `publish: true` publishes it in the same call.',
++ parameters: [path('app', appId)],
++ requestBody: body({ type: 'object', required: ['manifest'], properties: {
++ kind: { type: 'string', enum: ['app', 'mod'] }, manifest: { type: 'object' }, notes: str(), publish: { type: 'boolean' },
++ } }),
++ responses: { 201: json('The release and validation warnings', { type: 'object', required: ['release'], properties: {
++ release: ref('Release'), warnings: arrayOf(str()),
++ } }), ...errors([400, 401, 403, 404, 409, 422, 429]) },
++});
++for (const action of ['publish', 'deprecate', 'revoke']) {
++ route('post', `/releases/{id}/${action}`, {
++ operationId: `${action}Release`, summary: `${action[0].toUpperCase()}${action.slice(1)} a release (its app only)`, tags: ['releases'], security: APP,
++ description: 'services.release.manage. deprecate needs a reason; revoke needs a reason and is final.',
++ parameters: [path('id', releaseId)],
++ requestBody: body({ type: 'object', properties: {
++ reason: str({ maxLength: 500 }), replacement: releaseId, notes: str(),
++ } }, action !== 'publish'),
++ responses: { 200: json('The release and the event it announced', { type: 'object', required: ['release'], properties: {
++ release: ref('Release'), event_id: nullable('string'),
++ } }), ...errors([400, 401, 403, 404, 409, 422, 429]) },
++ });
++}
++
++function openapi() {
++ return {
++ openapi: '3.1.0',
++ info: {
++ title: 'OpenVibe.Services API',
++ version: pkg.version,
++ description: pkg.description,
++ license: { name: 'AGPL-3.0-only', identifier: 'AGPL-3.0-only' },
++ contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.Services', email: '[email protected]' },
++ },
++ servers: [{ url: SITE }],
++ tags: ['resources', 'docs', 'releases'].map((name) => ({ name })),
++ components: {
++ securitySchemes: {
++ bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service token for audience openvibe.services carrying the route\'s capability (services.resource.read, services.resource.control, services.release.read), or a person\'s user token scoped to one project' },
++ app: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'An app token for audience openvibe.services carrying services.release.manage; it manages only its own releases' },
++ },
++ schemas: {
++ ResourceSummary: { type: 'object', required: ['id', 'kind', 'service', 'state'], properties: {
++ id: str(), kind: str(), service: str(), project_id: str({ pattern: '^prj_' }),
++ owner: { type: 'object', properties: { type: str(), id: str() } }, name: nullable('string'), state: str(),
++ created_at: str({ format: 'date-time' }), ovrn: str(),
++ } },
++ ResourceControlRequest: { type: 'object', required: ['action', 'idempotency_key'], properties: {
++ resource: str({ description: 'The resource OVRN' }), resource_kind: str(), project_id: str({ pattern: '^prj_' }),
++ on_behalf_of: { type: 'object', properties: { type: str(), id: str() } }, action: str(),
++ dry_run: { type: 'boolean' }, idempotency_key: str(),
++ } },
++ ResourceControlResult: { type: 'object', required: ['action', 'state', 'at'], properties: {
++ action: str(), resource: str(), state: { type: 'string', enum: ['done', 'refused', 'failed', 'pending'] },
++ at: str({ format: 'date-time' }), result: nullable('object'), problem: nullable('object'),
++ } },
++ Authority: { type: 'object', properties: {
++ id: str(), name: str(), origin: str(), public_origin: str(), audience: str(), capability: str(),
++ manifest_version: str(), contracts_range: str(), status: str(),
++ } },
++ ManifestValidation: { type: 'object', required: ['valid'], properties: {
++ valid: { type: 'boolean' }, errors: arrayOf({ type: 'object' }), warnings: arrayOf({ type: 'object' }), contracts_version: str(),
++ } },
++ Trust: { type: 'object', properties: {
++ tier: str(), note: nullable('string'), set_by: nullable('string'), set_at: nullable('string', { format: 'date-time' }),
++ } },
++ Release: { type: 'object', required: ['id', 'app_id', 'status'], properties: {
++ id: releaseId, app_id: appId, project_id: str(), environment: { type: 'string', enum: ['production', 'sandbox'] },
++ kind: { type: 'string', enum: ['app', 'mod'] }, subject_id: str(), name: str(), version: str(),
++ status: { type: 'string', enum: ['draft', 'published', 'deprecated', 'revoked'] }, manifest_id: str(),
++ compatibility: { type: 'object' }, notes: nullable('string'), created_at: str({ format: 'date-time' }), created_by: str(),
++ published_at: nullable('string', { format: 'date-time' }), deprecated_at: nullable('string', { format: 'date-time' }),
++ deprecation_reason: nullable('string'), replacement: nullable('string'), revoked_at: nullable('string', { format: 'date-time' }),
++ revocation_reason: nullable('string'), trust: ref('Trust'),
++ } },
++ },
++ },
++ paths: { ...Object.fromEntries(Object.entries(PATHS).sort(([a], [b]) => a.localeCompare(b)).map(([p, item]) => [MOUNT + p, item])) },
++ };
++}
++
++/** The documented routes as "METHOD /api/v1/path": what the app must mount. */
++function routes() {
++ return Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)).sort();
++}
++
++/** Serve GET /openapi.json here; GET /api/v1/openapi.json redirects to it (308). */
++function mount(app, document = openapi()) {
++ const send = (_req, res) => {
++ res.set('Cache-Control', 'public, max-age=300');
++ res.type('application/json; charset=utf-8').send(JSON.stringify(document));
++ };
++ app.get('/openapi.json', send);
++ app.get('/api/v1/openapi.json', (_req, res) => res.redirect(308, '/openapi.json'));
++}
++
++module.exports = { openapi, routes, mount, MOUNT };
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
nnew file mode 100644
iindex 0000000..7476b7a
---- /dev/null
++++ b/test/openapi.test.js
@@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * /openapi.json documents this service's API, and the document is the API: every route it lists is a
++ * route the app mounts, and every mounted /api/v1 route is listed (the probes and the
++ * /api/v1/openapi.json redirect excepted). The mounted set is read from the real Express stack, so a
++ * route added, renamed or removed without the document fails here.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { routes, MOUNT } = require('../server/http/openapi');
++
++/** The path an app.use(...) layer mounts its router at, decoded from Express's compiled regexp. */
++function mountOf(layer) {
++ const source = String((layer.regexp && layer.regexp.source) || '');
++ if (layer.regexp && layer.regexp.fast_slash) return '';
++ const m = /^\^(.*?)\\\/\?\(\?=\\\/\|\$\)$/.exec(source);
++ assert.ok(m, `cannot read the mount path of a router layer (${source})`);
++ return m[1].replace(/\\\//g, '/').replace(/\\\./g, '.');
++}
++
++/** ":param" as OpenAPI writes it, "{param}". */
++const templated = (p) => String(p).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
++
++/** Every "METHOD /path" the app answers, routers included. */
++function mounted(app) {
++ const out = new Set();
++ const walk = (stack, prefix) => {
++ for (const layer of stack) {
++ if (layer.route) {
++ for (const [method, on] of Object.entries(layer.route.methods)) {
++ if (!on || method === '_all') continue;
++ for (const p of [].concat(layer.route.path)) out.add(`${method.toUpperCase()} ${prefix}${templated(p)}`);
++ }
++ } else if (layer.name === 'router' && layer.handle && layer.handle.stack) {
++ walk(layer.handle.stack, prefix + mountOf(layer));
++ }
++ }
++ };
++ assert.ok(app._router && app._router.stack, 'the app has a router');
++ walk(app._router.stack, '');
++ return out;
++}
++
++const LEFT_OUT = new Set(['GET /api/health', 'GET /api/ready', 'GET /api/v1/openapi.json']);
++/** The resource-index router is mounted behind a path gate, so the stack walk cannot see it: it is
++ * proved mounted by calling each route (an unmatched route answers 404). */
++const GATED = new Set([
++ 'GET /api/v1/resources', 'POST /api/v1/resources/control', 'GET /api/v1/resources/{ovrn}', 'GET /api/v1/authorities',
++]);
++
++(async () => {
++ const t = await boot({ authorities: [] });
++ try {
++ let doc = null;
++ await check('/openapi.json is served as cached JSON', async () => {
++ const r = await t.call('GET', '/openapi.json', { token: null });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('content-type'), 'application/json; charset=utf-8');
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
++ doc = r.json;
++ assert.strictEqual(doc.openapi, '3.1.0');
++ assert.ok(doc.paths && typeof doc.paths === 'object');
++ assert.ok(Object.keys(doc.paths).length > 0, 'paths is not empty');
++ assert.strictEqual(doc.info.title, 'OpenVibe.Services API');
++ assert.deepStrictEqual(doc.servers, [{ url: 'https://openvibe.services' }]);
++ });
++
++ await check('every documented route is mounted, with an operationId and responses', async () => {
++ const live = new Set(mounted(t.app));
++ for (const [p, item] of Object.entries(doc.paths)) {
++ assert.ok(p.startsWith('/api/'), `${p} is outside /api`);
++ for (const [method, op] of Object.entries(item)) {
++ assert.ok(op.operationId, `${method.toUpperCase()} ${p} has an operationId`);
++ assert.ok(op.responses && Object.keys(op.responses).length, `${method.toUpperCase()} ${p} describes its responses`);
++ if (!GATED.has(`${method.toUpperCase()} ${p}`)) assert.ok(live.has(`${method.toUpperCase()} ${p}`), `${method.toUpperCase()} ${p} is documented but not mounted`);
++ }
++ }
++ });
++
++ await check('every mounted /api/v1 route is documented', async () => {
++ const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();
++ const live = [...mounted(t.app)].filter((key) => key.split(' ')[1].startsWith(`${MOUNT}/`) && !LEFT_OUT.has(key) && !GATED.has(key)).sort();
++ assert.deepStrictEqual(live, documented.filter((key) => !GATED.has(key)));
++ assert.deepStrictEqual(documented, routes(), 'server/http/openapi.js routes() and the document agree');
++ for (const key of GATED) assert.ok(documented.includes(key), `${key} is documented`);
++ });
++
++ await check('the gated resource-index routes answer (are mounted)', async () => {
++ const probe = [
++ ['GET', '/api/v1/resources'], ['GET', '/api/v1/authorities'], ['GET', '/api/v1/resources/x'],
++ ['POST', '/api/v1/resources/control'],
++ ];
++ for (const [method, p] of probe) {
++ const r = await t.call(method, p, { body: method === 'POST' ? {} : undefined });
++ assert.notStrictEqual(r.status, 404, `${method} ${p} is not mounted`);
++ }
++ });
++
++ await check('/api/v1/openapi.json redirects to it', async () => {
++ const r = await fetch(`${t.base}/api/v1/openapi.json`, { redirect: 'manual' });
++ assert.strictEqual(r.status, 308);
++ assert.strictEqual(r.headers.get('location'), '/openapi.json');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });