openvibers/openvibe.tools Commits
Tools API for developers (platform S10): GET /api/v1/openapi.json, OpenAPI 3.1 generated from the tool descriptors (registry, one run path per tool with its input schema and an example, the jobs facade, problem responses, bearer auth); a /developers page (discover, run, jobs, the openvibe-sdk/tools client, access tiers and limits) linked from the home page, sitemap and llms.txt (new API section)
Changes
nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * OpenAPI 3.1 for the Tools platform API (ADR-027), generated from the tool descriptors (tools.tool@1),++ * so it can never drift from what the gateway serves: GET /api/v1/openapi.json.++ *++ * /api/v1/tools, /api/v1/tools/{id}, /api/v1/tools/{id}/schema the registry (public, CORS *)++ * /api/v1/tools/<id>/run one path per tool with an API, its input++ * schema and an example inline++ * /api/v1/jobs/{id}, /events, /files/{n}, /retry the jobs facade++ *++ * Shared shapes point at the openvibe-contracts schemas by their $id (tools.run@1, tools.job@1, …).++ */++const C = 'https://openvibe.network/contracts/tools';++const ref = (name) => ({ $ref: `${C}/${name}.v1.json` });++const problem = { description: 'RFC 9457 problem (application/problem+json)', content: { 'application/problem+json': { schema: { type: 'object', required: ['type', 'title', 'status'], properties: { type: { type: 'string' }, title: { type: 'string' }, status: { type: 'integer' }, code: { type: 'string' }, detail: { type: 'string' } } } } } };++const idParam = { name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^[a-z][a-z0-9-]{0,39}$' } };++const jobParam = { name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^job_[0-9A-HJKMNP-TV-Z]{26}$' } };++++function runPath(t) {++ const withFiles = !!(t.files && t.files.max > 0);++ const input = t.input && typeof t.input === 'object' && !t.input.$ref ? t.input : { type: 'object' };++ const json = { type: 'object', required: ['input'], additionalProperties: false, properties: { input, wait_ms: { type: 'integer', minimum: 0, maximum: 60000 }, idempotency_key: { type: 'string', maxLength: 200 } } };++ const body = withFiles++ ? { 'multipart/form-data': { schema: { type: 'object', required: ['file'], properties: { file: { type: 'array', items: { type: 'string', format: 'binary' }, minItems: t.files.min || 0, maxItems: t.files.max }, input: { type: 'string', description: 'JSON of the input object' }, wait_ms: { type: 'integer' }, idempotency_key: { type: 'string' } } } } }++ : { 'application/json': { schema: json, ...(t.examples && t.examples[0] ? { example: { input: t.examples[0].input } } : {}) } };++ const security = t.auth && t.auth.anonymous ? [{}, { bearer: [] }] : [{ bearer: [] }];++ return {++ post: {++ operationId: `run_${t.id.replace(/-/g, '_')}`,++ summary: `${t.name}: ${t.summary}`.slice(0, 200),++ tags: [t.family],++ description: [`Capability ${t.auth ? t.auth.capability : 'tools.tool.run'}${t.auth && t.auth.anonymous ? ' (anonymous callers allowed, on the lowest quota tier)' : ' (a token or browser session is required)'}.`,++ `Runs as a ${t.execution === 'job' ? `job (${t.run && t.run.job ? t.run.job.type : 'job'}): 202 with the job unless it finishes within wait_ms` : 'direct call'}; timeout ${t.limits.timeoutMs} ms.`,++ t.egress ? 'Reaches out to the host you name; throttled per target.' : null].filter(Boolean).join(' '),++ security,++ parameters: [{ name: 'Idempotency-Key', in: 'header', required: false, schema: { type: 'string', maxLength: 200 } }],++ requestBody: { required: true, content: body },++ responses: {++ 200: { description: 'Finished (tools.run@1: state succeeded)', content: { 'application/json': { schema: ref('run') } } },++ 202: { description: 'Still running: follow the job (tools.run@1: state queued|running, job, location)', headers: { Location: { schema: { type: 'string' } } }, content: { 'application/json': { schema: ref('run') } } },++ 400: problem, 401: problem, 403: problem, 404: problem, 413: problem, 415: problem, 422: problem, 429: { ...problem, headers: { 'Retry-After': { schema: { type: 'integer' } } } }, 503: problem, 504: problem,++ },++ },++ };++}++++function openapi(snapshot, site) {++ const tools = (snapshot.tools || []).filter((t) => t.api && t.run && t.status !== 'unavailable');++ const paths = {++ '/api/v1/tools': { get: { operationId: 'listTools', summary: 'The tool registry (tools.tool-list@1)', tags: ['registry'], parameters: ['family', 'q', 'execution', 'api'].map((n) => ({ name: n, in: 'query', required: false, schema: { type: 'string' } })), responses: { 200: { description: 'Every tool', content: { 'application/json': { schema: ref('tool-list') } } } } } },++ '/api/v1/tools/{id}': { get: { operationId: 'getTool', summary: 'One tool\'s descriptor (tools.tool@1)', tags: ['registry'], parameters: [idParam], responses: { 200: { description: 'The descriptor', content: { 'application/json': { schema: ref('tool') } } }, 404: problem } } },++ '/api/v1/tools/{id}/schema': { get: { operationId: 'getToolSchema', summary: 'A tool\'s input and output JSON Schemas', tags: ['registry'], parameters: [idParam], responses: { 200: { description: '{ $schema, $id, $defs: { input, output } }' }, 404: problem } } },++ '/api/v1/jobs/{id}': { get: { operationId: 'getJob', summary: 'A job (tools.job@1)', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'The job', content: { 'application/json': { schema: ref('job') } } }, 404: problem } },++ delete: { operationId: 'cancelJob', summary: 'Cancel a job', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'Cancelled (or already finished)' }, 404: problem } } },++ '/api/v1/jobs/{id}/events': { get: { operationId: 'jobEvents', summary: 'Job progress as server-sent events (Last-Event-ID resumes)', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'text/event-stream' } } } },++ '/api/v1/jobs/{id}/files/{n}': { get: { operationId: 'jobFile', summary: 'Download result file n', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam, { name: 'n', in: 'path', required: true, schema: { type: 'integer', minimum: 0 } }], responses: { 200: { description: 'The file' }, 404: problem } } },++ '/api/v1/jobs/{id}/retry': { post: { operationId: 'retryJob', summary: 'Retry a failed job', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'The new job', content: { 'application/json': { schema: ref('job') } } }, 409: problem } } },++ };++ for (const t of tools) paths[t.run.path] = runPath(t);++ return {++ openapi: '3.1.0',++ info: {++ title: 'OpenVibe.Tools API', version: String(snapshot.version || '1'),++ description: `Every tool on ${site} with an API, generated from the tool descriptors (tools.tool@1). Anonymous calls run on the lowest quota tier; a Network service or app token (audience openvibe.tools) raises it. The SDK is openvibe-sdk/tools (createToolsClient). Errors are RFC 9457 problems; 429 carries Retry-After.`,++ license: { name: 'MIT' }, contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.Tools', email: '[email protected]' },++ },++ servers: [{ url: site }],++ tags: [{ name: 'registry' }, { name: 'jobs' }, ...[...new Set(tools.map((t) => t.family))].sort().map((f) => ({ name: f }))],++ components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or app token for audience openvibe.tools (tools.tool.run; tools.net.probe for probes)' } } },++ paths,++ };++}++++module.exports = { openapi };@@@ -165,7 +165,7 @@ ${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks"><div><b>Without signing in</b><p>Every tool works. Results stay available for an hour, then they are deleted.</p></div><div><b>Signed in</b><p>Results keep for 24 hours, your recent tools follow you across devices, and your theme and notifications come along from the rest of OpenVibe.</p></div>--<div><b>For developers</b><p>The whole catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></section>`;++<div><b>For developers</b><p>Run any tool from code: see <a href="/developers">the Tools API</a> (<a href="/api/v1/openapi.json">OpenAPI</a>). The catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></section>`;return shell({ families, body, head: head({title: 'Online Tools for Files, Text, Code & Networks', description: `${tools.length} online tools in one place: video and audio converters, image and PDF tools, developer utilities and network diagnostics. Open source and community-run.`,canonical: SITE + '/', keywords: 'online tools, converter, pdf tools, image converter, developer tools, network tools, youtube downloader',@@@ -230,6 +230,41 @@ function cached(key, render) {if (!e || e.ver !== ver) { const html = render(); e = { ver, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' }; cache.set(key, e); }return e;}++/** /developers: how to call any tool from code (run API, jobs, SDK, auth tiers, limits). */++function pageDevelopers() {++ const { families } = registry.get();++ const snap = require('../registry/descriptors').snapshot();++ const withApi = snap.tools.filter((t) => t.api && t.status !== 'unavailable');++ const code = (s) => `<pre><code>${esc(s)}</code></pre>`;++ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › Developers</nav><div class="page-h">${icon('code', 56)}<h1>Tools API</h1></div>++<p class="lead">${withApi.length} of ${snap.tools.length} tools can be called from code, with the same engines the pages use. One request shape for all of them; long work runs as a job you can follow.</p>++<section><div class="sec-h"><h2>Discover</h2></div><p class="sec-p">Every tool is described by a machine-readable descriptor (inputs as JSON Schema, file limits, how it runs, who may call it).</p>++<ul><li><a href="/api/v1/tools">/api/v1/tools</a>: the registry (filter with <code>?family=</code>, <code>?q=</code>, <code>?api=true</code>)</li>++<li><code>/api/v1/tools/{id}</code> and <code>/api/v1/tools/{id}/schema</code>: one tool</li>++<li><a href="/api/v1/openapi.json">/api/v1/openapi.json</a>: OpenAPI 3.1, generated from the descriptors</li></ul></section>++<section><div class="sec-h"><h2>Run a tool</h2></div>++${code(`curl -X POST ${SITE}/api/v1/tools/jsonminify/run \\++ -H 'Content-Type: application/json' \\++ -d '{"input":{"text":"{ \\"a\\": 1 }"}}'++# → {"state":"succeeded","tool":"jsonminify","result":{"text":"{\\"a\\":1}"},"took_ms":3}`)}++<p class="sec-p">File tools take <code>multipart/form-data</code> (<code>file</code> parts plus an <code>input</code> JSON part). Work that outlasts <code>wait_ms</code> answers <b>202</b> with a job: follow it at <code>/api/v1/jobs/{id}/events</code> (server-sent events) and download results from <code>/api/v1/jobs/{id}/files/{n}</code>. Send an <code>Idempotency-Key</code> header to make retries safe.</p></section>++<section><div class="sec-h"><h2>From JavaScript</h2></div>++${code(`const { createClient } = require('openvibe-sdk/core');++const { createToolsClient } = require('openvibe-sdk/tools');++const tools = createToolsClient(createClient({}));++const out = await tools.run('jsonminify', { text: '{ "a": 1 }' });++const job = await tools.run('png', { format: 'png' }, { files: [fileBlob] });++const done = await job.wait(); // any tool, sync or job`)}++<p class="sec-p">The SDK is <a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a> (tag v0.6.0+). It retries on 429 after <code>Retry-After</code> and generates idempotency keys for you.</p></section>++<section><div class="sec-h"><h2>Access and limits</h2></div>++<ul><li><b>Anonymous</b>: most tools, on the lowest quota tier, per address. Tools that handle files people should not share anonymously (audio, PDF) need a browser session or a token.</li>++<li><b>Apps and services</b>: a token from <a href="https://openvibe.network">OpenVibe.Network</a> for audience <code>openvibe.tools</code> with <code>tools.tool.run</code> (developer sandbox apps get it by default) raises the tier.</li>++<li><b>Network probes</b> (port checks, ping, latency) need <code>tools.net.probe</code>, granted to partners only. The YouTube downloader has no API.</li>++<li>Every answer carries <code>RateLimit-*</code> headers; over the limit is <b>429</b> with <code>Retry-After</code>. Errors are RFC 9457 problems with a stable <code>code</code>.</li></ul></section>`;++ return shell({ families, body, head: head({ title: 'Tools API for developers', description: `Call ${withApi.length} OpenVibe tools from code: one run API, jobs for long work, an OpenAPI document and the openvibe-sdk client.`, canonical: SITE + '/developers',++ jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, { name: 'Developers', url: SITE + '/developers' }])] }) });++}++function send(req, res, key, render, type) {const e = cached(key, render);res.set('Content-Type', type || 'text/html; charset=utf-8');@@@ -242,7 +277,7 @@ function send(req, res, key, render, type) {function sitemapEntries() {const { tools, families } = registry.get();const today = new Date().toISOString().slice(0, 10);-- return [{ loc: SITE + '/', changefreq: 'daily', priority: 1 }, { loc: SITE + '/all-tools', changefreq: 'weekly', priority: 0.8 },++ return [{ loc: SITE + '/', changefreq: 'daily', priority: 1 }, { loc: SITE + '/all-tools', changefreq: 'weekly', priority: 0.8 }, { loc: SITE + '/developers', changefreq: 'weekly', priority: 0.7 },...families.filter(f => f.path).map(f => ({ loc: SITE + f.path, changefreq: 'weekly', priority: 0.9 })),...families.filter(f => f.hosts).map(f => ({ loc: f.url, changefreq: 'weekly', priority: 0.9 })),...tools.filter(t => !t.external).flatMap(t => [{ loc: t.url, changefreq: 'weekly', priority: 0.8 }, { loc: `${SITE}/tool/${t.id}`, changefreq: 'monthly', priority: 0.5 }]),@@@ -253,6 +288,8 @@ function createSiteRouter() {const router = express.Router();const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));router.use(apexOnly);++ router.get('/api/v1/openapi.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'openapi', () => JSON.stringify(require('../openapi').openapi(require('../registry/descriptors').snapshot(), SITE)), 'application/json; charset=utf-8'); });++ router.get('/developers', (req, res) => send(req, res, 'developers', pageDevelopers));router.get('/api/catalog.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'catalog', () => JSON.stringify(registry.catalog()), 'application/json; charset=utf-8'); });router.get('/', (req, res) => send(req, res, '/', pageIndex));router.get('/all-tools', (req, res) => send(req, res, '/all-tools', pageAll));@@@ -261,7 +298,7 @@ function createSiteRouter() {router.get('/sitemap.xml', (req, res) => send(req, res, 'sitemap', () => seo.sitemapXml(sitemapEntries()), 'application/xml; charset=utf-8'));router.get('/robots.txt', (req, res) => send(req, res, 'robots', () => seo.robotsTxt({ sitemaps: [SITE + '/sitemap.xml'], disallow: ['/api/', '/auth/'], allow: ['/api/catalog.json'], allowAI: true }), 'text/plain; charset=utf-8'));router.get('/llms.txt', (req, res) => send(req, res, 'llms', () => { const { tools, families } = registry.get(); return seo.llmsTxt({ name: NAME, summary: `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`,-- sections: families.filter(f => f.path).map(f => ({ title: f.name, links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline })) })) }); }, 'text/plain; charset=utf-8'));++ sections: [{ title: 'API', links: [{ title: 'Developer guide', url: SITE + '/developers', note: 'run any tool from code' }, { title: 'OpenAPI 3.1', url: SITE + '/api/v1/openapi.json' }, { title: 'Tool registry (JSON)', url: SITE + '/api/v1/tools' }] }].concat(families.filter(f => f.path).map(f => ({ title: f.name, links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline })) }))) }); }, 'text/plain; charset=utf-8'));router.get('/:slug', (req, res, next) => { const f = registry.get().families.find(x => x.path === '/' + req.params.slug); return f ? send(req, res, f.path, () => pageFamily(f)) : next(); });return router;}nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++// /api/v1/openapi.json is generated from the descriptors: every tool with an API has its run path,++// its input schema inline, unique operation ids, and nothing for tools without an API (yt).++const assert = require('assert');++const { openapi } = require('../server/openapi');++const descriptors = require('../server/registry/descriptors');++++const snap = descriptors.snapshot();++const doc = openapi(snap, 'https://openvibe.tools');++assert.strictEqual(doc.openapi, '3.1.0');++const withApi = snap.tools.filter((t) => t.api && t.run && t.status !== 'unavailable');++for (const t of withApi) {++ const op = doc.paths[t.run.path] && doc.paths[t.run.path].post;++ assert.ok(op, `${t.id} has ${t.run.path}`);++ assert.ok(op.responses['200'] && op.responses['202'] && op.responses['429'], `${t.id} documents 200/202/429`);++}++for (const t of snap.tools.filter((x) => !x.api)) assert.ok(!doc.paths[`/api/v1/tools/${t.id}/run`], `${t.id} has no API, so no run path`);++const ids = Object.values(doc.paths).flatMap((v) => Object.values(v).map((op) => op.operationId));++assert.strictEqual(ids.length, new Set(ids).size, 'operation ids are unique');++assert.ok(doc.paths['/api/v1/tools'] && doc.paths['/api/v1/jobs/{id}/events'], 'registry and jobs facade are documented');++const json = doc.paths['/api/v1/tools/jsonminify/run'].post.requestBody.content['application/json'];++assert.deepStrictEqual(json.schema.properties.input.required, ['text'], 'the tool input schema is inline');++console.log(`openapi: ${withApi.length} run paths, all checks passed`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * OpenAPI 3.1 for the Tools platform API (ADR-027), generated from the tool descriptors (tools.tool@1),
+ * so it can never drift from what the gateway serves: GET /api/v1/openapi.json.
+ *
+ * /api/v1/tools, /api/v1/tools/{id}, /api/v1/tools/{id}/schema the registry (public, CORS *)
+ * /api/v1/tools/<id>/run one path per tool with an API, its input
+ * schema and an example inline
+ * /api/v1/jobs/{id}, /events, /files/{n}, /retry the jobs facade
+ *
+ * Shared shapes point at the openvibe-contracts schemas by their $id (tools.run@1, tools.job@1, …).
+ */
+const C = 'https://openvibe.network/contracts/tools';
+const ref = (name) => ({ $ref: `${C}/${name}.v1.json` });
+const problem = { description: 'RFC 9457 problem (application/problem+json)', content: { 'application/problem+json': { schema: { type: 'object', required: ['type', 'title', 'status'], properties: { type: { type: 'string' }, title: { type: 'string' }, status: { type: 'integer' }, code: { type: 'string' }, detail: { type: 'string' } } } } } };
+const idParam = { name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^[a-z][a-z0-9-]{0,39}$' } };
+const jobParam = { name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^job_[0-9A-HJKMNP-TV-Z]{26}$' } };
+
+function runPath(t) {
+ const withFiles = !!(t.files && t.files.max > 0);
+ const input = t.input && typeof t.input === 'object' && !t.input.$ref ? t.input : { type: 'object' };
+ const json = { type: 'object', required: ['input'], additionalProperties: false, properties: { input, wait_ms: { type: 'integer', minimum: 0, maximum: 60000 }, idempotency_key: { type: 'string', maxLength: 200 } } };
+ const body = withFiles
+ ? { 'multipart/form-data': { schema: { type: 'object', required: ['file'], properties: { file: { type: 'array', items: { type: 'string', format: 'binary' }, minItems: t.files.min || 0, maxItems: t.files.max }, input: { type: 'string', description: 'JSON of the input object' }, wait_ms: { type: 'integer' }, idempotency_key: { type: 'string' } } } } }
+ : { 'application/json': { schema: json, ...(t.examples && t.examples[0] ? { example: { input: t.examples[0].input } } : {}) } };
+ const security = t.auth && t.auth.anonymous ? [{}, { bearer: [] }] : [{ bearer: [] }];
+ return {
+ post: {
+ operationId: `run_${t.id.replace(/-/g, '_')}`,
+ summary: `${t.name}: ${t.summary}`.slice(0, 200),
+ tags: [t.family],
+ description: [`Capability ${t.auth ? t.auth.capability : 'tools.tool.run'}${t.auth && t.auth.anonymous ? ' (anonymous callers allowed, on the lowest quota tier)' : ' (a token or browser session is required)'}.`,
+ `Runs as a ${t.execution === 'job' ? `job (${t.run && t.run.job ? t.run.job.type : 'job'}): 202 with the job unless it finishes within wait_ms` : 'direct call'}; timeout ${t.limits.timeoutMs} ms.`,
+ t.egress ? 'Reaches out to the host you name; throttled per target.' : null].filter(Boolean).join(' '),
+ security,
+ parameters: [{ name: 'Idempotency-Key', in: 'header', required: false, schema: { type: 'string', maxLength: 200 } }],
+ requestBody: { required: true, content: body },
+ responses: {
+ 200: { description: 'Finished (tools.run@1: state succeeded)', content: { 'application/json': { schema: ref('run') } } },
+ 202: { description: 'Still running: follow the job (tools.run@1: state queued|running, job, location)', headers: { Location: { schema: { type: 'string' } } }, content: { 'application/json': { schema: ref('run') } } },
+ 400: problem, 401: problem, 403: problem, 404: problem, 413: problem, 415: problem, 422: problem, 429: { ...problem, headers: { 'Retry-After': { schema: { type: 'integer' } } } }, 503: problem, 504: problem,
+ },
+ },
+ };
+}
+
+function openapi(snapshot, site) {
+ const tools = (snapshot.tools || []).filter((t) => t.api && t.run && t.status !== 'unavailable');
+ const paths = {
+ '/api/v1/tools': { get: { operationId: 'listTools', summary: 'The tool registry (tools.tool-list@1)', tags: ['registry'], parameters: ['family', 'q', 'execution', 'api'].map((n) => ({ name: n, in: 'query', required: false, schema: { type: 'string' } })), responses: { 200: { description: 'Every tool', content: { 'application/json': { schema: ref('tool-list') } } } } } },
+ '/api/v1/tools/{id}': { get: { operationId: 'getTool', summary: 'One tool\'s descriptor (tools.tool@1)', tags: ['registry'], parameters: [idParam], responses: { 200: { description: 'The descriptor', content: { 'application/json': { schema: ref('tool') } } }, 404: problem } } },
+ '/api/v1/tools/{id}/schema': { get: { operationId: 'getToolSchema', summary: 'A tool\'s input and output JSON Schemas', tags: ['registry'], parameters: [idParam], responses: { 200: { description: '{ $schema, $id, $defs: { input, output } }' }, 404: problem } } },
+ '/api/v1/jobs/{id}': { get: { operationId: 'getJob', summary: 'A job (tools.job@1)', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'The job', content: { 'application/json': { schema: ref('job') } } }, 404: problem } },
+ delete: { operationId: 'cancelJob', summary: 'Cancel a job', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'Cancelled (or already finished)' }, 404: problem } } },
+ '/api/v1/jobs/{id}/events': { get: { operationId: 'jobEvents', summary: 'Job progress as server-sent events (Last-Event-ID resumes)', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'text/event-stream' } } } },
+ '/api/v1/jobs/{id}/files/{n}': { get: { operationId: 'jobFile', summary: 'Download result file n', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam, { name: 'n', in: 'path', required: true, schema: { type: 'integer', minimum: 0 } }], responses: { 200: { description: 'The file' }, 404: problem } } },
+ '/api/v1/jobs/{id}/retry': { post: { operationId: 'retryJob', summary: 'Retry a failed job', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'The new job', content: { 'application/json': { schema: ref('job') } } }, 409: problem } } },
+ };
+ for (const t of tools) paths[t.run.path] = runPath(t);
+ return {
+ openapi: '3.1.0',
+ info: {
+ title: 'OpenVibe.Tools API', version: String(snapshot.version || '1'),
+ description: `Every tool on ${site} with an API, generated from the tool descriptors (tools.tool@1). Anonymous calls run on the lowest quota tier; a Network service or app token (audience openvibe.tools) raises it. The SDK is openvibe-sdk/tools (createToolsClient). Errors are RFC 9457 problems; 429 carries Retry-After.`,
+ license: { name: 'MIT' }, contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.Tools', email: '[email protected]' },
+ },
+ servers: [{ url: site }],
+ tags: [{ name: 'registry' }, { name: 'jobs' }, ...[...new Set(tools.map((t) => t.family))].sort().map((f) => ({ name: f }))],
+ components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or app token for audience openvibe.tools (tools.tool.run; tools.net.probe for probes)' } } },
+ paths,
+ };
+}
+
+module.exports = { openapi };
@@ -165,7 +165,7 @@ ${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next
@@ -165,7 +165,7 @@ ${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next
<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">
<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">
<div><b>Without signing in</b><p>Every tool works. Results stay available for an hour, then they are deleted.</p></div>
<div><b>Without signing in</b><p>Every tool works. Results stay available for an hour, then they are deleted.</p></div>
<div><b>Signed in</b><p>Results keep for 24 hours, your recent tools follow you across devices, and your theme and notifications come along from the rest of OpenVibe.</p></div>
<div><b>Signed in</b><p>Results keep for 24 hours, your recent tools follow you across devices, and your theme and notifications come along from the rest of OpenVibe.</p></div>
-<div><b>For developers</b><p>The whole catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></section>`;
+<div><b>For developers</b><p>Run any tool from code: see <a href="/developers">the Tools API</a> (<a href="/api/v1/openapi.json">OpenAPI</a>). The catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></section>`;
return shell({ families, body, head: head({
return shell({ families, body, head: head({
title: 'Online Tools for Files, Text, Code & Networks', description: `${tools.length} online tools in one place: video and audio converters, image and PDF tools, developer utilities and network diagnostics. Open source and community-run.`,
title: 'Online Tools for Files, Text, Code & Networks', description: `${tools.length} online tools in one place: video and audio converters, image and PDF tools, developer utilities and network diagnostics. Open source and community-run.`,
canonical: SITE + '/', keywords: 'online tools, converter, pdf tools, image converter, developer tools, network tools, youtube downloader',
canonical: SITE + '/', keywords: 'online tools, converter, pdf tools, image converter, developer tools, network tools, youtube downloader',
@@ -230,6 +230,41 @@ function cached(key, render) {
@@ -230,6 +230,41 @@ function cached(key, render) {
if (!e || e.ver !== ver) { const html = render(); e = { ver, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' }; cache.set(key, e); }
if (!e || e.ver !== ver) { const html = render(); e = { ver, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' }; cache.set(key, e); }
return e;
return e;
}
}
+/** /developers: how to call any tool from code (run API, jobs, SDK, auth tiers, limits). */
+function pageDevelopers() {
+ const { families } = registry.get();
+ const snap = require('../registry/descriptors').snapshot();
+ const withApi = snap.tools.filter((t) => t.api && t.status !== 'unavailable');
+ const code = (s) => `<pre><code>${esc(s)}</code></pre>`;
+ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › Developers</nav><div class="page-h">${icon('code', 56)}<h1>Tools API</h1></div>
+<p class="lead">${withApi.length} of ${snap.tools.length} tools can be called from code, with the same engines the pages use. One request shape for all of them; long work runs as a job you can follow.</p>
+<section><div class="sec-h"><h2>Discover</h2></div><p class="sec-p">Every tool is described by a machine-readable descriptor (inputs as JSON Schema, file limits, how it runs, who may call it).</p>
+<ul><li><a href="/api/v1/tools">/api/v1/tools</a>: the registry (filter with <code>?family=</code>, <code>?q=</code>, <code>?api=true</code>)</li>
+<li><code>/api/v1/tools/{id}</code> and <code>/api/v1/tools/{id}/schema</code>: one tool</li>
+<li><a href="/api/v1/openapi.json">/api/v1/openapi.json</a>: OpenAPI 3.1, generated from the descriptors</li></ul></section>
+<section><div class="sec-h"><h2>Run a tool</h2></div>
+${code(`curl -X POST ${SITE}/api/v1/tools/jsonminify/run \\
+ -H 'Content-Type: application/json' \\
+ -d '{"input":{"text":"{ \\"a\\": 1 }"}}'
+# → {"state":"succeeded","tool":"jsonminify","result":{"text":"{\\"a\\":1}"},"took_ms":3}`)}
+<p class="sec-p">File tools take <code>multipart/form-data</code> (<code>file</code> parts plus an <code>input</code> JSON part). Work that outlasts <code>wait_ms</code> answers <b>202</b> with a job: follow it at <code>/api/v1/jobs/{id}/events</code> (server-sent events) and download results from <code>/api/v1/jobs/{id}/files/{n}</code>. Send an <code>Idempotency-Key</code> header to make retries safe.</p></section>
+<section><div class="sec-h"><h2>From JavaScript</h2></div>
+${code(`const { createClient } = require('openvibe-sdk/core');
+const { createToolsClient } = require('openvibe-sdk/tools');
+const tools = createToolsClient(createClient({}));
+const out = await tools.run('jsonminify', { text: '{ "a": 1 }' });
+const job = await tools.run('png', { format: 'png' }, { files: [fileBlob] });
+const done = await job.wait(); // any tool, sync or job`)}
+<p class="sec-p">The SDK is <a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a> (tag v0.6.0+). It retries on 429 after <code>Retry-After</code> and generates idempotency keys for you.</p></section>
+<section><div class="sec-h"><h2>Access and limits</h2></div>
+<ul><li><b>Anonymous</b>: most tools, on the lowest quota tier, per address. Tools that handle files people should not share anonymously (audio, PDF) need a browser session or a token.</li>
+<li><b>Apps and services</b>: a token from <a href="https://openvibe.network">OpenVibe.Network</a> for audience <code>openvibe.tools</code> with <code>tools.tool.run</code> (developer sandbox apps get it by default) raises the tier.</li>
+<li><b>Network probes</b> (port checks, ping, latency) need <code>tools.net.probe</code>, granted to partners only. The YouTube downloader has no API.</li>
+<li>Every answer carries <code>RateLimit-*</code> headers; over the limit is <b>429</b> with <code>Retry-After</code>. Errors are RFC 9457 problems with a stable <code>code</code>.</li></ul></section>`;
+ return shell({ families, body, head: head({ title: 'Tools API for developers', description: `Call ${withApi.length} OpenVibe tools from code: one run API, jobs for long work, an OpenAPI document and the openvibe-sdk client.`, canonical: SITE + '/developers',
+ jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, { name: 'Developers', url: SITE + '/developers' }])] }) });
+}
+
function send(req, res, key, render, type) {
function send(req, res, key, render, type) {
const e = cached(key, render);
const e = cached(key, render);
res.set('Content-Type', type || 'text/html; charset=utf-8');
res.set('Content-Type', type || 'text/html; charset=utf-8');
@@ -242,7 +277,7 @@ function send(req, res, key, render, type) {
@@ -242,7 +277,7 @@ function send(req, res, key, render, type) {
function sitemapEntries() {
function sitemapEntries() {
const { tools, families } = registry.get();
const { tools, families } = registry.get();
const today = new Date().toISOString().slice(0, 10);
const today = new Date().toISOString().slice(0, 10);
- return [{ loc: SITE + '/', changefreq: 'daily', priority: 1 }, { loc: SITE + '/all-tools', changefreq: 'weekly', priority: 0.8 },
+ return [{ loc: SITE + '/', changefreq: 'daily', priority: 1 }, { loc: SITE + '/all-tools', changefreq: 'weekly', priority: 0.8 }, { loc: SITE + '/developers', changefreq: 'weekly', priority: 0.7 },
...families.filter(f => f.path).map(f => ({ loc: SITE + f.path, changefreq: 'weekly', priority: 0.9 })),
...families.filter(f => f.path).map(f => ({ loc: SITE + f.path, changefreq: 'weekly', priority: 0.9 })),
...families.filter(f => f.hosts).map(f => ({ loc: f.url, changefreq: 'weekly', priority: 0.9 })),
...families.filter(f => f.hosts).map(f => ({ loc: f.url, changefreq: 'weekly', priority: 0.9 })),
...tools.filter(t => !t.external).flatMap(t => [{ loc: t.url, changefreq: 'weekly', priority: 0.8 }, { loc: `${SITE}/tool/${t.id}`, changefreq: 'monthly', priority: 0.5 }]),
...tools.filter(t => !t.external).flatMap(t => [{ loc: t.url, changefreq: 'weekly', priority: 0.8 }, { loc: `${SITE}/tool/${t.id}`, changefreq: 'monthly', priority: 0.5 }]),
@@ -253,6 +288,8 @@ function createSiteRouter() {
@@ -253,6 +288,8 @@ function createSiteRouter() {
const router = express.Router();
const router = express.Router();
const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));
const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));
router.use(apexOnly);
router.use(apexOnly);
+ router.get('/api/v1/openapi.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'openapi', () => JSON.stringify(require('../openapi').openapi(require('../registry/descriptors').snapshot(), SITE)), 'application/json; charset=utf-8'); });
+ router.get('/developers', (req, res) => send(req, res, 'developers', pageDevelopers));
router.get('/api/catalog.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'catalog', () => JSON.stringify(registry.catalog()), 'application/json; charset=utf-8'); });
router.get('/api/catalog.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'catalog', () => JSON.stringify(registry.catalog()), 'application/json; charset=utf-8'); });
router.get('/', (req, res) => send(req, res, '/', pageIndex));
router.get('/', (req, res) => send(req, res, '/', pageIndex));
router.get('/all-tools', (req, res) => send(req, res, '/all-tools', pageAll));
router.get('/all-tools', (req, res) => send(req, res, '/all-tools', pageAll));
@@ -261,7 +298,7 @@ function createSiteRouter() {
@@ -261,7 +298,7 @@ function createSiteRouter() {
router.get('/sitemap.xml', (req, res) => send(req, res, 'sitemap', () => seo.sitemapXml(sitemapEntries()), 'application/xml; charset=utf-8'));
router.get('/sitemap.xml', (req, res) => send(req, res, 'sitemap', () => seo.sitemapXml(sitemapEntries()), 'application/xml; charset=utf-8'));
router.get('/robots.txt', (req, res) => send(req, res, 'robots', () => seo.robotsTxt({ sitemaps: [SITE + '/sitemap.xml'], disallow: ['/api/', '/auth/'], allow: ['/api/catalog.json'], allowAI: true }), 'text/plain; charset=utf-8'));
router.get('/robots.txt', (req, res) => send(req, res, 'robots', () => seo.robotsTxt({ sitemaps: [SITE + '/sitemap.xml'], disallow: ['/api/', '/auth/'], allow: ['/api/catalog.json'], allowAI: true }), 'text/plain; charset=utf-8'));
router.get('/llms.txt', (req, res) => send(req, res, 'llms', () => { const { tools, families } = registry.get(); return seo.llmsTxt({ name: NAME, summary: `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`,
router.get('/llms.txt', (req, res) => send(req, res, 'llms', () => { const { tools, families } = registry.get(); return seo.llmsTxt({ name: NAME, summary: `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`,
- sections: families.filter(f => f.path).map(f => ({ title: f.name, links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline })) })) }); }, 'text/plain; charset=utf-8'));
+ sections: [{ title: 'API', links: [{ title: 'Developer guide', url: SITE + '/developers', note: 'run any tool from code' }, { title: 'OpenAPI 3.1', url: SITE + '/api/v1/openapi.json' }, { title: 'Tool registry (JSON)', url: SITE + '/api/v1/tools' }] }].concat(families.filter(f => f.path).map(f => ({ title: f.name, links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline })) }))) }); }, 'text/plain; charset=utf-8'));
router.get('/:slug', (req, res, next) => { const f = registry.get().families.find(x => x.path === '/' + req.params.slug); return f ? send(req, res, f.path, () => pageFamily(f)) : next(); });
router.get('/:slug', (req, res, next) => { const f = registry.get().families.find(x => x.path === '/' + req.params.slug); return f ? send(req, res, f.path, () => pageFamily(f)) : next(); });
return router;
return router;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+// /api/v1/openapi.json is generated from the descriptors: every tool with an API has its run path,
+// its input schema inline, unique operation ids, and nothing for tools without an API (yt).
+const assert = require('assert');
+const { openapi } = require('../server/openapi');
+const descriptors = require('../server/registry/descriptors');
+
+const snap = descriptors.snapshot();
+const doc = openapi(snap, 'https://openvibe.tools');
+assert.strictEqual(doc.openapi, '3.1.0');
+const withApi = snap.tools.filter((t) => t.api && t.run && t.status !== 'unavailable');
+for (const t of withApi) {
+ const op = doc.paths[t.run.path] && doc.paths[t.run.path].post;
+ assert.ok(op, `${t.id} has ${t.run.path}`);
+ assert.ok(op.responses['200'] && op.responses['202'] && op.responses['429'], `${t.id} documents 200/202/429`);
+}
+for (const t of snap.tools.filter((x) => !x.api)) assert.ok(!doc.paths[`/api/v1/tools/${t.id}/run`], `${t.id} has no API, so no run path`);
+const ids = Object.values(doc.paths).flatMap((v) => Object.values(v).map((op) => op.operationId));
+assert.strictEqual(ids.length, new Set(ids).size, 'operation ids are unique');
+assert.ok(doc.paths['/api/v1/tools'] && doc.paths['/api/v1/jobs/{id}/events'], 'registry and jobs facade are documented');
+const json = doc.paths['/api/v1/tools/jsonminify/run'].post.requestBody.content['application/json'];
+assert.deepStrictEqual(json.schema.properties.input.required, ['text'], 'the tool input schema is inline');
+console.log(`openapi: ${withApi.length} run paths, all checks passed`);