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..dd43a65d5a --- /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 \| 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: + +- `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..328c55d55c --- /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|\-+]{1,}
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 | integer | null (date-time)noDate to archive the tag, either an ISO 8601 date-time string or epoch milliseconds, 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
+ + diff --git a/lib/server/api-docs/openapi.yaml b/lib/server/api-docs/openapi.yaml new file mode 100644 index 0000000000..213de05495 --- /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, integer, 'null'], format: date-time, description: "Date to archive the tag, either an ISO 8601 date-time string or epoch milliseconds, 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 } 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/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/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: '', 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..97cb2568f3 --- /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: #1f6fa8; --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-dark); } +.method.post { background: #2f7d32; } .method.put, .method.patch { background: #b85410; } .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..2e4107fa4c --- /dev/null +++ b/scripts/build-api-docs.js @@ -0,0 +1,348 @@ +/** + * @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})` : ''}`; +}; + +/** + * Escapes the pipes of the given text, so that they are not interpreted as column separators in a markdown table cell. + * Backslashes right before a pipe are doubled, otherwise they would escape the backslash added in front of the pipe + * + * @param {string} text the text to escape + * @return {string} the escaped text + */ +const escapeCell = (text) => text.replace(/(\\*)\|/g, '$1$1\\|'); + +/** + * 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 = 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'); +}; + +/** + * 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}=${encodeURIComponent(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'} | ${ + escapeCell((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(), + '', + '## 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)}`); 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'); + }); +};