openvibers/openvibe.mediahub Commits

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

OP OpenVibers committed 15 days ago · fa044aa464 · 4 files, +367 −1

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 9403a97..64567cf 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -29,6 +29,7 @@ const { createPrincipal } = require('./http/principal');
const { createSyncKeys } = require('./drive/syncKeys');
const { createDrops } = require('./drive/drops');
const { createApi } = require('./http/api');
++const openapi = require('./http/openapi');
const { createPageRoutes } = require('./http/pages');
const { createVideoRoutes } = require('./video/pages');
const { createPicsRoutes } = require('./pics/pages');
@@@ -160,6 +161,10 @@ async function createApp(opts = {}) {
// origin's /release.json, and so does the host's browser watch.
release.mount(app, { registry: metrics.registry });
++ // GET /openapi.json: the public OpenAPI 3.1 document (server/http/openapi.js), one per request host. Mounted
++ // before host routing so openvibe.video and openvibe.pics serve it too.
++ openapi.mount(app, { hosts: [config.baseUrl, config.video.url, config.pics.url] });
++
// ── Host routing: openvibe.video is OpenVibe.Video, openvibe.pics is OpenVibe.Pics; every other host
// (openvibe.download) stays the drive. On those hosts the drive's pages, API and legal pages never answer.
const videoHost = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
nnew file mode 100644
iindex 0000000..4053921
---- /dev/null
++++ b/server/http/openapi.js
@@@ -0,0 +1,248 @@
++'use strict';
++/**
++ * OpenAPI 3.1 for OpenVibe.MediaHub's drive API, from the route table below.
++ *
++ * ROUTES is the table: every entry is one method on one path under /api/v1. server/http/api.js is
++ * that router, and test/openapi.test.js walks the mounted stack and fails when the two differ in
++ * either direction — so the document cannot advertise an address this service does not answer.
++ *
++ * Choice for the three hosts: one document, built per request. `servers` is the host the request
++ * came in on first, then the other hosts this service answers for (openvibe.download, openvibe.video,
++ * openvibe.pics — the code's configured Pics host; openvibe.media is OpenVibe.Media, a different
++ * service). The /api/v1 surface itself is the drive API: the video and Pics hosts serve pages, not
++ * /api routes, and page paths are outside /api so they do not belong in the document.
++ *
++ * 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; /internal/events is loopback-only. Request fields are what
++ * server/http/api.js reads; response properties are its wire shapes. Nothing is inferred.
++ */
++const pkg = require('../../package.json');
++
++const MOUNT = '/api/v1';
++const DEFAULT_SERVERS = ['https://openvibe.download', 'https://openvibe.video', 'https://openvibe.pics'];
++
++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 PERSON = [{ bearer: [] }, { cookie: [] }];
++const ANONYMOUS = [{}];
++
++const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });
++const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
++
++const PATHS = {};
++function route(method, p, operation) {
++ (PATHS[p] = PATHS[p] || {})[method] = operation;
++}
++
++// ── Liveness ───────────────────────────────────────────────────────────────────────────────────
++route('get', '/ping', {
++ operationId: 'ping', summary: 'Liveness', tags: ['liveness'], security: ANONYMOUS,
++ responses: { 200: json('The service answers', { type: 'object', properties: { ok: { const: true }, service: str() } }), ...errors([429]) },
++});
++
++// ── Files ──────────────────────────────────────────────────────────────────────────────────────
++route('get', '/files', {
++ operationId: 'listFiles', summary: 'One folder of your drive (or the root)', tags: ['files'], security: PERSON,
++ description: 'A person\'s own files: no app, agent or service token can hold them. `before` is the last file id of the previous page.',
++ parameters: [query('folder_id', str(), 'A folder id; absent for the root'), query('limit', integer({ minimum: 1, maximum: 200, default: 100 })), query('before', str(), 'The last file id of the previous page')],
++ responses: { 200: json('The folder, its subfolders, files and usage', { type: 'object', properties: {
++ folder_id: nullable('string'), folders: arrayOf(ref('Folder')), files: arrayOf(ref('File')), usage: ref('Usage'), note: str(),
++ } }), ...errors() },
++});
++route('post', '/files', {
++ operationId: 'startUpload', summary: 'Start a chunked upload (the bytes follow through /uploads/{id}/parts/{n})', tags: ['files'], security: PERSON,
++ description: 'A signed-in session write must come from openvibe.download itself. 201 carries Location: /api/v1/uploads/{id}.',
++ requestBody: body({ type: 'object', required: ['name', 'size'], properties: {
++ name: str(), size: integer({ minimum: 0 }), content_type: str(), folder_id: str(),
++ } }),
++ responses: { 201: json('The upload session and what is still missing', { type: 'object', properties: { upload: { type: 'object' }, note: str() } }), ...errors() },
++});
++route('get', '/files/{id}', {
++ operationId: 'getFile', summary: 'One of your files and its share links', tags: ['files'], security: PERSON,
++ parameters: [path('id', str())],
++ responses: { 200: json('The file and its shares', { type: 'object', properties: { file: ref('File'), shares: arrayOf(ref('Share')) } }), ...errors() },
++});
++route('delete', '/files/{id}', {
++ operationId: 'deleteFile', summary: 'Delete one of your files (and ask Media to delete the object)', tags: ['files'], security: PERSON,
++ parameters: [path('id', str())],
++ responses: { 204: { description: 'Deleted' }, ...errors([401, 403, 404, 429]) },
++});
++
++// ── Folders ────────────────────────────────────────────────────────────────────────────────────
++route('get', '/folders', {
++ operationId: 'listFolders', summary: 'Your folders', tags: ['folders'], security: PERSON,
++ responses: { 200: json('Folders', { type: 'object', properties: { folders: arrayOf(ref('Folder')) } }), ...errors() },
++});
++route('post', '/folders', {
++ operationId: 'createFolder', summary: 'Make a folder', tags: ['folders'], security: PERSON,
++ requestBody: body({ type: 'object', required: ['name'], properties: { name: str(), parent_id: str() } }),
++ responses: { 201: json('The folder', { type: 'object', properties: { folder: ref('Folder') } }), ...errors([401, 403, 409, 422, 429]) },
++});
++
++// ── Shares ────────────────────────────────────────────────────────────────────────────────────
++route('post', '/shares', {
++ operationId: 'createShare', summary: 'Share a file or a folder with a link', tags: ['shares'], security: PERSON,
++ description: 'A share link is made by the person themself (signed in, or their Network token), never by a sync key. `usernames` is an array or a whitespace/comma-separated string; `public: true` makes an anyone-with-the-link share.',
++ requestBody: body({ type: 'object', properties: {
++ file_id: str(), folder_id: str(), hours: integer({ minimum: 0 }), usernames: { oneOf: [str(), arrayOf(str())] }, public: { type: 'boolean' },
++ } }),
++ responses: { 201: json('The share and the names that did not resolve', { type: 'object', properties: { share: ref('Share'), unresolved: arrayOf(str()) } }), ...errors([401, 403, 404, 422, 429]) },
++});
++route('delete', '/shares/{slug}', {
++ operationId: 'revokeShare', summary: 'Revoke one of your share links', tags: ['shares'], security: PERSON,
++ parameters: [path('slug', str())],
++ responses: { 204: { description: 'Revoked' }, ...errors([401, 403, 404, 429]) },
++});
++route('get', '/usage', {
++ operationId: 'getUsage', summary: 'What you hold and what is left', tags: ['usage'], security: PERSON,
++ responses: { 200: json('Usage and the last days', { type: 'object', properties: {
++ usage: ref('Usage'), days: arrayOf({ type: 'object', properties: { day: str(), uploads: integer() } }), note: str(),
++ } }), ...errors() },
++});
++
++// ── Chunked uploads ───────────────────────────────────────────────────────────────────────────
++route('put', '/uploads/{id}/parts/{n}', {
++ operationId: 'putUploadPart', summary: 'One part of a chunked upload (raw bytes)', tags: ['uploads'], security: PERSON,
++ description: 'The body is the part\'s raw bytes, not JSON. A part is at most the service\'s part size.',
++ parameters: [path('id', str()), path('n', integer({ minimum: 0 }))],
++ requestBody: { required: true, content: { 'application/octet-stream': { schema: { type: 'string', contentEncoding: 'binary' } } } },
++ responses: { 200: json('The part and the bytes received', { type: 'object', properties: { part: integer(), size: integer() } }), ...errors([401, 403, 404, 409, 413, 422]) },
++});
++route('get', '/uploads/{id}', {
++ operationId: 'getUpload', summary: 'What a chunked upload is still missing (resume)', tags: ['uploads'], security: PERSON,
++ parameters: [path('id', str())],
++ responses: { 200: json('The upload session', { type: 'object', properties: { upload: { type: 'object' } } }), ...errors([401, 403, 404, 409, 429]) },
++});
++route('post', '/uploads/{id}/complete', {
++ operationId: 'completeUpload', summary: 'Assemble a chunked upload into a file', tags: ['uploads'], security: PERSON,
++ parameters: [path('id', str())],
++ responses: { 201: json('The file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 409, 429]) },
++});
++route('delete', '/uploads/{id}', {
++ operationId: 'abortUpload', summary: 'Abandon a chunked upload', tags: ['uploads'], security: PERSON,
++ parameters: [path('id', str())],
++ responses: { 204: { description: 'Abandoned' }, ...errors([401, 403, 404]) },
++});
++
++// ── Drops: an upload without an account ──────────────────────────────────────────────────────
++route('post', '/drops', {
++ operationId: 'startDrop', summary: 'Start an upload without an account', tags: ['drops'], security: ANONYMOUS,
++ description: 'The token in the answer is sent as X-Drop-Token on the part and complete calls. Off unless drops are enabled here.',
++ requestBody: body({ type: 'object', required: ['name', 'size'], properties: { name: str(), size: integer({ minimum: 0 }), content_type: str() } }),
++ responses: { 201: json('The upload, its token and how to finish it', { type: 'object', properties: { upload: { type: 'object' }, token: str(), note: str() } }), ...errors([403, 413, 429]) },
++});
++route('put', '/drops/{id}/parts/{n}', {
++ operationId: 'putDropPart', summary: 'One part of a drop upload (raw bytes, X-Drop-Token)', tags: ['drops'], security: ANONYMOUS,
++ parameters: [path('id', str()), path('n', integer({ minimum: 0 })), { name: 'X-Drop-Token', in: 'header', required: true, schema: { type: 'string' } }],
++ requestBody: { required: true, content: { 'application/octet-stream': { schema: { type: 'string', contentEncoding: 'binary' } } } },
++ responses: { 200: json('The part and the bytes received', { type: 'object', properties: { part: integer(), size: integer() } }), ...errors([403, 404, 409, 413, 422]) },
++});
++route('get', '/drops/{id}', {
++ operationId: 'getDrop', summary: 'The state of a drop upload', tags: ['drops'], security: ANONYMOUS,
++ parameters: [path('id', str()), { name: 'X-Drop-Token', in: 'header', required: true, schema: { type: 'string' } }],
++ responses: { 200: json('The upload', { type: 'object', properties: { upload: { type: 'object' } } }), ...errors([403, 404]) },
++});
++route('post', '/drops/{id}/complete', {
++ operationId: 'completeDrop', summary: 'Finish a drop upload (the delete link is shown once)', tags: ['drops'], security: ANONYMOUS,
++ parameters: [path('id', str()), { name: 'X-Drop-Token', in: 'header', required: true, schema: { type: 'string' } }],
++ responses: { 201: json('The drop, its delete key and delete URL', { type: 'object', properties: {
++ drop: { type: 'object', properties: { slug: str(), url: str({ format: 'uri' }), name: str(), size: integer(), content_type: str(), created_at: str() } },
++ delete_key: str(), delete_url: str({ format: 'uri' }), note: str(),
++ } }), ...errors([403, 404, 409, 429]) },
++});
++route('delete', '/drops/{id}', {
++ operationId: 'abortDrop', summary: 'Abandon a drop upload', tags: ['drops'], security: ANONYMOUS,
++ parameters: [path('id', str()), { name: 'X-Drop-Token', in: 'header', required: true, schema: { type: 'string' } }],
++ responses: { 204: { description: 'Abandoned' }, ...errors([403, 404]) },
++});
++
++function openapi({ servers = DEFAULT_SERVERS } = {}) {
++ return {
++ openapi: '3.1.0',
++ info: {
++ title: 'OpenVibe.MediaHub 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.MediaHub', email: '[email protected]' },
++ },
++ servers: servers.map((url) => ({ url })),
++ tags: ['liveness', 'files', 'folders', 'shares', 'usage', 'uploads', 'drops'].map((name) => ({ name })),
++ components: {
++ securitySchemes: {
++ bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A person\'s Network user token; a sync key (Bearer ovdl_…) acts for its owner on the drive API only' },
++ cookie: { type: 'apiKey', in: 'cookie', name: 'media-hub_at', description: 'The browser session on openvibe.download' },
++ },
++ schemas: {
++ File: { type: 'object', required: ['id', 'name', 'size', 'content_type'], properties: {
++ id: str(), name: str(), size: integer(), content_type: str(), sha256: nullable('string'), folder_id: nullable('string'),
++ created_at: str(), object: { type: 'object', properties: { media_object_id: nullable('string') } },
++ } },
++ Folder: { type: 'object', required: ['id', 'name'], properties: {
++ id: str(), name: str(), parent_id: nullable('string'), created_at: str(),
++ } },
++ Share: { type: 'object', required: ['slug', 'state'], properties: {
++ slug: str(), url: str(), file_id: nullable('string'), folder_id: nullable('string'), expires_at: nullable('string'),
++ downloads: integer(), report_count: integer(), state: str(), revoked_at: nullable('string'), suspended_at: nullable('string'),
++ created_at: str(), allowed: arrayOf(str()), limited: { type: 'boolean' }, public: { type: 'boolean' },
++ } },
++ Usage: { type: 'object', properties: {
++ bytes_used: integer(), bytes_limit: integer(), max_file_bytes: integer(),
++ files: integer(), uploads_today: integer(), uploads_per_day: integer(),
++ live_shares: integer(), max_shares: integer(),
++ } },
++ },
++ },
++ 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, one document per request: the request's own origin first, then the
++ * other configured hosts. GET /api/v1/openapi.json redirects to it (308).
++ */
++function mount(app, { hosts = DEFAULT_SERVERS } = {}) {
++ const send = (req, res) => {
++ const requestOrigin = `${req.protocol}://${req.get('host')}`;
++ const requestHost = String(req.hostname || '').toLowerCase();
++ const others = hosts.filter((u) => { try { return new URL(u).hostname.toLowerCase() !== requestHost; } catch { return true; } });
++ const document = openapi({ servers: [requestOrigin, ...others] });
++ 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/helpers/boot.js b/test/helpers/boot.js
iindex 94de660..a5f9968 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -72,7 +72,7 @@ async function boot(opts = {}) {
}
const t = {
-- base, network, media, config, ctx: built.ctx, get, signIn, dbDump,
++ base, network, media, config, ctx: built.ctx, app: built.app, get, signIn, dbDump,
logs: () => captured.join('\n'),
async close() {
await new Promise((r) => server.close(r));
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
nnew file mode 100644
iindex 0000000..cd9199b
---- /dev/null
++++ b/test/openapi.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++/**
++ * /openapi.json documents this service's drive 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.
++ *
++ * The document is per request: `servers` starts with the host the request came in on, then the other
++ * hosts this service answers for.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++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']);
++
++(async () => {
++ const t = await boot();
++ try {
++ let doc = null;
++ await check('/openapi.json is served as cached JSON, with this host first', async () => {
++ const r = await t.get('/openapi.json');
++ 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.MediaHub API');
++ assert.ok(doc.servers.length >= 3, 'the other hosts are listed');
++ assert.ok(doc.servers[0].url.includes('127.0.0.1'), `the request host is first (${doc.servers[0].url})`);
++ const hosts = doc.servers.map((s) => new URL(s.url).hostname);
++ for (const host of ['openvibe.download', 'openvibe.video', 'openvibe.pics']) assert.ok(hosts.includes(host), `${host} is listed`);
++ });
++
++ 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`);
++ 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)).sort();
++ assert.deepStrictEqual(live, documented);
++ assert.deepStrictEqual(documented, routes(), 'server/http/openapi.js routes() and the document agree');
++ });
++
++ await check('the document follows the Host header', async () => {
++ const u = new URL(t.base);
++ const text = await new Promise((resolve, reject) => {
++ const req = http.get({ hostname: u.hostname, port: u.port, path: '/openapi.json', headers: { host: 'openvibe.video' } }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
++ });
++ req.on('error', reject);
++ });
++ const hosts = JSON.parse(text).servers.map((s) => new URL(s.url).hostname);
++ assert.strictEqual(hosts[0], 'openvibe.video');
++ assert.ok(!hosts.slice(1).includes('openvibe.video'), 'the request host is not repeated');
++ });
++
++ 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; });