From 40c16a28a26dcc3a7bada1923708f7d92fce051a Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Tue, 20 Oct 2026 14:38:24 +0200 Subject: [PATCH 1/9] [O2B-1623] Add OpenAPI specification of the tags HTTP API Describe the tags endpoints (list, create, get by id or name, update, delete, logs of a tag) with parameters, schemas, errors, required roles and examples. Operations that change data are flagged `x-llm-exclude: true`, and the spec carries instructions for LLMs (always paginate, 100 items max). --- lib/server/api-docs/openapi.yaml | 352 +++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 lib/server/api-docs/openapi.yaml diff --git a/lib/server/api-docs/openapi.yaml b/lib/server/api-docs/openapi.yaml new file mode 100644 index 0000000000..bf90686dda --- /dev/null +++ b/lib/server/api-docs/openapi.yaml @@ -0,0 +1,352 @@ +openapi: 3.1.0 +info: + title: Bookkeeping HTTP API + version: 0.1.0-pilot + description: | + HTTP API of the ALICE Bookkeeping. **Internal use only.** + + > Pilot: only the `tags` resource is documented so far. + + ## Authentication + Every request carries a JWT in the `token` **query parameter** (e.g. `GET /api/tags?token=`). + The token's `access` claim lists the user's roles; operations marked with a role below return `403` otherwise. + + ## Response envelope + Successful responses are `{ "data": ... }`, with `meta.page` added on paginated endpoints. + Failed responses are `{ "errors": [ { status, title, detail?, source? } ] }`. + Note that request-validation failures are sent with **HTTP 400** while each error's `status` field says `"422"`. + + ## Conventions + - Filters use bracket syntax: `filter[partialText]=foo`. List-valued filters are comma separated: `filter[ids]=1,2,3`. + - Pagination uses `page[limit]` and `page[offset]`. + - Timestamps are Unix epoch **milliseconds**. + - Operations flagged `x-llm-exclude: true` change data and are **not meant for LLMs or MCP tools**; skip them when generating tools. + + ## Instructions for LLMs and automated agents + - **Always paginate** when an endpoint supports `page[limit]` and `page[offset]`. Never rely on the default: when `page[limit]` is omitted the server + may return up to 5000 items, which is far too much for a model context. + - **Request at most 100 items per call** (`page[limit]=100`). This is a rule for LLMs and agents, not a server limit (the server accepts more). + - To read more, repeat the call increasing `page[offset]` by the limit until `offset` reaches `meta.page.totalCount`. + - Prefer narrowing the request with `filter[...]` parameters over reading everything and filtering afterwards. + - Endpoints that are not paginated (for example `GET /tags`) return everything: use their filters. + - Do not call operations flagged `x-llm-exclude: true`: they create, change or delete data. +servers: + - url: /api +security: + - jwtQuery: [] +tags: + - name: Tags + description: Labels attached to logs (and runs). Each tag may notify an e-mail list and a Mattermost channel. + +paths: + /tags: + get: + tags: [Tags] + operationId: listTags + summary: List tags + description: | + Returns every tag matching the filters, **not paginated**. + Returns `204 No Content` (empty body) when nothing matches, so clients must not assume a JSON body. + parameters: + - name: filter[ids] + in: query + description: Comma separated tag ids. + schema: { type: string, examples: ['1,2,3'] } + - name: filter[texts] + in: query + description: Comma separated exact tag texts. Takes precedence over `filter[partialText]`. + schema: { type: string, examples: ['FOOD,DRINK'] } + - name: filter[partialText] + in: query + description: Substring match on the tag text. Ignored when `filter[texts]` is given. + schema: { type: string, examples: ['-TAG-'] } + - name: filter[emails] + in: query + description: Comma separated e-mail addresses. + schema: { type: string } + - name: filter[mattermosts] + in: query + description: Comma separated Mattermost channel names. + schema: { type: string } + responses: + '200': + description: Matching tags. + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: array + items: { $ref: '#/components/schemas/Tag' } + '204': { description: "No tag matches the filters. No body." } + '400': { $ref: '#/components/responses/ValidationError' } + post: + tags: [Tags] + operationId: createTag + x-llm-exclude: true + summary: Create a tag + x-required-roles: [admin] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateTag' } + example: + text: FOOD + description: Food related issues + email: cake@cern.ch,test@cern.ch + mattermost: food-channel + color: '#ff8800' + responses: + '201': + description: The created tag. + content: + application/json: + schema: { $ref: '#/components/schemas/TagResponse' } + '400': { $ref: '#/components/responses/ValidationError' } + '403': { $ref: '#/components/responses/Forbidden' } + '409': + description: A tag with the same text already exists. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + errors: + - status: 409 + title: The request conflicts with existing data + detail: The provided entity already exists + + /tags/name: + get: + tags: [Tags] + operationId: getTagByName + summary: Get a tag by its text + description: Looks a tag up by its exact text (case as stored). Use this when you know the label but not the id. + parameters: + - name: name + in: query + required: true + description: Exact tag text. Must be URL-encoded. + schema: { type: string, examples: ['FOOD'] } + responses: + '200': + description: The tag. + content: + application/json: + schema: { $ref: '#/components/schemas/TagResponse' } + '400': { $ref: '#/components/responses/ValidationError' } + '404': { $ref: '#/components/responses/NotFound' } + + /tags/{tagId}: + parameters: + - $ref: '#/components/parameters/TagId' + get: + tags: [Tags] + operationId: getTagById + summary: Get a tag by id + responses: + '200': + description: The tag. + content: + application/json: + schema: { $ref: '#/components/schemas/TagResponse' } + '400': { $ref: '#/components/responses/ValidationError' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Tags] + operationId: updateTag + x-llm-exclude: true + summary: Update a tag + description: | + Only `description`, `email`, `mattermost`, `color` and `archivedAt` can change; + the tag `text` is immutable. Set `archivedAt` to a date to archive the tag and to `null` to restore it. + x-required-roles: [admin] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateTag' } + example: + description: Updated description + archivedAt: null + responses: + '201': + description: The updated tag. (Status is `201`, not `200`.) + content: + application/json: + schema: { $ref: '#/components/schemas/TagResponse' } + '400': { $ref: '#/components/responses/ValidationError' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [Tags] + operationId: deleteTag + x-llm-exclude: true + summary: Delete a tag + x-required-roles: [admin] + responses: + '200': + description: The deleted tag. + content: + application/json: + schema: { $ref: '#/components/schemas/TagResponse' } + '400': { $ref: '#/components/responses/ValidationError' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /tags/{tagId}/logs: + parameters: + - $ref: '#/components/parameters/TagId' + get: + tags: [Tags] + operationId: listLogsByTag + summary: List logs carrying a tag + parameters: + - name: page[limit] + in: query + description: Maximum number of logs to return. LLMs and agents must use 100 or less. + schema: { type: integer, minimum: 1, examples: [100] } + - name: page[offset] + in: query + schema: { type: integer, minimum: 0, default: 0 } + responses: + '200': + description: A page of logs. + content: + application/json: + schema: + type: object + required: [data, meta] + properties: + data: + type: array + description: Log entities (see the Logs resource, not yet documented). + items: { type: object, additionalProperties: true } + meta: + type: object + properties: + page: + type: object + properties: + pageCount: { type: integer } + totalCount: { type: integer } + '400': { $ref: '#/components/responses/ValidationError' } + '404': { $ref: '#/components/responses/NotFound' } + +components: + securitySchemes: + jwtQuery: + type: apiKey + in: query + name: token + description: JWT identifying the user. Its `access` claim carries the roles (e.g. `admin`, `guest`). + + parameters: + TagId: + name: tagId + in: path + required: true + description: Tag identifier. + schema: { type: integer, minimum: 1 } + + responses: + ValidationError: + description: | + The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. + `source.pointer` has the form `/data/attributes//`. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + errors: + - status: '422' + source: { pointer: /data/attributes/params/tagId } + title: Invalid Attribute + detail: '"params.tagId" must be a number' + Forbidden: + description: The token lacks the role required by this operation. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + errors: [{ status: '403', title: Access denied }] + NotFound: + description: The requested entity does not exist. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + errors: [{ status: '404', title: 'Tag with this id (999999999) could not be found' }] + + schemas: + Tag: + type: object + required: [id, text, archived, updatedAt] + properties: + id: { type: integer, minimum: 1 } + text: { type: string, minLength: 2, maxLength: 20, description: "Unique label. Immutable." } + description: { type: [string, 'null'], maxLength: 100 } + email: + type: [string, 'null'] + description: Comma separated e-mail addresses notified when the tag is used. + mattermost: { type: [string, 'null'], description: "Mattermost channel notified when the tag is used." } + color: + type: [string, 'null'] + pattern: '^#[0-9A-Fa-f]{6}$' + description: Hex colour, e.g. `#ff8800`. + archived: { type: boolean, description: "True when `archivedAt` is set." } + archivedAt: { type: [integer, 'null'], description: "Epoch milliseconds, null if not archived." } + lastEditedBy: + type: [object, 'null'] + properties: + name: { type: string } + updatedAt: { type: integer, description: "Epoch milliseconds." } + TagResponse: + type: object + required: [data] + properties: + data: { $ref: '#/components/schemas/Tag' } + CreateTag: + type: object + required: [text] + properties: + text: + type: string + minLength: 2 + maxLength: 20 + pattern: '[\w|\-+]{1,}' + description: Words, digits, `-` and `+` only. + description: { type: [string, 'null'], maxLength: 100 } + mattermost: { type: [string, 'null'] } + email: + type: [string, 'null'] + description: One or more e-mail addresses, comma separated. + color: { type: [string, 'null'], pattern: '^#[0-9A-Fa-f]{6}$' } + UpdateTag: + type: object + description: All properties optional; `text` cannot be changed. + properties: + description: { type: [string, 'null'], maxLength: 100 } + mattermost: { type: [string, 'null'] } + email: { type: [string, 'null'], description: "One or more e-mail addresses, comma separated." } + color: { type: [string, 'null'], pattern: '^#[0-9A-Fa-f]{6}$' } + archivedAt: { type: [string, 'null'], format: date-time, description: "Date to archive the tag, null to restore it." } + ErrorResponse: + type: object + required: [errors] + properties: + errors: + type: array + items: + type: object + required: [status, title] + properties: + status: { type: [string, integer] } + title: { type: string } + detail: { type: string } + source: + type: object + properties: + pointer: { type: string } From 995c19c201c2d0ff8915fa252a391757b37c47c7 Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Tue, 20 Oct 2026 14:38:24 +0200 Subject: [PATCH 2/9] [O2B-1623] Add generator of the HTTP API documentation `npm run docs:api` builds `API.md` (Markdown with curl examples) and `index.html` (styled page, collapsible endpoints) from the OpenAPI spec. - Fails when a non-GET operation does not declare `x-llm-exclude` - Spec content is treated as untrusted when rendering: raw HTML is escaped, unsafe link schemes are dropped, and values put in curl examples are shell-quoted - New devDependencies `js-yaml` (already in the lockfile through eslint) and `marked`, pinned to a release older than two weeks --- package-lock.json | 15 ++ package.json | 3 + scripts/api-docs.css | 40 +++++ scripts/build-api-docs.js | 339 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 scripts/api-docs.css create mode 100644 scripts/build-api-docs.js diff --git a/package-lock.json b/package-lock.json index 8d70fc4ef1..bc3f22baf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,8 @@ "eslint": "9.37.0", "eslint-plugin-jsdoc": "65.3.0", "globals": "17.12.0", + "js-yaml": "4.3.2", + "marked": "18.0.14", "mkdirp": "3.0.1", "mocha": "12.0.2", "nodemon": "3.1.14", @@ -6008,6 +6010,19 @@ "node": ">=0.10.0" } }, + "node_modules/marked": { + "version": "18.0.14", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.14.tgz", + "integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index f16ba1d864..e45a483892 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "coverage": "nyc npm test && npm run coverage:report", "coverage:report": "nyc report --reporter=html --reporter=json", + "docs:api": "node scripts/build-api-docs.js", "lint": "eslint --config eslint.config.js ", "lint:fix": "npm run lint -- --fix", "sequelize": "sequelize-cli", @@ -55,6 +56,8 @@ "eslint": "9.37.0", "eslint-plugin-jsdoc": "65.3.0", "globals": "17.12.0", + "js-yaml": "4.3.2", + "marked": "18.0.14", "mkdirp": "3.0.1", "mocha": "12.0.2", "nodemon": "3.1.14", diff --git a/scripts/api-docs.css b/scripts/api-docs.css new file mode 100644 index 0000000000..47dcd79c18 --- /dev/null +++ b/scripts/api-docs.css @@ -0,0 +1,40 @@ +/* Palette shared with the Bookkeeping GUI (@aliceo2/web-ui bootstrap.css) */ +:root { + --red: #d62631; --orange: #ef8239; --blue: #39a6ef; --blue-dark: #2b84bf; --light-blue: #dbedff; --green: #4caf50; + --gray-darker: #757575; --gray: #d5d5d5; --gray-light: #eee; --gray-lighter: #f7f7f7; +} +* { box-sizing: border-box; } +body { margin: 0; font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; color: #000; background: #fff; } +header { background: #000; border-bottom: 4px solid var(--red); } +header div { max-width: 62rem; margin: 0 auto; padding: .5rem 1rem; display: flex; align-items: center; gap: 1rem; } +header img { height: 3rem; } +header strong { color: #fff; font-size: 1.25rem; font-weight: 600; } +header span { color: var(--gray); font-size: .9rem; margin-left: .5rem; } +nav { margin-left: auto; font-size: .9rem; display: flex; gap: 1rem; } +nav a { color: var(--blue); } +main { max-width: 62rem; margin: 0 auto; padding: 1rem 1rem 4rem; } +a { color: var(--blue-dark); } +h1 { display: none; } +h2 { margin-top: 2.5rem; padding-bottom: .3rem; border-bottom: 2px solid var(--gray-light); } +h2::before { content: ""; display: inline-block; width: .5rem; height: 1.1rem; background: var(--red); margin-right: .6rem; vertical-align: -.1rem; } +details { margin-top: 1rem; } +details[open] { padding-bottom: .5rem; } +summary { cursor: pointer; list-style: none; } +summary::-webkit-details-marker { display: none; } +summary h3::after { content: "\25B8"; float: right; color: var(--gray-darker); } +details[open] summary h3::after { content: "\25BE"; } +summary:hover h3 { background: var(--light-blue); } +h3 { margin: 0; padding: .5rem .75rem; background: var(--gray-lighter); border-left: 4px solid var(--blue); border-radius: 0 4px 4px 0; font-size: 1.1rem; } +h3 code { background: none; padding: 0; font-size: 1em; } +.method { display: inline-block; min-width: 4.2rem; padding: .05rem .5rem; margin-right: .4rem; border-radius: 4px; color: #fff; font: 700 .8rem monospace; text-align: center; background: var(--blue); } +.method.post { background: var(--green); } .method.put, .method.patch { background: var(--orange); } .method.delete { background: var(--red); } +h3:has(.post) { border-left-color: var(--green); } h3:has(.put), h3:has(.patch) { border-left-color: var(--orange); } h3:has(.delete) { border-left-color: var(--red); } +code { font-family: monospace, monospace; font-size: .85em; padding: .15em .4em; background: var(--gray-lighter); border-radius: 3px; } +pre { background: #1e2a33; color: #e8eef2; padding: .8rem 1rem; border-radius: 6px; overflow-x: auto; border-left: 4px solid var(--orange); } +pre code { background: none; padding: 0; color: inherit; font-size: .85rem; } +table { border-collapse: collapse; display: block; overflow-x: auto; margin: 1rem 0; } +th { background: var(--gray-light); } +th, td { border: 1px solid var(--gray); padding: .35rem .6rem; text-align: left; vertical-align: top; } +blockquote { margin: 1rem 0; padding: .1rem 1rem; background: #ffe8c8; border-left: 4px solid var(--orange); } +footer { max-width: 62rem; margin: 0 auto; padding: 1rem; color: var(--gray-darker); font-size: .85rem; border-top: 1px solid var(--gray-light); } +.no-llm { margin-left: .75rem; padding: .05rem .5rem; border: 1px solid var(--red); border-radius: 999px; color: var(--red); background: #fff; font-size: .75rem; font-weight: 600; } diff --git a/scripts/build-api-docs.js b/scripts/build-api-docs.js new file mode 100644 index 0000000000..5e6eedcc49 --- /dev/null +++ b/scripts/build-api-docs.js @@ -0,0 +1,339 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Generates the HTTP API reference (lib/server/api-docs/API.md and index.html) from the OpenAPI spec + * (lib/server/api-docs/openapi.yaml), including ready to use curl examples. + * + * Usage: node scripts/build-api-docs.js + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); +const { marked } = require('marked'); + +const API_DOCS_DIR = path.resolve(__dirname, '..', 'lib', 'server', 'api-docs'); +const SPEC_PATH = path.join(API_DOCS_DIR, 'openapi.yaml'); +const OUTPUT_PATH = path.join(API_DOCS_DIR, 'API.md'); +const CSS_PATH = path.join(__dirname, 'api-docs.css'); +const HTML_OUTPUT_PATH = path.join(API_DOCS_DIR, 'index.html'); +const NO_LLM_MARKER = '(not for LLM use)'; +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']; + +// JSON_SCHEMA: plain YAML types only (no timestamps, binary or merge keys) +const spec = yaml.load(fs.readFileSync(SPEC_PATH, 'utf8'), { schema: yaml.JSON_SCHEMA }); + +// Every operation that is not a GET must say explicitly whether it is excluded from LLM / MCP use +const undeclaredOperations = Object.entries(spec.paths).flatMap(([route, pathItem]) => HTTP_METHODS + .filter((method) => method !== 'get' && pathItem[method] && typeof pathItem[method]['x-llm-exclude'] !== 'boolean') + .map((method) => `${method.toUpperCase()} ${route}`)); +if (undeclaredOperations.length > 0) { + throw new Error(`Add "x-llm-exclude: true|false" to these non-GET operations: ${undeclaredOperations.join(', ')}`); +} + +/** + * Escapes the characters that are significant in HTML + * + * @param {string} text the text to escape + * @return {string} the escaped text + */ +const escapeHtml = (text) => String(text) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', '''); + +/** + * Quotes a value for a POSIX shell in single quotes, so that it is never interpreted + * + * @param {string} value the value to quote + * @return {string} the quoted value + */ +const shellSingleQuote = (value) => `'${String(value).replaceAll('\'', '\'\\\'\'')}'`; + +/** + * Escapes a value to be put in a POSIX shell double-quoted string without any expansion + * + * @param {string} value the value to escape + * @return {string} the escaped value + */ +const shellDoubleQuoteEscape = (value) => String(value).replace(/[\\"$`!]/g, '\\$&'); + +/** + * Resolves a local $ref (e.g. #/components/schemas/Tag), returns the given object if it is not a reference + * + * @param {object} node the object to resolve + * @return {object} the resolved object + */ +const resolve = (node) => { + if (node?.$ref) { + if (!node.$ref.startsWith('#/')) { + throw new Error(`Only local $ref are supported, got "${node.$ref}"`); + } + return node.$ref.replace('#/', '').split('/').reduce((current, key) => current[key], spec); + } + return node; +}; + +/** + * Returns a short human readable type for the given schema + * + * @param {object} schema the (possibly referenced) schema + * @return {string} the type + */ +const typeOf = (schema) => { + const { type = 'object', format } = resolve(schema); + const types = Array.isArray(type) ? type : [type]; + return `${types.join(' \\| ')}${format ? ` (${format})` : ''}`; +}; + +/** + * Returns the markdown table of the properties of the given object schema + * + * @param {object} schema the (possibly referenced) object schema + * @return {string} the table + */ +const propertiesTable = (schema) => { + const { properties = {}, required = [] } = resolve(schema); + const rows = Object.entries(properties).map(([name, property]) => { + const { description = '', minLength, maxLength, pattern } = resolve(property); + const constraints = [ + minLength !== undefined && `min length ${minLength}`, + maxLength !== undefined && `max length ${maxLength}`, + pattern && `pattern \`${pattern}\``, + ].filter(Boolean).join(', '); + const notes = [description, constraints].filter(Boolean).join(' ').replace(/\n/g, ' '); + return `| \`${name}\` | ${typeOf(property)} | ${required.includes(name) ? 'yes' : 'no'} | ${notes} |`; + }); + return ['| Field | Type | Required | Notes |', '| --- | --- | --- | --- |', ...rows].join('\n'); +}; + +/** + * Returns a sample value for the given query/path parameter + * + * @param {object} parameter the parameter + * @return {string} the sample value + */ +const sampleValue = (parameter) => { + const { schema = {} } = parameter; + const [example] = schema.examples ?? []; + return String(example ?? schema.default ?? (parameter.in === 'path' ? 1 : 'VALUE')); +}; + +/** + * Builds the curl example of an operation + * + * @param {string} method the HTTP method + * @param {string} route the route as in the spec + * @param {object} operation the operation + * @param {object[]} parameters the parameters of the operation + * @return {string} the markdown + */ +const examplesOf = (method, route, operation, parameters) => { + const url = route.replace(/{(\w+)}/g, (_, name) => sampleValue(parameters.find((parameter) => parameter.name === name))); + const queryParameters = parameters + .filter(({ in: location, name, required }) => location === 'query' && (required || name === 'page[limit]')); + const query = queryParameters.map((parameter) => `${parameter.name}=${sampleValue(parameter)}`); + // $BOOKKEEPING_URL and $TOKEN are meant to be expanded by the shell, anything coming from the spec is not + const address = `${shellDoubleQuoteEscape(`/api${url}?${[...query, 'token='].join('&')}`)}$TOKEN`; + const body = operation.requestBody?.content?.['application/json']?.example; + + const curlLines = [ + `curl -g${method === 'get' ? '' : ` -X ${method.toUpperCase()}`} "$BOOKKEEPING_URL${address}"`, + ...body === undefined ? [] : [' -H \'content-type: application/json\'', ` -d ${shellSingleQuote(JSON.stringify(body))}`], + ]; + + return [ + '```bash', + curlLines.join(' \\\n'), + '```', + ].join('\n'); +}; + +/** + * Renders one operation + * + * @param {string} method the HTTP method + * @param {string} route the route as in the spec + * @param {object} operation the operation + * @param {object[]} sharedParameters the parameters declared at path level + * @return {string} the markdown + */ +const renderOperation = (method, route, operation, sharedParameters) => { + const parameters = [...sharedParameters, ...operation.parameters ?? []].map(resolve); + const marker = operation['x-llm-exclude'] ? ` ${NO_LLM_MARKER}` : ''; + const out = [ + `### \`${method.toUpperCase()} ${route}\` - ${operation.summary}${marker}`, + '', + `Operation id: \`${operation.operationId}\``, + '', + ]; + + if (operation.description) { + out.push(operation.description.trim(), ''); + } + if (operation['x-llm-exclude']) { + out.push('**Not meant for LLM or MCP tool use** (`x-llm-exclude`).', ''); + } + if (operation['x-required-roles']) { + out.push(`**Required role:** ${operation['x-required-roles'].map((role) => `\`${role}\``).join(', ')}`, ''); + } + + if (parameters.length) { + out.push( + '| Parameter | In | Type | Required | Notes |', + '| --- | --- | --- | --- | --- |', + ...parameters.map((parameter) => + `| \`${parameter.name}\` | ${parameter.in} | ${typeOf(parameter.schema)} | ${parameter.required ? 'yes' : 'no'} | ${ + (parameter.description ?? '').replace(/\n/g, ' ')} |`), + '', + ); + } + + const requestSchema = operation.requestBody?.content?.['application/json']?.schema; + if (requestSchema) { + out.push('Request body (JSON):', '', propertiesTable(requestSchema), ''); + } + + out.push( + 'Responses:', + '', + ...Object.entries(operation.responses).map(([status, response]) => + `- \`${status}\`: ${(resolve(response).description ?? '').trim().replace(/\n/g, ' ')}`), + '', + examplesOf(method, route, operation, parameters), + '', + ); + + return out.join('\n'); +}; + +/** + * Renders the whole document + * + * @return {string} the markdown + */ +const render = () => { + const out = [ + '', + `# ${spec.info.title}`, + '', + spec.info.description.trim().replace(/^## /gm, '## '), + '', + '## Quick start', + '', + 'The examples below read the server address and the JWT from the environment:', + '', + '```bash', + 'export BOOKKEEPING_URL=http://localhost:4000', + 'export TOKEN=', + '```', + '', + '`curl -g` is needed because of the `[` `]` in filter names. Note that a token in the URL ends up in shell history and server logs.', + '', + ]; + + const groups = new Map(); + Object.entries(spec.paths).forEach(([route, pathItem]) => { + HTTP_METHODS.filter((method) => pathItem[method]).forEach((method) => { + const [group = 'Other'] = pathItem[method].tags ?? []; + groups.set(group, [...groups.get(group) ?? [], renderOperation(method, route, pathItem[method], pathItem.parameters ?? [])]); + }); + }); + + groups.forEach((operations, group) => { + const description = spec.tags?.find(({ name }) => name === group)?.description; + out.push(`## ${group}`, '', ...description ? [description, ''] : [], ...operations); + }); + + return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`; +}; + +/** + * Wraps the markdown into a standalone HTML page (no script, the server CSP only allows scripts from itself) + * + * @param {string} markdown the markdown to convert + * @return {string} the page + */ +const renderHtml = (markdown) => { + const body = marked.parse(markdown.replace(/^\n/, '')) + // Turn the leading HTTP method of operation headings into a coloured badge + .replace( + /

