openvibers/openvibe.search Commits
API: publish OpenAPI 3.1 at /openapi.json (#129)
Changes
@@@ -19,6 +19,7 @@ const { pageRouter } = require('./web/page');const { webhookRouter } = require('./api/webhook');const { createLimits } = require('./actor-limits');const { createIndexNow } = require('openvibe-shared/indexnow');++const openapi = require('./openapi');const pkg = require('../package.json');function createApp({ config, db, store, engine, auth, outbox, purges, purger, saved, searcher: givenSearcher = null, notifier = null, vectorStore = null, embeddings = null, fetchImpl = globalThis.fetch, log = console, now }) {@@@ -68,6 +69,8 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa// fails; the Network key and index consistency are optional and degrade it (see observability.js).const readiness = createSearchReadiness({ db, config, engine, store, outbox, purges, purger, saved, notifier, release: release.release });app.get('/api/ready', readiness.handler);++ // GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.++ openapi.mount(app);// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).release.mount(app, { registry: metrics.registry });nnew file mode 100644@@@ -0,0 +1,209 @@++'use strict';++/**++ * OpenAPI 3.1 for OpenVibe.Search's 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++ * exactly these routers, 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 the 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 owner-side indexing API (PUT/DELETE /api/v1/documents,++ * GET /api/v1/owners/…) needs a service token and is the network's internal write path, not the++ * public query surface: it stays out. /internal/events (the signed Events webhook) and /mcp are++ * outside /api.++ *++ * Request fields are what server/api/*.js reads; response properties are the projections those++ * handlers and server/saved.js serialize. Nothing is inferred.++ */++const pkg = require('../package.json');++++const MOUNT = '/api/v1';++const SITE = 'https://openvibe.search';++++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, 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: [] }, { cookie: [] }];++const PERSON = [{ bearer: [] }, { cookie: [] }];++const SERVICE = [{ bearer: [] }];++++const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });++const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });++++const owner = str({ pattern: '^[a-z][a-z0-9-]{1,39}$' });++const type = str({ pattern: '^[a-z][a-z0-9_]{1,39}$' });++const id = str({ pattern: '^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}$' });++const filterParams = [++ query('owner', owner, 'Only this owner\'s documents'), query('type', type, 'Only this document type'),++ query('lang', str({ pattern: '^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$' }), 'Only this language'),++ query('facet.<key>', str({ maxLength: 200 }), 'A facet filter; repeat for several values'),++ query('facets', str(), 'Comma-separated facet keys to count (at most 5)'),++ query('limit', integer({ minimum: 1 }), 'Page size'), query('cursor', str(), 'The next_cursor from the previous page'),++];++const searchBody = { type: 'object', required: ['results', 'next_cursor', 'explain'], properties: {++ results: arrayOf(ref('DocumentMatch')), next_cursor: nullable('string'),++ explain: { type: 'object', properties: { ranking: { type: 'string', enum: ['keyword', 'hybrid'] }, reason: nullable('string') } },++ facets: { type: 'object', additionalProperties: arrayOf({ type: 'object' }) },++} };++++const PATHS = {};++function route(method, p, operation) {++ (PATHS[p] = PATHS[p] || {})[method] = operation;++}++++// ── Query ─────────────────────────────────────────────────────────────────────────────────────++route('get', '/search', {++ operationId: 'search', summary: 'Search the index as the caller', tags: ['query'], security: ANONYMOUS,++ description: 'Visible to any origin (Access-Control-Allow-Origin *). Anonymous callers see public, published, indexable documents only; a user token adds documents whose ACL names the person; a service token with search.query.delegate and X-OV-Subject adds that person\'s groups and entitlements.',++ parameters: [query('q', str({ maxLength: 500 }), 'The words; an empty q browses'), ...filterParams],++ responses: { 200: json('A page of matches and how it was ranked', searchBody), ...errors() },++});++route('get', '/suggest', {++ operationId: 'suggest', summary: 'Title suggestions for a search box', tags: ['query'], security: ANONYMOUS,++ description: 'Visible to any origin. At most 20 suggestions; q is at most 100 characters.',++ parameters: [query('q', str({ maxLength: 100 })), query('owner', owner), query('type', type), query('limit', integer({ minimum: 1, maximum: 20 }))],++ responses: { 200: json('Suggestions', { type: 'object', required: ['suggestions'], properties: { suggestions: arrayOf(ref('Suggestion')) } }), ...errors() },++});++route('get', '/documents/{owner}/{type}/{id}', {++ operationId: 'getDocument', summary: 'One document by its exact id', tags: ['query'], security: ANONYMOUS,++ description: 'A document the caller may not see and one that does not exist give the same 404. Nothing here returns an ACL, a draft, an unpublished or deleted document.',++ parameters: [path('owner', owner), path('type', type), path('id', id)],++ responses: { 200: json('The document', { type: 'object', required: ['document'], properties: { document: ref('DocumentMatch') } }), ...errors([400, 404, 429]) },++});++++// ── Search Fabric ────────────────────────────────────────────────────────────────────────────++route('post', '/query', {++ operationId: 'fabricQuery', summary: 'One Search Fabric request (index plus the web ladder)', tags: ['fabric'], security: ANONYMOUS,++ description: 'search.fabric.query when a service token presents it; a browser caller is anonymous. The body is search.fabric-request@1.',++ requestBody: body({ type: 'object' }),++ responses: { 200: json('search.fabric-result@1', { type: 'object' }), ...errors([400, 401, 403, 429]) },++});++route('get', '/query', {++ operationId: 'fabricQueryGet', summary: 'The same Fabric request in the query string', tags: ['fabric'], security: ANONYMOUS,++ description: 'Visible to any origin.',++ parameters: [query('q', str()), query('scopes', str({ description: 'Comma-separated scopes' })), query('freshness', str()), query('depth', str()),++ query('cursor', str()), query('limit', integer({ minimum: 1 })), query('owner', owner), query('type', type), query('lang', str()), query('site', str())],++ responses: { 200: json('search.fabric-result@1', { type: 'object' }), ...errors([400, 401, 403, 429]) },++});++route('get', '/query/providers', {++ operationId: 'fabricProviders', summary: 'The web ladder\'s provider offers', tags: ['fabric'], security: ANONYMOUS,++ description: 'Visible to any origin; search.provider-offer@1 items.',++ responses: { 200: json('Providers', { type: 'object', required: ['providers'], properties: { providers: arrayOf({ type: 'object' }) } }), ...errors([429]) },++});++++// ── Saved searches ───────────────────────────────────────────────────────────────────────────++route('get', '/saved-searches', {++ operationId: 'listSavedSearches', summary: 'The caller\'s saved searches, newest first', tags: ['saved-searches'], security: PERSON,++ responses: { 200: json('Saved searches and the per-person maximum', { type: 'object', required: ['saved_searches'], properties: {++ saved_searches: arrayOf(ref('SavedSearch')), max: integer(),++ } }), ...errors([401, 403, 429]) },++});++route('post', '/saved-searches', {++ operationId: 'saveSearch', summary: 'Save a query (201 new, 200 already saved)', tags: ['saved-searches'], security: PERSON,++ description: 'A saved search needs words or at least one filter. A cookie-authenticated write must come from this origin.',++ requestBody: body({ type: 'object', properties: {++ name: str({ maxLength: 100 }), q: str({ maxLength: 500 }), owner, type, lang: str(),++ facets: { type: 'object', additionalProperties: { oneOf: [str(), arrayOf(str())] } },++ } }, false),++ responses: { 201: json('The saved search', { type: 'object', required: ['saved_search'], properties: { saved_search: ref('SavedSearch') } }),++ 200: json('Already saved: the existing one', { type: 'object', required: ['saved_search'], properties: { saved_search: ref('SavedSearch') } }), ...errors() },++});++route('get', '/saved-searches/{id}', {++ operationId: 'getSavedSearch', summary: 'One of the caller\'s saved searches', tags: ['saved-searches'], security: PERSON,++ parameters: [path('id', str())],++ responses: { 200: json('The saved search', { type: 'object', required: ['saved_search'], properties: { saved_search: ref('SavedSearch') } }), ...errors([401, 403, 404, 429]) },++});++route('delete', '/saved-searches/{id}', {++ operationId: 'deleteSavedSearch', summary: 'Delete one of the caller\'s saved searches', tags: ['saved-searches'], security: PERSON,++ parameters: [path('id', str())],++ responses: { 204: { description: 'Deleted' }, ...errors([401, 403, 404, 429]) },++});++route('get', '/saved-searches/{id}/results', {++ operationId: 'runSavedSearch', summary: 'Run a saved search now, as the caller', tags: ['saved-searches'], security: PERSON,++ description: 'The ACL applies when it runs, never when it was saved.',++ parameters: [path('id', str()), query('limit', integer({ minimum: 1 })), query('cursor', str())],++ responses: { 200: json('The saved search and a page of matches', { allOf: [searchBody, { type: 'object', properties: {++ saved_search: { type: 'object', required: ['id', 'name'], properties: { id: str(), name: str() } },++ } }] }), ...errors([401, 403, 404, 429]) },++});++++function openapi() {++ return {++ openapi: '3.1.0',++ info: {++ title: 'OpenVibe.Search 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.Search', email: '[email protected]' },++ },++ servers: [{ url: SITE }],++ tags: ['query', 'fabric', 'saved-searches'].map((name) => ({ name })),++ components: {++ securitySchemes: {++ bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network user JWT (people) or a service token for audience openvibe.search carrying search.query.delegate (acting for X-OV-Subject)' },++ cookie: { type: 'apiKey', in: 'cookie', name: 'ov_token', description: 'The browser session: the same Network user JWT as a host-only cookie' },++ },++ schemas: {++ DocumentMatch: { type: 'object', required: ['owner', 'type', 'id', 'title', 'canonical_url'], properties: {++ owner, type, id, revision: integer(), visibility: { type: 'string', enum: ['public', 'unlisted', 'members', 'private'] },++ title: str(), summary: nullable('string'), canonical_url: str({ format: 'uri' }), facets: nullable('object'),++ language: nullable('string'), authorship: nullable('object'), provenance: nullable('object'),++ published_at: nullable('string', { format: 'date-time' }), updated_at: nullable('string', { format: 'date-time' }),++ indexable: { type: 'boolean' }, snippet_html: str(), ranks: { type: 'object', properties: { keyword: nullable('integer'), vector: nullable('integer') } },++ } },++ Suggestion: { type: 'object', required: ['owner', 'type', 'id', 'title'], properties: {++ owner, type, id, title: str(), canonical_url: str({ format: 'uri' }),++ } },++ SavedSearch: { type: 'object', required: ['id', 'name', 'q', 'filters'], properties: {++ id: str(), name: str(), q: str(), filters: { type: 'object' },++ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }), last_run_at: nullable('string', { format: 'date-time' }),++ } },++ },++ },++ 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 };nnew file mode 100644@@@ -0,0 +1,87 @@++'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, the /api/v1/openapi.json++ * redirect and the service-token owner-side indexing API 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 H = require('./helpers');++const { routes, MOUNT } = require('../server/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;++}++++/** Not public API surface: probes, the redirect, and the service-token document write/reconciliation API. */++const LEFT_OUT = new Set([++ 'GET /api/health', 'GET /api/ready', 'GET /api/v1/openapi.json',++ 'PUT /api/v1/documents/{owner}/{type}/{id}', 'DELETE /api/v1/documents/{owner}/{type}/{id}',++ 'GET /api/v1/owners/{owner}/documents', 'GET /api/v1/owners/{owner}/documents/{type}/{id}',++ 'GET /api/v1/owners/{owner}/rejections', 'GET /api/v1/owners/{owner}/removals',++]);++++(async () => {++ const h = await H.boot();++ try {++ const r = await H.request(h.base, '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');++ assert.ok(r.body, 'the response is JSON');++++ const doc = r.body;++ assert.strictEqual(doc.openapi, '3.1.0');++ assert.ok(doc.paths && typeof doc.paths === 'object', 'paths is an object');++ assert.strictEqual(doc.info.title, 'OpenVibe.Search API');++ assert.deepStrictEqual(doc.servers, [{ url: 'https://openvibe.search' }]);++ const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();++ assert.ok(documented.length > 0, 'paths is not empty');++ 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`);++ }++ }++++ const live = [...mounted(h.app)].filter((key) => key.split(' ')[1].startsWith(`${MOUNT}/`) && !LEFT_OUT.has(key)).sort();++ assert.deepStrictEqual(documented, live, 'every mounted /api/v1 route is documented and vice versa');++ assert.deepStrictEqual(routes(), documented, 'server/openapi.js routes() and the document agree');++++ const redirect = await fetch(`${h.base}/api/v1/openapi.json`, { redirect: 'manual' });++ assert.strictEqual(redirect.status, 308);++ assert.strictEqual(redirect.headers.get('location'), '/openapi.json');++ } finally {++ await h.stop();++ }++ console.log('openapi: /openapi.json documents exactly the public /api/v1 routes');++})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -19,6 +19,7 @@ const { pageRouter } = require('./web/page');
@@ -19,6 +19,7 @@ const { pageRouter } = require('./web/page');
const { webhookRouter } = require('./api/webhook');
const { webhookRouter } = require('./api/webhook');
const { createLimits } = require('./actor-limits');
const { createLimits } = require('./actor-limits');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
+const openapi = require('./openapi');
const pkg = require('../package.json');
const pkg = require('../package.json');
function createApp({ config, db, store, engine, auth, outbox, purges, purger, saved, searcher: givenSearcher = null, notifier = null, vectorStore = null, embeddings = null, fetchImpl = globalThis.fetch, log = console, now }) {
function createApp({ config, db, store, engine, auth, outbox, purges, purger, saved, searcher: givenSearcher = null, notifier = null, vectorStore = null, embeddings = null, fetchImpl = globalThis.fetch, log = console, now }) {
@@ -68,6 +69,8 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa
@@ -68,6 +69,8 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa
// fails; the Network key and index consistency are optional and degrade it (see observability.js).
// fails; the Network key and index consistency are optional and degrade it (see observability.js).
const readiness = createSearchReadiness({ db, config, engine, store, outbox, purges, purger, saved, notifier, release: release.release });
const readiness = createSearchReadiness({ db, config, engine, store, outbox, purges, purger, saved, notifier, release: release.release });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
+ // GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.
+ openapi.mount(app);
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
new file mode 100644
new file mode 100644
@@ -0,0 +1,209 @@
@@ -0,0 +1,209 @@
+'use strict';
+/**
+ * OpenAPI 3.1 for OpenVibe.Search's 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
+ * exactly these routers, 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 the 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 owner-side indexing API (PUT/DELETE /api/v1/documents,
+ * GET /api/v1/owners/…) needs a service token and is the network's internal write path, not the
+ * public query surface: it stays out. /internal/events (the signed Events webhook) and /mcp are
+ * outside /api.
+ *
+ * Request fields are what server/api/*.js reads; response properties are the projections those
+ * handlers and server/saved.js serialize. Nothing is inferred.
+ */
+const pkg = require('../package.json');
+
+const MOUNT = '/api/v1';
+const SITE = 'https://openvibe.search';
+
+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, 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: [] }, { cookie: [] }];
+const PERSON = [{ bearer: [] }, { cookie: [] }];
+const SERVICE = [{ bearer: [] }];
+
+const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });
+const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
+
+const owner = str({ pattern: '^[a-z][a-z0-9-]{1,39}$' });
+const type = str({ pattern: '^[a-z][a-z0-9_]{1,39}$' });
+const id = str({ pattern: '^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}$' });
+const filterParams = [
+ query('owner', owner, 'Only this owner\'s documents'), query('type', type, 'Only this document type'),
+ query('lang', str({ pattern: '^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$' }), 'Only this language'),
+ query('facet.<key>', str({ maxLength: 200 }), 'A facet filter; repeat for several values'),
+ query('facets', str(), 'Comma-separated facet keys to count (at most 5)'),
+ query('limit', integer({ minimum: 1 }), 'Page size'), query('cursor', str(), 'The next_cursor from the previous page'),
+];
+const searchBody = { type: 'object', required: ['results', 'next_cursor', 'explain'], properties: {
+ results: arrayOf(ref('DocumentMatch')), next_cursor: nullable('string'),
+ explain: { type: 'object', properties: { ranking: { type: 'string', enum: ['keyword', 'hybrid'] }, reason: nullable('string') } },
+ facets: { type: 'object', additionalProperties: arrayOf({ type: 'object' }) },
+} };
+
+const PATHS = {};
+function route(method, p, operation) {
+ (PATHS[p] = PATHS[p] || {})[method] = operation;
+}
+
+// ── Query ─────────────────────────────────────────────────────────────────────────────────────
+route('get', '/search', {
+ operationId: 'search', summary: 'Search the index as the caller', tags: ['query'], security: ANONYMOUS,
+ description: 'Visible to any origin (Access-Control-Allow-Origin *). Anonymous callers see public, published, indexable documents only; a user token adds documents whose ACL names the person; a service token with search.query.delegate and X-OV-Subject adds that person\'s groups and entitlements.',
+ parameters: [query('q', str({ maxLength: 500 }), 'The words; an empty q browses'), ...filterParams],
+ responses: { 200: json('A page of matches and how it was ranked', searchBody), ...errors() },
+});
+route('get', '/suggest', {
+ operationId: 'suggest', summary: 'Title suggestions for a search box', tags: ['query'], security: ANONYMOUS,
+ description: 'Visible to any origin. At most 20 suggestions; q is at most 100 characters.',
+ parameters: [query('q', str({ maxLength: 100 })), query('owner', owner), query('type', type), query('limit', integer({ minimum: 1, maximum: 20 }))],
+ responses: { 200: json('Suggestions', { type: 'object', required: ['suggestions'], properties: { suggestions: arrayOf(ref('Suggestion')) } }), ...errors() },
+});
+route('get', '/documents/{owner}/{type}/{id}', {
+ operationId: 'getDocument', summary: 'One document by its exact id', tags: ['query'], security: ANONYMOUS,
+ description: 'A document the caller may not see and one that does not exist give the same 404. Nothing here returns an ACL, a draft, an unpublished or deleted document.',
+ parameters: [path('owner', owner), path('type', type), path('id', id)],
+ responses: { 200: json('The document', { type: 'object', required: ['document'], properties: { document: ref('DocumentMatch') } }), ...errors([400, 404, 429]) },
+});
+
+// ── Search Fabric ────────────────────────────────────────────────────────────────────────────
+route('post', '/query', {
+ operationId: 'fabricQuery', summary: 'One Search Fabric request (index plus the web ladder)', tags: ['fabric'], security: ANONYMOUS,
+ description: 'search.fabric.query when a service token presents it; a browser caller is anonymous. The body is search.fabric-request@1.',
+ requestBody: body({ type: 'object' }),
+ responses: { 200: json('search.fabric-result@1', { type: 'object' }), ...errors([400, 401, 403, 429]) },
+});
+route('get', '/query', {
+ operationId: 'fabricQueryGet', summary: 'The same Fabric request in the query string', tags: ['fabric'], security: ANONYMOUS,
+ description: 'Visible to any origin.',
+ parameters: [query('q', str()), query('scopes', str({ description: 'Comma-separated scopes' })), query('freshness', str()), query('depth', str()),
+ query('cursor', str()), query('limit', integer({ minimum: 1 })), query('owner', owner), query('type', type), query('lang', str()), query('site', str())],
+ responses: { 200: json('search.fabric-result@1', { type: 'object' }), ...errors([400, 401, 403, 429]) },
+});
+route('get', '/query/providers', {
+ operationId: 'fabricProviders', summary: 'The web ladder\'s provider offers', tags: ['fabric'], security: ANONYMOUS,
+ description: 'Visible to any origin; search.provider-offer@1 items.',
+ responses: { 200: json('Providers', { type: 'object', required: ['providers'], properties: { providers: arrayOf({ type: 'object' }) } }), ...errors([429]) },
+});
+
+// ── Saved searches ───────────────────────────────────────────────────────────────────────────
+route('get', '/saved-searches', {
+ operationId: 'listSavedSearches', summary: 'The caller\'s saved searches, newest first', tags: ['saved-searches'], security: PERSON,
+ responses: { 200: json('Saved searches and the per-person maximum', { type: 'object', required: ['saved_searches'], properties: {
+ saved_searches: arrayOf(ref('SavedSearch')), max: integer(),
+ } }), ...errors([401, 403, 429]) },
+});
+route('post', '/saved-searches', {
+ operationId: 'saveSearch', summary: 'Save a query (201 new, 200 already saved)', tags: ['saved-searches'], security: PERSON,
+ description: 'A saved search needs words or at least one filter. A cookie-authenticated write must come from this origin.',
+ requestBody: body({ type: 'object', properties: {
+ name: str({ maxLength: 100 }), q: str({ maxLength: 500 }), owner, type, lang: str(),
+ facets: { type: 'object', additionalProperties: { oneOf: [str(), arrayOf(str())] } },
+ } }, false),
+ responses: { 201: json('The saved search', { type: 'object', required: ['saved_search'], properties: { saved_search: ref('SavedSearch') } }),
+ 200: json('Already saved: the existing one', { type: 'object', required: ['saved_search'], properties: { saved_search: ref('SavedSearch') } }), ...errors() },
+});
+route('get', '/saved-searches/{id}', {
+ operationId: 'getSavedSearch', summary: 'One of the caller\'s saved searches', tags: ['saved-searches'], security: PERSON,
+ parameters: [path('id', str())],
+ responses: { 200: json('The saved search', { type: 'object', required: ['saved_search'], properties: { saved_search: ref('SavedSearch') } }), ...errors([401, 403, 404, 429]) },
+});
+route('delete', '/saved-searches/{id}', {
+ operationId: 'deleteSavedSearch', summary: 'Delete one of the caller\'s saved searches', tags: ['saved-searches'], security: PERSON,
+ parameters: [path('id', str())],
+ responses: { 204: { description: 'Deleted' }, ...errors([401, 403, 404, 429]) },
+});
+route('get', '/saved-searches/{id}/results', {
+ operationId: 'runSavedSearch', summary: 'Run a saved search now, as the caller', tags: ['saved-searches'], security: PERSON,
+ description: 'The ACL applies when it runs, never when it was saved.',
+ parameters: [path('id', str()), query('limit', integer({ minimum: 1 })), query('cursor', str())],
+ responses: { 200: json('The saved search and a page of matches', { allOf: [searchBody, { type: 'object', properties: {
+ saved_search: { type: 'object', required: ['id', 'name'], properties: { id: str(), name: str() } },
+ } }] }), ...errors([401, 403, 404, 429]) },
+});
+
+function openapi() {
+ return {
+ openapi: '3.1.0',
+ info: {
+ title: 'OpenVibe.Search 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.Search', email: '[email protected]' },
+ },
+ servers: [{ url: SITE }],
+ tags: ['query', 'fabric', 'saved-searches'].map((name) => ({ name })),
+ components: {
+ securitySchemes: {
+ bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network user JWT (people) or a service token for audience openvibe.search carrying search.query.delegate (acting for X-OV-Subject)' },
+ cookie: { type: 'apiKey', in: 'cookie', name: 'ov_token', description: 'The browser session: the same Network user JWT as a host-only cookie' },
+ },
+ schemas: {
+ DocumentMatch: { type: 'object', required: ['owner', 'type', 'id', 'title', 'canonical_url'], properties: {
+ owner, type, id, revision: integer(), visibility: { type: 'string', enum: ['public', 'unlisted', 'members', 'private'] },
+ title: str(), summary: nullable('string'), canonical_url: str({ format: 'uri' }), facets: nullable('object'),
+ language: nullable('string'), authorship: nullable('object'), provenance: nullable('object'),
+ published_at: nullable('string', { format: 'date-time' }), updated_at: nullable('string', { format: 'date-time' }),
+ indexable: { type: 'boolean' }, snippet_html: str(), ranks: { type: 'object', properties: { keyword: nullable('integer'), vector: nullable('integer') } },
+ } },
+ Suggestion: { type: 'object', required: ['owner', 'type', 'id', 'title'], properties: {
+ owner, type, id, title: str(), canonical_url: str({ format: 'uri' }),
+ } },
+ SavedSearch: { type: 'object', required: ['id', 'name', 'q', 'filters'], properties: {
+ id: str(), name: str(), q: str(), filters: { type: 'object' },
+ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }), last_run_at: nullable('string', { format: 'date-time' }),
+ } },
+ },
+ },
+ 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'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, the /api/v1/openapi.json
+ * redirect and the service-token owner-side indexing API 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 H = require('./helpers');
+const { routes, MOUNT } = require('../server/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;
+}
+
+/** Not public API surface: probes, the redirect, and the service-token document write/reconciliation API. */
+const LEFT_OUT = new Set([
+ 'GET /api/health', 'GET /api/ready', 'GET /api/v1/openapi.json',
+ 'PUT /api/v1/documents/{owner}/{type}/{id}', 'DELETE /api/v1/documents/{owner}/{type}/{id}',
+ 'GET /api/v1/owners/{owner}/documents', 'GET /api/v1/owners/{owner}/documents/{type}/{id}',
+ 'GET /api/v1/owners/{owner}/rejections', 'GET /api/v1/owners/{owner}/removals',
+]);
+
+(async () => {
+ const h = await H.boot();
+ try {
+ const r = await H.request(h.base, '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');
+ assert.ok(r.body, 'the response is JSON');
+
+ const doc = r.body;
+ assert.strictEqual(doc.openapi, '3.1.0');
+ assert.ok(doc.paths && typeof doc.paths === 'object', 'paths is an object');
+ assert.strictEqual(doc.info.title, 'OpenVibe.Search API');
+ assert.deepStrictEqual(doc.servers, [{ url: 'https://openvibe.search' }]);
+ const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();
+ assert.ok(documented.length > 0, 'paths is not empty');
+ 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`);
+ }
+ }
+
+ const live = [...mounted(h.app)].filter((key) => key.split(' ')[1].startsWith(`${MOUNT}/`) && !LEFT_OUT.has(key)).sort();
+ assert.deepStrictEqual(documented, live, 'every mounted /api/v1 route is documented and vice versa');
+ assert.deepStrictEqual(routes(), documented, 'server/openapi.js routes() and the document agree');
+
+ const redirect = await fetch(`${h.base}/api/v1/openapi.json`, { redirect: 'manual' });
+ assert.strictEqual(redirect.status, 308);
+ assert.strictEqual(redirect.headers.get('location'), '/openapi.json');
+ } finally {
+ await h.stop();
+ }
+ console.log('openapi: /openapi.json documents exactly the public /api/v1 routes');
+})().catch((err) => { console.error(err); process.exitCode = 1; });