\s*(GET|POST|PUT|PATCH|DELETE) ([^<]*)<\/code>/g, + (_, method, route) => `

${method} ${route}`, + ) + // Turn the "not for LLM use" marker of operation headings into a badge + .replaceAll( + NO_LLM_MARKER, + 'not for LLM use', + ) + // Make each operation (an h3 and everything up to the next heading) a collapsed block + .split(/(?=)/) + .map((section) => { + const [, heading, content] = section.match(/^(

.*?<\/h3>)([\s\S]*)$/) ?? []; + return heading ? `
\n${heading}\n${content}
\n` : section; + }) + .join(''); + + return ` + + + + +${escapeHtml(spec.info.title)} + + + + +
+ALICE +BookkeepingHTTP API + +
+
+${body} +
+
Generated from openapi.yaml by npm run docs:api · ALICE Bookkeeping · internal use only
+ + +`; +}; + +// The spec is trusted but the page is served without authentication: never let raw HTML or unsafe links through +marked.use({ + renderer: { + html: ({ text }) => escapeHtml(text), + + /** + * Renders a link only when it points to a safe location (http, https, mailto, or relative) + * + * @param {{href: string, title: string, tokens: object[]}} link the link token + * @return {string} the html + */ + link({ href, title, tokens }) { + const label = this.parser.parseInline(tokens); + return /^(https?:|mailto:|\/|#|\.)/i.test(href) ? + `${label}` : + label; + }, + }, +}); + +const markdown = render(); +fs.writeFileSync(OUTPUT_PATH, markdown); +fs.writeFileSync(HTML_OUTPUT_PATH, renderHtml(markdown)); +// eslint-disable-next-line no-console +console.log(`Written ${path.relative(process.cwd(), OUTPUT_PATH)} and ${path.relative(process.cwd(), HTML_OUTPUT_PATH)}`); From 04dda180dc641650b0ec2dd22ef8eb9488deed6a Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Tue, 20 Oct 2026 14:38:24 +0200 Subject: [PATCH 3/9] [O2B-1623] Serve the HTTP API documentation at /api-docs Serve the generated HTML page (`/api-docs`), the Markdown version for LLMs (`/api-docs/API.md`) and the spec (`/api-docs/openapi.yaml`). The files live under `lib/server` so that they are part of the Docker image. --- lib/server/WebUiServer.js | 11 + lib/server/api-docs/API.md | 220 +++++++++++++++++ lib/server/api-docs/index.html | 440 +++++++++++++++++++++++++++++++++ 3 files changed, 671 insertions(+) create mode 100644 lib/server/api-docs/API.md create mode 100644 lib/server/api-docs/index.html diff --git a/lib/server/WebUiServer.js b/lib/server/WebUiServer.js index 4251ef72ad..a6c7661314 100644 --- a/lib/server/WebUiServer.js +++ b/lib/server/WebUiServer.js @@ -85,6 +85,17 @@ class WebUiServer { }); this.http.addStaticPath(path.resolve(__dirname, '..', 'public')); + // HTTP API documentation: HTML and Markdown references (generated by `npm run docs:api`) and the OpenAPI spec they are built from + const apiDocsDirectory = path.resolve(__dirname, 'api-docs'); + this.http.routerStatics.get(['/api-docs', '/api-docs/'], (_request, response) => { + response.type('text/html; charset=utf-8').sendFile(path.join(apiDocsDirectory, 'index.html')); + }); + this.http.routerStatics.get('/api-docs/API.md', (_request, response) => { + response.type('text/plain; charset=utf-8').sendFile(path.join(apiDocsDirectory, 'API.md')); + }); + this.http.routerStatics.get('/api-docs/openapi.yaml', (_request, response) => { + response.type('text/yaml; charset=utf-8').sendFile(path.join(apiDocsDirectory, 'openapi.yaml')); + }); buildEndpoints(this.http); } diff --git a/lib/server/api-docs/API.md b/lib/server/api-docs/API.md new file mode 100644 index 0000000000..1546c14575 --- /dev/null +++ b/lib/server/api-docs/API.md @@ -0,0 +1,220 @@ + +# Bookkeeping HTTP API + +HTTP API of the ALICE Bookkeeping. **Internal use only.** + +> Pilot: only the `tags` resource is documented so far. + +## Authentication +Every request carries a JWT in the `token` **query parameter** (e.g. `GET /api/tags?token=`). +The token's `access` claim lists the user's roles; operations marked with a role below return `403` otherwise. + +## Response envelope +Successful responses are `{ "data": ... }`, with `meta.page` added on paginated endpoints. +Failed responses are `{ "errors": [ { status, title, detail?, source? } ] }`. +Note that request-validation failures are sent with **HTTP 400** while each error's `status` field says `"422"`. + +## Conventions +- Filters use bracket syntax: `filter[partialText]=foo`. List-valued filters are comma separated: `filter[ids]=1,2,3`. +- Pagination uses `page[limit]` and `page[offset]`. +- Timestamps are Unix epoch **milliseconds**. +- Operations flagged `x-llm-exclude: true` change data and are **not meant for LLMs or MCP tools**; skip them when generating tools. + +## Instructions for LLMs and automated agents +- **Always paginate** when an endpoint supports `page[limit]` and `page[offset]`. Never rely on the default: when `page[limit]` is omitted the server + may return up to 5000 items, which is far too much for a model context. +- **Request at most 100 items per call** (`page[limit]=100`). This is a rule for LLMs and agents, not a server limit (the server accepts more). +- To read more, repeat the call increasing `page[offset]` by the limit until `offset` reaches `meta.page.totalCount`. +- Prefer narrowing the request with `filter[...]` parameters over reading everything and filtering afterwards. +- Endpoints that are not paginated (for example `GET /tags`) return everything: use their filters. +- Do not call operations flagged `x-llm-exclude: true`: they create, change or delete data. + +## Quick start + +The examples below read the server address and the JWT from the environment: + +```bash +export BOOKKEEPING_URL=http://localhost:4000 +export TOKEN= +``` + +`curl -g` is needed because of the `[` `]` in filter names. Note that a token in the URL ends up in shell history and server logs. + +## Tags + +Labels attached to logs (and runs). Each tag may notify an e-mail list and a Mattermost channel. + +### `GET /tags` - List tags + +Operation id: `listTags` + +Returns every tag matching the filters, **not paginated**. +Returns `204 No Content` (empty body) when nothing matches, so clients must not assume a JSON body. + +| Parameter | In | Type | Required | Notes | +| --- | --- | --- | --- | --- | +| `filter[ids]` | query | string | no | Comma separated tag ids. | +| `filter[texts]` | query | string | no | Comma separated exact tag texts. Takes precedence over `filter[partialText]`. | +| `filter[partialText]` | query | string | no | Substring match on the tag text. Ignored when `filter[texts]` is given. | +| `filter[emails]` | query | string | no | Comma separated e-mail addresses. | +| `filter[mattermosts]` | query | string | no | Comma separated Mattermost channel names. | + +Responses: + +- `200`: Matching tags. +- `204`: No tag matches the filters. No body. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. + +```bash +curl -g "$BOOKKEEPING_URL/api/tags?token=$TOKEN" +``` + +### `POST /tags` - Create a tag (not for LLM use) + +Operation id: `createTag` + +**Not meant for LLM or MCP tool use** (`x-llm-exclude`). + +**Required role:** `admin` + +Request body (JSON): + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `text` | string | yes | Words, digits, `-` and `+` only. min length 2, max length 20, pattern `[\w|\-+]{1,}` | +| `description` | string \| null | no | max length 100 | +| `mattermost` | string \| null | no | | +| `email` | string \| null | no | One or more e-mail addresses, comma separated. | +| `color` | string \| null | no | pattern `^#[0-9A-Fa-f]{6}$` | + +Responses: + +- `201`: The created tag. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. +- `403`: The token lacks the role required by this operation. +- `409`: A tag with the same text already exists. + +```bash +curl -g -X POST "$BOOKKEEPING_URL/api/tags?token=$TOKEN" \ + -H 'content-type: application/json' \ + -d '{"text":"FOOD","description":"Food related issues","email":"cake@cern.ch,test@cern.ch","mattermost":"food-channel","color":"#ff8800"}' +``` + +### `GET /tags/name` - Get a tag by its text + +Operation id: `getTagByName` + +Looks a tag up by its exact text (case as stored). Use this when you know the label but not the id. + +| Parameter | In | Type | Required | Notes | +| --- | --- | --- | --- | --- | +| `name` | query | string | yes | Exact tag text. Must be URL-encoded. | + +Responses: + +- `200`: The tag. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. +- `404`: The requested entity does not exist. + +```bash +curl -g "$BOOKKEEPING_URL/api/tags/name?name=FOOD&token=$TOKEN" +``` + +### `GET /tags/{tagId}` - Get a tag by id + +Operation id: `getTagById` + +| Parameter | In | Type | Required | Notes | +| --- | --- | --- | --- | --- | +| `tagId` | path | integer | yes | Tag identifier. | + +Responses: + +- `200`: The tag. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. +- `404`: The requested entity does not exist. + +```bash +curl -g "$BOOKKEEPING_URL/api/tags/1?token=$TOKEN" +``` + +### `PUT /tags/{tagId}` - Update a tag (not for LLM use) + +Operation id: `updateTag` + +Only `description`, `email`, `mattermost`, `color` and `archivedAt` can change; +the tag `text` is immutable. Set `archivedAt` to a date to archive the tag and to `null` to restore it. + +**Not meant for LLM or MCP tool use** (`x-llm-exclude`). + +**Required role:** `admin` + +| Parameter | In | Type | Required | Notes | +| --- | --- | --- | --- | --- | +| `tagId` | path | integer | yes | Tag identifier. | + +Request body (JSON): + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `description` | string \| null | no | max length 100 | +| `mattermost` | string \| null | no | | +| `email` | string \| null | no | One or more e-mail addresses, comma separated. | +| `color` | string \| null | no | pattern `^#[0-9A-Fa-f]{6}$` | +| `archivedAt` | string \| null (date-time) | no | Date to archive the tag, null to restore it. | + +Responses: + +- `201`: The updated tag. (Status is `201`, not `200`.) +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. +- `403`: The token lacks the role required by this operation. +- `404`: The requested entity does not exist. + +```bash +curl -g -X PUT "$BOOKKEEPING_URL/api/tags/1?token=$TOKEN" \ + -H 'content-type: application/json' \ + -d '{"description":"Updated description","archivedAt":null}' +``` + +### `DELETE /tags/{tagId}` - Delete a tag (not for LLM use) + +Operation id: `deleteTag` + +**Not meant for LLM or MCP tool use** (`x-llm-exclude`). + +**Required role:** `admin` + +| Parameter | In | Type | Required | Notes | +| --- | --- | --- | --- | --- | +| `tagId` | path | integer | yes | Tag identifier. | + +Responses: + +- `200`: The deleted tag. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. +- `403`: The token lacks the role required by this operation. +- `404`: The requested entity does not exist. + +```bash +curl -g -X DELETE "$BOOKKEEPING_URL/api/tags/1?token=$TOKEN" +``` + +### `GET /tags/{tagId}/logs` - List logs carrying a tag + +Operation id: `listLogsByTag` + +| Parameter | In | Type | Required | Notes | +| --- | --- | --- | --- | --- | +| `tagId` | path | integer | yes | Tag identifier. | +| `page[limit]` | query | integer | no | Maximum number of logs to return. LLMs and agents must use 100 or less. | +| `page[offset]` | query | integer | no | | + +Responses: + +- `200`: A page of logs. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. +- `404`: The requested entity does not exist. + +```bash +curl -g "$BOOKKEEPING_URL/api/tags/1/logs?page[limit]=100&token=$TOKEN" +``` diff --git a/lib/server/api-docs/index.html b/lib/server/api-docs/index.html new file mode 100644 index 0000000000..6b1e6a78fb --- /dev/null +++ b/lib/server/api-docs/index.html @@ -0,0 +1,440 @@ + + + + + +Bookkeeping HTTP API + + + + +
+ALICE +BookkeepingHTTP API + +
+
+

Bookkeeping HTTP API

+

HTTP API of the ALICE Bookkeeping. Internal use only.

+
+

Pilot: only the tags resource is documented so far.

+
+

Authentication

+

Every request carries a JWT in the token query parameter (e.g. GET /api/tags?token=<jwt>). +The token's access claim lists the user's roles; operations marked with a role below return 403 otherwise.

+

Response envelope

+

Successful responses are { "data": ... }, with meta.page added on paginated endpoints. +Failed responses are { "errors": [ { status, title, detail?, source? } ] }. +Note that request-validation failures are sent with HTTP 400 while each error's status field says "422".

+

Conventions

+
    +
  • Filters use bracket syntax: filter[partialText]=foo. List-valued filters are comma separated: filter[ids]=1,2,3.
  • +
  • Pagination uses page[limit] and page[offset].
  • +
  • Timestamps are Unix epoch milliseconds.
  • +
  • Operations flagged x-llm-exclude: true change data and are not meant for LLMs or MCP tools; skip them when generating tools.
  • +
+

Instructions for LLMs and automated agents

+
    +
  • Always paginate when an endpoint supports page[limit] and page[offset]. Never rely on the default: when page[limit] is omitted the server +may return up to 5000 items, which is far too much for a model context.
  • +
  • Request at most 100 items per call (page[limit]=100). This is a rule for LLMs and agents, not a server limit (the server accepts more).
  • +
  • To read more, repeat the call increasing page[offset] by the limit until offset reaches meta.page.totalCount.
  • +
  • Prefer narrowing the request with filter[...] parameters over reading everything and filtering afterwards.
  • +
  • Endpoints that are not paginated (for example GET /tags) return everything: use their filters.
  • +
  • Do not call operations flagged x-llm-exclude: true: they create, change or delete data.
  • +
+

Quick start

+

The examples below read the server address and the JWT from the environment:

+
export BOOKKEEPING_URL=http://localhost:4000
+export TOKEN=<your jwt>
+
+

curl -g is needed because of the [ ] in filter names. Note that a token in the URL ends up in shell history and server logs.

+

Tags

+

Labels attached to logs (and runs). Each tag may notify an e-mail list and a Mattermost channel.

+
+

GET /tags - List tags

+ +

Operation id: listTags

+

Returns every tag matching the filters, not paginated. +Returns 204 No Content (empty body) when nothing matches, so clients must not assume a JSON body.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterInTypeRequiredNotes
filter[ids]querystringnoComma separated tag ids.
filter[texts]querystringnoComma separated exact tag texts. Takes precedence over filter[partialText].
filter[partialText]querystringnoSubstring match on the tag text. Ignored when filter[texts] is given.
filter[emails]querystringnoComma separated e-mail addresses.
filter[mattermosts]querystringnoComma separated Mattermost channel names.
+

Responses:

+
    +
  • 200: Matching tags.
  • +
  • 204: No tag matches the filters. No body.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
+
curl -g "$BOOKKEEPING_URL/api/tags?token=$TOKEN"
+
+
+
+

POST /tags - Create a tag not for LLM use

+ +

Operation id: createTag

+

Not meant for LLM or MCP tool use (x-llm-exclude).

+

Required role: admin

+

Request body (JSON):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredNotes
textstringyesWords, digits, - and + only. min length 2, max length 20, pattern `[\w
descriptionstring | nullnomax length 100
mattermoststring | nullno
emailstring | nullnoOne or more e-mail addresses, comma separated.
colorstring | nullnopattern ^#[0-9A-Fa-f]{6}$
+

Responses:

+
    +
  • 201: The created tag.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
  • 403: The token lacks the role required by this operation.
  • +
  • 409: A tag with the same text already exists.
  • +
+
curl -g -X POST "$BOOKKEEPING_URL/api/tags?token=$TOKEN" \
+  -H 'content-type: application/json' \
+  -d '{"text":"FOOD","description":"Food related issues","email":"cake@cern.ch,test@cern.ch","mattermost":"food-channel","color":"#ff8800"}'
+
+
+
+

GET /tags/name - Get a tag by its text

+ +

Operation id: getTagByName

+

Looks a tag up by its exact text (case as stored). Use this when you know the label but not the id.

+ + + + + + + + + + + + + + + + + +
ParameterInTypeRequiredNotes
namequerystringyesExact tag text. Must be URL-encoded.
+

Responses:

+
    +
  • 200: The tag.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
  • 404: The requested entity does not exist.
  • +
+
curl -g "$BOOKKEEPING_URL/api/tags/name?name=FOOD&token=$TOKEN"
+
+
+
+

GET /tags/{tagId} - Get a tag by id

+ +

Operation id: getTagById

+ + + + + + + + + + + + + + + + + +
ParameterInTypeRequiredNotes
tagIdpathintegeryesTag identifier.
+

Responses:

+
    +
  • 200: The tag.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
  • 404: The requested entity does not exist.
  • +
+
curl -g "$BOOKKEEPING_URL/api/tags/1?token=$TOKEN"
+
+
+
+

PUT /tags/{tagId} - Update a tag not for LLM use

+ +

Operation id: updateTag

+

Only description, email, mattermost, color and archivedAt can change; +the tag text is immutable. Set archivedAt to a date to archive the tag and to null to restore it.

+

Not meant for LLM or MCP tool use (x-llm-exclude).

+

Required role: admin

+ + + + + + + + + + + + + + + + + +
ParameterInTypeRequiredNotes
tagIdpathintegeryesTag identifier.
+

Request body (JSON):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredNotes
descriptionstring | nullnomax length 100
mattermoststring | nullno
emailstring | nullnoOne or more e-mail addresses, comma separated.
colorstring | nullnopattern ^#[0-9A-Fa-f]{6}$
archivedAtstring | null (date-time)noDate to archive the tag, null to restore it.
+

Responses:

+
    +
  • 201: The updated tag. (Status is 201, not 200.)
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
  • 403: The token lacks the role required by this operation.
  • +
  • 404: The requested entity does not exist.
  • +
+
curl -g -X PUT "$BOOKKEEPING_URL/api/tags/1?token=$TOKEN" \
+  -H 'content-type: application/json' \
+  -d '{"description":"Updated description","archivedAt":null}'
+
+
+
+

DELETE /tags/{tagId} - Delete a tag not for LLM use

+ +

Operation id: deleteTag

+

Not meant for LLM or MCP tool use (x-llm-exclude).

+

Required role: admin

+ + + + + + + + + + + + + + + + + +
ParameterInTypeRequiredNotes
tagIdpathintegeryesTag identifier.
+

Responses:

+
    +
  • 200: The deleted tag.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
  • 403: The token lacks the role required by this operation.
  • +
  • 404: The requested entity does not exist.
  • +
+
curl -g -X DELETE "$BOOKKEEPING_URL/api/tags/1?token=$TOKEN"
+
+
+
+

GET /tags/{tagId}/logs - List logs carrying a tag

+ +

Operation id: listLogsByTag

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterInTypeRequiredNotes
tagIdpathintegeryesTag identifier.
page[limit]queryintegernoMaximum number of logs to return. LLMs and agents must use 100 or less.
page[offset]queryintegerno
+

Responses:

+
    +
  • 200: A page of logs.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
  • 404: The requested entity does not exist.
  • +
+
curl -g "$BOOKKEEPING_URL/api/tags/1/logs?page[limit]=100&token=$TOKEN"
+
+
+ +
+
Generated from openapi.yaml by npm run docs:api · ALICE Bookkeeping · internal use only
+ + From c75388aed4cce84f47905bd31cf10151c9e53268 Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Tue, 20 Oct 2026 14:40:21 +0200 Subject: [PATCH 4/9] [O2B-1623] Remove no-op replacement flagged by CodeQL in the API docs generator --- scripts/build-api-docs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-api-docs.js b/scripts/build-api-docs.js index 5e6eedcc49..91b5f0ed4d 100644 --- a/scripts/build-api-docs.js +++ b/scripts/build-api-docs.js @@ -230,7 +230,7 @@ const render = () => { '', `# ${spec.info.title}`, '', - spec.info.description.trim().replace(/^## /gm, '## '), + spec.info.description.trim(), '', '## Quick start', '', From a2cf088dffd5db3876b2b4ecb64764c989bccfa9 Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Tue, 20 Oct 2026 14:46:56 +0200 Subject: [PATCH 5/9] [O2B-1623] Remove unimplemented and dead tag routes - `GET /tags/:tagId/runs` was declared twice: once with a controller answering 501 and once with `TagsController.patchRun`, which does not exist, so the binder silently skipped it - `GET /tags/:tagId/log` answered 501 as well None of them is used by the GUI or covered by tests, and none did anything but advertise endpoints that do not exist. They now answer 404 like any unknown route. The matching `getRuns` and `patchLog` stub controllers are removed. --- lib/server/controllers/tags.controller.js | 46 ----------------------- lib/server/routers/tags.router.js | 15 -------- 2 files changed, 61 deletions(-) diff --git a/lib/server/controllers/tags.controller.js b/lib/server/controllers/tags.controller.js index 553f2db9d5..3b16d6013e 100644 --- a/lib/server/controllers/tags.controller.js +++ b/lib/server/controllers/tags.controller.js @@ -129,28 +129,6 @@ const getLogsByTagId = async (request, response) => { } }; -/** - * Get all Runs with tag. - * - * @param {Object} request The *request* object represents the HTTP request and has properties for the request query - * string, parameters, body, HTTP headers, and so on. - * @param {Object} response The *response* object represents the HTTP response that an Express app sends when it gets an - * HTTP request. - * @param {Function} next The *next* object represents the next middleware function which is used to pass control to the - * next middleware function. - * @returns {undefined} - */ -const getRuns = (request, response, next) => { - response.status(501).json({ - errors: [ - { - status: '501', - title: 'Not implemented', - }, - ], - }); -}; - /** * Get all tags. * @@ -174,28 +152,6 @@ const listTags = async (request, response, next) => { response.status(count === 0 ? 204 : 200).json({ data: tags }); }; -/** - * Patch tag on log. - * - * @param {Object} request The *request* object represents the HTTP request and has properties for the request query - * string, parameters, body, HTTP headers, and so on. - * @param {Object} response The *response* object represents the HTTP response that an Express app sends when it gets an - * HTTP request. - * @param {Function} next The *next* object represents the next middleware function which is used to pass control to the - * next middleware function. - * @returns {undefined} - */ -const patchLog = (request, response, next) => { - response.status(501).json({ - errors: [ - { - status: '501', - title: 'Not implemented', - }, - ], - }); -}; - /** * Get tag. * @@ -290,10 +246,8 @@ module.exports = { createTag, deleteTag, getLogsByTagId, - getRuns, getTagById, listTags, - patchLog, updateTagById, getTagByName, }; diff --git a/lib/server/routers/tags.router.js b/lib/server/routers/tags.router.js index 175d6642b1..29364c9ad8 100644 --- a/lib/server/routers/tags.router.js +++ b/lib/server/routers/tags.router.js @@ -42,26 +42,11 @@ module.exports = { method: 'put', controller: [rbacMiddleware([BkpRoles.ADMIN]), TagsController.updateTagById], }, - { - method: 'get', - path: '/runs', - controller: TagsController.getRuns, - }, { method: 'get', path: '/logs', controller: TagsController.getLogsByTagId, }, - { - method: 'get', - path: '/runs', - controller: TagsController.patchRun, - }, - { - method: 'get', - path: '/log', - controller: TagsController.patchLog, - }, { method: 'delete', path: '', From b58d66dc120c3990871246836c2395721a927b37 Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Tue, 20 Oct 2026 14:47:52 +0200 Subject: [PATCH 6/9] [O2B-1623] Fail at startup when a route declares a controller that is not a function The route binder silently skipped routes whose controller was undefined (for example a typo or a missing export, as with the removed `GET /tags/:tagId/runs`). It now throws, naming the route. Nodes that only group children, such as `/statistics` and `/dpl-process`, are not affected. Unit tests check that every declared route binds, that grouping nodes are ignored, and that undefined controllers (alone or inside a list) are rejected. --- lib/server/routers/index.js | 8 ++++ test/lib/server/index.js | 2 + test/lib/server/routers/index.js | 18 ++++++++ test/lib/server/routers/routers.test.js | 61 +++++++++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 test/lib/server/routers/index.js create mode 100644 test/lib/server/routers/routers.test.js diff --git a/lib/server/routers/index.js b/lib/server/routers/index.js index cb83465446..56050e099d 100644 --- a/lib/server/routers/index.js +++ b/lib/server/routers/index.js @@ -86,6 +86,12 @@ const inheritArgs = (parent, child) => { */ const bindRoute = (http, route, parentPath = '') => { const localPath = appendPath(parentPath, route.path, route.pathOption); + + // A route that declares a controller must give a function (or a list of functions), otherwise it would be silently skipped + if (route.method && 'controller' in route && ![route.controller].flat().every((controller) => typeof controller === 'function')) { + throw new Error(`Route ${route.method.toUpperCase()} ${localPath} declares a controller that is not a function`); + } + if (route.method && route.controller) { if (!route.args) { route.args = {}; @@ -124,3 +130,5 @@ module.exports = (http) => { bindRoute(http, route); }); }; + +module.exports.bindRoute = bindRoute; diff --git a/test/lib/server/index.js b/test/lib/server/index.js index 64c13effab..e3dd565339 100644 --- a/test/lib/server/index.js +++ b/test/lib/server/index.js @@ -14,11 +14,13 @@ const UtilitiesSuite = require('./utilities/index.js'); const ServicesSuite = require('./services/index.js'); const MiddlewareSuite = require('./middleware/index.js'); +const RoutersSuite = require('./routers/index.js'); const ExternalServicesSynchronizationSuite = require('./externalServicesSynchronization/index.js'); module.exports = () => { describe('Utilities', UtilitiesSuite); describe('Services', ServicesSuite); describe('Middlewares', MiddlewareSuite); + describe('Routers', RoutersSuite); describe('External Services Synchronization', ExternalServicesSynchronizationSuite); }; diff --git a/test/lib/server/routers/index.js b/test/lib/server/routers/index.js new file mode 100644 index 0000000000..22a271d9dc --- /dev/null +++ b/test/lib/server/routers/index.js @@ -0,0 +1,18 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const RoutersTest = require('./routers.test.js'); + +module.exports = () => { + describe('Routers', RoutersTest); +}; diff --git a/test/lib/server/routers/routers.test.js b/test/lib/server/routers/routers.test.js new file mode 100644 index 0000000000..72f7a3495b --- /dev/null +++ b/test/lib/server/routers/routers.test.js @@ -0,0 +1,61 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { expect } = require('chai'); +const buildEndpoints = require('../../../../lib/server/routers'); + +const { bindRoute } = buildEndpoints; + +/** + * Returns a fake http server recording the routes bound to it + * + * @return {{registered: object[]}} the http server + */ +const createFakeHttp = () => { + const http = { registered: [] }; + ['get', 'post', 'put', 'patch', 'delete'].forEach((method) => { + http[method] = (path, ...callbacks) => http.registered.push({ method, path, callbacks: callbacks.slice(0, -1) }); + }); + return http; +}; + +module.exports = () => { + it('should bind every declared route with a controller function', () => { + const http = createFakeHttp(); + buildEndpoints(http); + + expect(http.registered).to.not.be.empty; + http.registered.forEach(({ callbacks }) => expect(callbacks).to.not.be.empty); + }); + + it('should bind the routes of a valid route tree, ignoring nodes that only group children', () => { + const http = createFakeHttp(); + bindRoute(http, { + method: 'get', + path: '/group', + children: [{ method: 'post', path: '/child', controller: [() => null, () => null] }], + }); + + expect(http.registered.map(({ method, path }) => [method, path])).to.eql([['post', '/group/child']]); + }); + + it('should throw when a route declares an undefined controller', () => { + expect(() => bindRoute(createFakeHttp(), { method: 'get', path: '/missing', controller: undefined })) + .to.throw('Route GET /missing declares a controller that is not a function'); + }); + + it('should throw when a nested route declares a controller list containing an undefined controller', () => { + const route = { path: '/parent', children: [{ method: 'put', path: '/child', controller: [() => null, undefined] }] }; + expect(() => bindRoute(createFakeHttp(), route)).to.throw('Route PUT /parent/child declares a controller that is not a function'); + }); +}; From 216cc1e43ec7134cbd7096215cbfb954ffdf2b95 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Tue, 20 Oct 2026 17:03:51 +0200 Subject: [PATCH 7/9] [O2B-1623] Escape pipes in the HTTP API documentation table notes Co-Authored-By: Claude Sonnet 5.5 --- lib/server/api-docs/API.md | 2 +- lib/server/api-docs/index.html | 2 +- scripts/build-api-docs.js | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/server/api-docs/API.md b/lib/server/api-docs/API.md index 1546c14575..881a2d15e5 100644 --- a/lib/server/api-docs/API.md +++ b/lib/server/api-docs/API.md @@ -81,7 +81,7 @@ Request body (JSON): | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `text` | string | yes | Words, digits, `-` and `+` only. min length 2, max length 20, pattern `[\w|\-+]{1,}` | +| `text` | string | yes | Words, digits, `-` and `+` only. min length 2, max length 20, pattern `[\w\|\-+]{1,}` | | `description` | string \| null | no | max length 100 | | `mattermost` | string \| null | no | | | `email` | string \| null | no | One or more e-mail addresses, comma separated. | diff --git a/lib/server/api-docs/index.html b/lib/server/api-docs/index.html index 6b1e6a78fb..dce95c840a 100644 --- a/lib/server/api-docs/index.html +++ b/lib/server/api-docs/index.html @@ -173,7 +173,7 @@

Tags

text string yes -Words, digits, - and + only. min length 2, max length 20, pattern `[\w +Words, digits, - and + only. min length 2, max length 20, pattern [\w|\-+]{1,} description diff --git a/scripts/build-api-docs.js b/scripts/build-api-docs.js index 91b5f0ed4d..c05fb98eb3 100644 --- a/scripts/build-api-docs.js +++ b/scripts/build-api-docs.js @@ -99,6 +99,14 @@ const typeOf = (schema) => { return `${types.join(' \\| ')}${format ? ` (${format})` : ''}`; }; +/** + * Escapes the pipes of the given text, so that they are not interpreted as column separators in a markdown table cell + * + * @param {string} text the text to escape + * @return {string} the escaped text + */ +const escapeCell = (text) => text.replace(/\|/g, '\\|'); + /** * Returns the markdown table of the properties of the given object schema * @@ -114,7 +122,7 @@ const propertiesTable = (schema) => { maxLength !== undefined && `max length ${maxLength}`, pattern && `pattern \`${pattern}\``, ].filter(Boolean).join(', '); - const notes = [description, constraints].filter(Boolean).join(' ').replace(/\n/g, ' '); + const notes = escapeCell([description, constraints].filter(Boolean).join(' ').replace(/\n/g, ' ')); return `| \`${name}\` | ${typeOf(property)} | ${required.includes(name) ? 'yes' : 'no'} | ${notes} |`; }); return ['| Field | Type | Required | Notes |', '| --- | --- | --- | --- |', ...rows].join('\n'); @@ -197,7 +205,7 @@ const renderOperation = (method, route, operation, sharedParameters) => { '| --- | --- | --- | --- | --- |', ...parameters.map((parameter) => `| \`${parameter.name}\` | ${parameter.in} | ${typeOf(parameter.schema)} | ${parameter.required ? 'yes' : 'no'} | ${ - (parameter.description ?? '').replace(/\n/g, ' ')} |`), + escapeCell((parameter.description ?? '').replace(/\n/g, ' '))} |`), '', ); } From 71af77f43e188e3f6e43a7c08d346dc9cf2f5c94 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Tue, 20 Oct 2026 17:05:12 +0200 Subject: [PATCH 8/9] [O2B-1623] Address review comments on the HTTP API documentation - Allow epoch milliseconds for archivedAt in UpdateTag - URL-encode query values in the curl examples - Darken method badge and link colours to reach a 4.5:1 contrast Co-Authored-By: Claude Sonnet 5.5 --- lib/server/api-docs/API.md | 2 +- lib/server/api-docs/index.html | 10 +++++----- lib/server/api-docs/openapi.yaml | 2 +- scripts/api-docs.css | 6 +++--- scripts/build-api-docs.js | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/server/api-docs/API.md b/lib/server/api-docs/API.md index 881a2d15e5..dd43a65d5a 100644 --- a/lib/server/api-docs/API.md +++ b/lib/server/api-docs/API.md @@ -161,7 +161,7 @@ Request body (JSON): | `mattermost` | string \| null | no | | | `email` | string \| null | no | One or more e-mail addresses, comma separated. | | `color` | string \| null | no | pattern `^#[0-9A-Fa-f]{6}$` | -| `archivedAt` | string \| null (date-time) | no | Date to archive the tag, null to restore it. | +| `archivedAt` | string \| integer \| null (date-time) | no | Date to archive the tag, either an ISO 8601 date-time string or epoch milliseconds, null to restore it. | Responses: diff --git a/lib/server/api-docs/index.html b/lib/server/api-docs/index.html index dce95c840a..328c55d55c 100644 --- a/lib/server/api-docs/index.html +++ b/lib/server/api-docs/index.html @@ -8,7 +8,7 @@