From 627d6575bf3b7fce38d4636642175f476a762faa Mon Sep 17 00:00:00 2001 From: Giovanni Remigi Date: Sun, 27 Sep 2026 12:30:04 +0200 Subject: [PATCH] Document the API with OpenAPI, and follow HTTP for files The server now describes its API in OpenAPI, at /api/openapi.json, and serves Swagger UI at /api/docs to browse it and try its requests. Swagger UI comes from the swagger-ui-dist package, so the page needs no internet connection. The description is written by hand in src/server/openapi.ts, and a test checks that every route it lists exists. The servers entry is the address the client reached the server at, so that Postman can import the description as is. The file routes follow HTTP more closely: a file is at /api/files/ instead of /api/file?path=, PUT answers 201 Created when it creates the file and 200 OK when it overwrites it, and other methods on a file get 405 Method Not Allowed with an Allow header. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 +++ docs/ARCHITECTURE.md | 4 +- package.json | 1 + pnpm-lock.yaml | 15 ++ pnpm-workspace.yaml | 1 + src/client/api.ts | 3 +- src/server/api.ts | 72 ++++++++-- src/server/openapi.ts | 308 ++++++++++++++++++++++++++++++++++++++++++ tests/api.test.ts | 64 ++++++++- 9 files changed, 466 insertions(+), 19 deletions(-) create mode 100644 src/server/openapi.ts diff --git a/README.md b/README.md index d5f5028..15f83d6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - [How Files Are Read and Written](#how-files-are-read-and-written) - [Validation](#validation) - [Your Own Node Types](#your-own-node-types) + - [The HTTP API](#the-http-api) - [Project Layout](#project-layout) - [Monitoring a Run from Another Server](#monitoring-a-run-from-another-server) @@ -162,6 +163,22 @@ std::cout << BT::writeTreeNodesModelXML(factory, false); Save the output in the folder, e.g. `models/my_nodes.xml`, and add a test that fails when it is out of date. The editor shows a file of models only as a list of node types, and does not let you edit a model in a file whose header comment says it is generated. XML files that are not behaviors (whose first element is not ``, like a ROS `package.xml`) are ignored. +## The HTTP API + +The editor reads and writes the folder through a small HTTP API, which the server documents itself. With the editor running, open: + +- : the documentation in [Swagger UI](https://swagger.io/tools/swagger-ui/), with every endpoint, its parameters, its responses and their schemas. **Try it out** on an endpoint sends a real request and shows the answer. +- : the same description in [OpenAPI](https://www.openapis.org/) 3.1, to read with other tools, e.g. to generate a client. + +To try the API by hand in [Postman](https://www.postman.com/) instead, import the description: **Import**, paste `http://localhost:8080/api/openapi.json`, and choose to generate a collection. Postman makes a request for every endpoint, with its parameters and example bodies, ready to send to the address it imported the description from. Use the desktop app: the web version of Postman cannot reach `localhost` without the Postman Desktop Agent. + +The documentation and the description are served on the port of the editor, so use the host port chosen with `EDITOR_PORT`, `5173` with the development server, and the address of the machine rather than `localhost` from another computer. The documentation is served with the editor and needs no internet connection. + +> [!WARNING] +> The requests act on the open folder: trying a `PUT` or a `DELETE` changes the files on disk. + +The description is written by hand in [`src/server/openapi.ts`](src/server/openapi.ts): a change to the API in [`src/server/api.ts`](src/server/api.ts) has to be made there too. + ## Project Layout To learn how the editor is built, start with [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 445d236..315f638 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -183,8 +183,8 @@ The checks are pure functions of the workspace: they return a list of `Issue`s a | Route | Purpose | |---|---| | `GET /api/workspace` | The folder, the content and the ETag of every behavior file in it, and the built-in nodes. | -| `PUT /api/file?path=a.xml` | Create or overwrite a file with the request body, if it is the version expected. | -| `DELETE /api/file?path=a.xml` | Delete a file, if it is the version expected. | +| `PUT /api/files/a/b.xml` | Create (201) or overwrite (200) a file with the request body, if it is the version expected. | +| `DELETE /api/files/a/b.xml` | Delete a file, if it is the version expected. | | `POST /api/validate` | Validate the given contents with BehaviorTree.CPP. | | `GET /api/folders?path=/a` | The sub-folders of a folder, for the *Open folder* dialog. | | `PUT /api/root` | Open another folder. | diff --git a/package.json b/package.json index 0cf4b6c..7d81463 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@xmldom/xmldom": "^0.9.12", "react": "^19.3.0", "react-dom": "^19.3.0", + "swagger-ui-dist": "^5.33.0", "tsx": "^4.23.15", "zustand": "^5.0.15" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3877a17..96c9ebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: react-dom: specifier: ^19.3.0 version: 19.3.0(react@19.3.0) + swagger-ui-dist: + specifier: ^5.33.0 + version: 5.33.0 tsx: specifier: ^4.23.15 version: 4.23.15 @@ -316,6 +319,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -661,6 +667,9 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + swagger-ui-dist@5.33.0: + resolution: {integrity: sha512-wpdK+m6BU5yj6pmUdMskZVTSWYG4DLglAx3sIhylloY37i8O37IrH+YEpqdXNfpaTGxILRBFzUqLF2jKqbfI7A==} + tinybench@6.1.4: resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} engines: {node: '>=20.0.0'} @@ -931,6 +940,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@scarf/scarf@1.4.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1187,6 +1198,10 @@ snapshots: std-env@4.2.0: {} + swagger-ui-dist@5.33.0: + dependencies: + '@scarf/scarf': 1.4.0 + tinybench@6.1.4: {} tinyexec@1.3.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..2b7f4d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ allowBuilds: + '@scarf/scarf': false esbuild: true diff --git a/src/client/api.ts b/src/client/api.ts index 3f02319..2b0a68d 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -51,7 +51,8 @@ export function isConflict(e: unknown): e is ApiError & { data: ConflictResponse return e instanceof ApiError && e.status === 412; } -const fileUrl = (path: string) => `/api/file?path=${encodeURIComponent(path)}`; +/** The URL of a file: its path, a/b.xml, with each part encoded. */ +const fileUrl = (path: string) => `/api/files/${path.split('/').map(encodeURIComponent).join('/')}`; export const api = { workspace: () => request('/api/workspace'), diff --git a/src/server/api.ts b/src/server/api.ts index e14f399..5d2a8dd 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -2,27 +2,33 @@ // so that the Vite development server and the production server share it. // // GET /api/workspace The folder, its XML files, their contents and ETags -// PUT /api/file?path=a.xml Create or overwrite a file (body: the XML) -// DELETE /api/file?path=a.xml Delete a file +// PUT /api/files/a/b.xml Create or overwrite a file (body: the XML) +// DELETE /api/files/a/b.xml Delete a file // POST /api/validate Validate with BehaviorTree.CPP // (body: {files: [{path, content}]}) // GET /api/folders?path=/a The sub-folders of a folder, to choose one // PUT /api/root Open another folder (body: {path}) +// GET /api/openapi.json The OpenAPI description of the above +// GET /api/docs Swagger UI, to browse and try the API // // Writes are conditional, so that nobody overwrites a change they have not // seen: PUT and DELETE take the ETag of the version the editor read in // If-Match, or If-None-Match: * to create a file only if it does not exist, -// and fail with 412 Precondition Failed otherwise. They also take the folder +// and fail with 412 Precondition Failed otherwise. PUT answers 201 Created +// when the file did not exist, and 200 OK when it overwrote it. They also take the folder // the editor loaded in X-Behaviors-Root, and fail with 409 Conflict if another // folder was opened since, e.g. from another tab. +import { createReadStream } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createRequire } from 'node:module'; import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; import type { NodeModel } from '../shared/types'; import { etagOf, isIgnoredFolder, isXmlFile, listXmlFiles, readBehaviorFiles, type StoredFile, type WorkspaceFile } from './files'; import { nativeBuiltins, nativeValidatorPath, validateNative } from './native'; +import { DOCS_HTML, OPENAPI } from './openapi'; export type { WorkspaceFile } from './files'; @@ -52,9 +58,43 @@ export interface ConflictResponse { /** The header that carries the folder the editor loaded. */ export const ROOT_HEADER = 'x-behaviors-root'; +/** The prefix of the URL of a file: the rest is its path in the folder. */ +const FILES = '/api/files/'; + +/** The path of the file that a URL under FILES names, e.g. a/b.xml. */ +function filePathOf(url: URL): string { + try { + return decodeURIComponent(url.pathname.slice(FILES.length)); + } catch { + throw new HttpError(400, 'The path is not valid URL encoding'); + } +} + +/** + * The address the client reached the server at, e.g. http://localhost:8080, + * for the OpenAPI description: tools that import it, like Postman, need an + * absolute address. It comes from the Host header, so it is right behind + * Docker's port mapping and from other machines too. + */ +function serverUrl(req: IncomingMessage): string { + const host = req.headers.host; + if (!host) return '/'; + const forwarded = req.headers['x-forwarded-proto']; + const scheme = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : 'http'; + return `${scheme}://${host}`; +} + /** The largest request body accepted, far above any behavior file. */ const MAX_BODY = 10 * 1024 * 1024; +/** The files of Swagger UI that /api/docs loads, and their types. */ +const SWAGGER_UI = dirname(createRequire(import.meta.url).resolve('swagger-ui-dist/package.json')); +const DOCS_FILES: Record = { + 'swagger-ui.css': 'text/css', + 'swagger-ui-bundle.js': 'text/javascript', + 'favicon-32x32.png': 'image/png', +}; + class HttpError extends Error { constructor(public status: number, message: string, public data: Record = {}) { super(message); @@ -210,22 +250,25 @@ export function createApi(initialRoot: string, options: ApiOptions = {}) { root, files, builtins: await builtins, nativeValidator: !!nativeValidatorPath(), }; send(res, 200, response); - } else if (route === 'PUT /api/file') { + } else if (route.startsWith(`PUT ${FILES}`)) { checkRoot(req); - const path = url.searchParams.get('path'); + const path = filePathOf(url); const full = safePath(root, path); const content = await body(req); - await checkPreconditions(req, full, path!); + await checkPreconditions(req, full, path); + const existed = await stat(full).then(() => true, () => false); await writeAtomically(full, content); const etag = etagOf(content); - send(res, 200, { etag }, { ETag: etag }); - } else if (route === 'DELETE /api/file') { + send(res, existed ? 200 : 201, { etag }, { ETag: etag }); + } else if (route.startsWith(`DELETE ${FILES}`)) { checkRoot(req); - const path = url.searchParams.get('path'); + const path = filePathOf(url); const full = safePath(root, path); - await checkPreconditions(req, full, path!); + await checkPreconditions(req, full, path); await rm(full, { force: true }); send(res, 200, { ok: true }); + } else if (url.pathname.startsWith(FILES)) { + send(res, 405, { error: `${req.method} is not allowed on a file` }, { Allow: 'PUT, DELETE' }); } else if (route === 'POST /api/validate') { checkRoot(req); const { files } = await jsonBody<{ files: WorkspaceFile[] }>(req); @@ -239,6 +282,15 @@ export function createApi(initialRoot: string, options: ApiOptions = {}) { root = await folderPath(path); console.log(`Behaviors folder: ${root}`); send(res, 200, { root }); + } else if (route === 'GET /api/openapi.json') { + send(res, 200, { ...OPENAPI, servers: [{ url: serverUrl(req) }] }); + } else if (route === 'GET /api/docs') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(DOCS_HTML); + } else if (req.method === 'GET' && Object.hasOwn(DOCS_FILES, url.pathname.replace('/api/docs/', ''))) { + const name = url.pathname.replace('/api/docs/', ''); + res.setHeader('Content-Type', DOCS_FILES[name]); + createReadStream(join(SWAGGER_UI, name)).pipe(res); } else { send(res, 404, { error: `No route ${route}` }); } diff --git a/src/server/openapi.ts b/src/server/openapi.ts new file mode 100644 index 0000000..03e1846 --- /dev/null +++ b/src/server/openapi.ts @@ -0,0 +1,308 @@ +// The OpenAPI description of the API in api.ts, served at /api/openapi.json and +// browsable with Swagger UI at /api/docs. It is written by hand: keep it in +// step with the routes, which tests/api.test.ts checks it lists. + +const json = (schema: object) => ({ 'application/json': { schema } }); +const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); +const error = (description: string) => ({ description, content: json(ref('Error')) }); + +const filePath = { + name: 'path', + in: 'path', + required: true, + description: 'The path of the file, relative to the open folder, e.g. `a/b.xml`. It must end in .xml. The slashes between folders may be sent as is or encoded as `%2F`.', + schema: { type: 'string' }, + example: 'examples/hello.xml', +}; + +export const OPENAPI = { + openapi: '3.1.0', + info: { + title: 'Behavior editor API', + version: '0.1.0', + license: { name: 'MIT', identifier: 'MIT' }, + description: [ + 'The HTTP API of the behavior editor, over a folder of BehaviorTree.CPP XML files on the server.', + '', + 'A file is at `/api/files/` followed by its path in the open folder. `PUT` creates it (201 Created) or overwrites it (200 OK).', + '', + 'Writes are conditional, so that nobody overwrites a change they have not seen: `PUT` and `DELETE` of a file take', + 'the ETag of the version read in `If-Match`, or `If-None-Match: *` to create a file only if it does not exist,', + 'and fail with 412 Precondition Failed otherwise. Without either header they write unconditionally.', + '', + 'Changes also take the folder the client loaded in `X-Behaviors-Root`, and fail with 409 Conflict if another', + 'folder was opened since, e.g. from another tab.', + '', + '**The requests run against the real folder**: "Try it out" on a write changes the files on disk.', + ].join('\n'), + }, + // Replaced with the address the client used when served, see serverUrl in api.ts. + servers: [{ url: '/' }], + // No authentication: the editor serves whoever can reach its port. + security: [], + tags: [ + { name: 'Files', description: 'The behavior files of the open folder' }, + { name: 'Folders', description: 'Choosing the folder to open' }, + { name: 'Validation', description: 'Validation with BehaviorTree.CPP itself' }, + ], + paths: { + '/api/workspace': { + get: { + tags: ['Files'], + operationId: 'getWorkspace', + summary: 'Read the open folder', + description: 'The open folder, its behavior files with their contents and ETags, and the built-in node models of the installed BehaviorTree.CPP. XML files that are not behaviors, e.g. package.xml, are left out.', + responses: { + 200: { description: 'The folder and its files', content: json(ref('Workspace')) }, + 500: error('The folder cannot be read'), + }, + }, + }, + '/api/files/{path}': { + put: { + tags: ['Files'], + operationId: 'putFile', + summary: 'Create or overwrite a file', + description: 'Writes the body to the file, creating its folders if needed. The write is atomic: a reader never sees half a file.', + parameters: [ + filePath, + { $ref: '#/components/parameters/IfMatch' }, + { $ref: '#/components/parameters/IfNoneMatch' }, + { $ref: '#/components/parameters/BehaviorsRoot' }, + ], + requestBody: { + required: true, + description: 'The XML of the file. At most 10 MB.', + content: { 'application/xml': { schema: { type: 'string' } } }, + }, + responses: { + 200: { + description: 'Overwrote the file', + headers: { ETag: { description: 'The ETag of the new content', schema: { type: 'string' } } }, + content: json({ type: 'object', required: ['etag'], properties: { etag: { type: 'string' } } }), + }, + 201: { + description: 'Created the file', + headers: { ETag: { description: 'The ETag of the new content', schema: { type: 'string' } } }, + content: json({ type: 'object', required: ['etag'], properties: { etag: { type: 'string' } } }), + }, + 400: error('The path is missing, absolute, outside the folder or not an .xml file'), + 409: error('Another folder was opened since the one in X-Behaviors-Root'), + 412: error('The file was changed or deleted on disk, or already exists with If-None-Match: *. `etag` is the ETag of the file on disk, if it exists.'), + 413: error('The body is larger than 10 MB'), + }, + }, + delete: { + tags: ['Files'], + operationId: 'deleteFile', + summary: 'Delete a file', + description: 'Deleting a file that does not exist succeeds, unless If-Match asks for a version of it.', + parameters: [ + filePath, + { $ref: '#/components/parameters/IfMatch' }, + { $ref: '#/components/parameters/BehaviorsRoot' }, + ], + responses: { + 200: { description: 'Deleted', content: json({ type: 'object', required: ['ok'], properties: { ok: { const: true } } }) }, + 400: error('The path is missing, absolute, outside the folder or not an .xml file'), + 409: error('Another folder was opened since the one in X-Behaviors-Root'), + 412: error('The file was changed or deleted on disk. `etag` is the ETag of the file on disk, if it exists.'), + }, + }, + }, + '/api/validate': { + post: { + tags: ['Validation'], + operationId: 'validate', + summary: 'Validate with BehaviorTree.CPP', + description: 'Loads the given files with the native validator (built by bin/build.sh), which runs the real BehaviorTree.CPP. The contents may differ from what is on disk, e.g. unsaved edits. Without the native validator, answers `available: false`.', + parameters: [{ $ref: '#/components/parameters/BehaviorsRoot' }], + requestBody: { + required: true, + content: json({ + type: 'object', + required: ['files'], + properties: { files: { type: 'array', items: ref('File') } }, + }), + }, + responses: { + 200: { description: 'The issues found', content: json(ref('ValidationResult')) }, + 400: error('The body is not JSON, has no files, or a path is not a valid .xml path in the folder'), + 409: error('Another folder was opened since the one in X-Behaviors-Root'), + 413: error('The body is larger than 10 MB'), + }, + }, + }, + '/api/folders': { + get: { + tags: ['Folders'], + operationId: 'listFolders', + summary: 'List the sub-folders of a folder', + description: 'For the Open folder dialog. Only the base folder (BEHAVIORS_BASE, or the home folder of the server\'s user) and the folder opened at startup can be browsed.', + parameters: [{ + name: 'path', + in: 'query', + description: 'The absolute path of the folder. Defaults to the open folder.', + schema: { type: 'string' }, + }], + responses: { + 200: { description: 'The sub-folders', content: json(ref('Folders')) }, + 400: error('The path is not absolute, or not a folder'), + 403: error('The folder is outside those that can be opened, or cannot be read'), + }, + }, + }, + '/api/root': { + put: { + tags: ['Folders'], + operationId: 'openFolder', + summary: 'Open another folder', + description: 'Changes the open folder for every client: the others get 409 Conflict on their next change.', + requestBody: { + required: true, + content: json({ + type: 'object', + required: ['path'], + properties: { path: { type: 'string', description: 'The absolute path of the folder' } }, + }), + }, + responses: { + 200: { description: 'Opened', content: json({ type: 'object', required: ['root'], properties: { root: { type: 'string' } } }) }, + 400: error('The body is not JSON, or the path is not absolute or not a folder'), + 403: error('The folder is outside those that can be opened'), + }, + }, + }, + }, + components: { + parameters: { + IfMatch: { + name: 'If-Match', + in: 'header', + description: 'Write only over this version: the ETag the client read, or `*` for any existing file.', + schema: { type: 'string' }, + }, + IfNoneMatch: { + name: 'If-None-Match', + in: 'header', + description: '`*` to create the file only if it does not exist.', + schema: { type: 'string' }, + }, + BehaviorsRoot: { + name: 'X-Behaviors-Root', + in: 'header', + description: 'The folder the client loaded, URL-encoded. Refuses the change if another folder was opened since.', + schema: { type: 'string' }, + }, + }, + schemas: { + Error: { + type: 'object', + required: ['error'], + properties: { + error: { type: 'string' }, + etag: { type: 'string', description: 'With 412: the ETag of the file on disk, if it exists' }, + }, + }, + File: { + type: 'object', + required: ['path', 'content'], + properties: { + path: { type: 'string', description: 'Relative to the open folder, with / separators' }, + content: { type: 'string', description: 'The XML' }, + }, + }, + StoredFile: { + allOf: [ref('File'), { + type: 'object', + required: ['etag'], + properties: { etag: { type: 'string', description: 'For If-Match, to save over this version only' } }, + }], + }, + Workspace: { + type: 'object', + required: ['root', 'files', 'nativeValidator'], + properties: { + root: { type: 'string', description: 'The absolute path of the open folder' }, + files: { type: 'array', items: ref('StoredFile') }, + builtins: { + type: 'array', + items: ref('NodeModel'), + description: 'The built-in node models of the installed BehaviorTree.CPP, when the native validator is built', + }, + nativeValidator: { type: 'boolean', description: 'Whether POST /api/validate can run BehaviorTree.CPP' }, + }, + }, + NodeModel: { + type: 'object', + required: ['id', 'category', 'ports'], + properties: { + id: { type: 'string', example: 'Sequence' }, + category: { enum: ['Action', 'Condition', 'Control', 'Decorator', 'SubTree'] }, + ports: { type: 'array', items: ref('PortModel') }, + description: { type: 'string' }, + builtin: { type: 'boolean' }, + }, + }, + PortModel: { + type: 'object', + required: ['direction', 'name'], + properties: { + direction: { enum: ['input', 'output', 'inout'] }, + name: { type: 'string' }, + type: { type: 'string' }, + default: { type: 'string' }, + description: { type: 'string' }, + }, + }, + ValidationResult: { + type: 'object', + required: ['available', 'issues'], + properties: { + available: { type: 'boolean', description: 'Whether the native validator is built' }, + issues: { type: 'array', items: ref('Issue') }, + output: { type: 'string', description: 'Anything the validator printed that is not a finding, e.g. a crash' }, + }, + }, + Issue: { + type: 'object', + required: ['severity', 'message', 'file'], + properties: { + severity: { enum: ['error', 'warning', 'info'] }, + message: { type: 'string' }, + file: { type: 'string' }, + tree: { type: 'string', description: 'The ID of the tree at fault' }, + source: { enum: ['editor', 'btcpp'] }, + }, + }, + Folders: { + type: 'object', + required: ['path', 'folders', 'xmlFiles'], + properties: { + path: { type: 'string' }, + parent: { type: 'string', description: 'Absent at the top of the folders that can be opened' }, + folders: { type: 'array', items: { type: 'string' } }, + xmlFiles: { type: 'integer', description: 'The number of XML files directly in the folder' }, + }, + }, + }, + }, +}; + +/** The page of Swagger UI, which loads its files from /api/docs/. */ +export const DOCS_HTML = ` + + + + + Behavior editor API + + + + +
+ + + + +`; diff --git a/tests/api.test.ts b/tests/api.test.ts index 7e1502a..d79e192 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createApi, safePath } from '../src/server/api'; import { etagOf } from '../src/server/files'; +import { OPENAPI } from '../src/server/openapi'; describe('safePath', () => { it('resolves files inside the root', () => { @@ -31,7 +32,7 @@ describe('the API', () => { return { status: res.status, etag: res.headers.get('etag'), data: await res.json() as Record }; }; const put = (path: string, body: string, headers: Record = {}) => - call('PUT', `/api/file?path=${encodeURIComponent(path)}`, { body, headers }); + call('PUT', `/api/files/${path}`, { body, headers }); const onDisk = (path: string) => readFile(join(root, path), 'utf8'); beforeAll(async () => { @@ -67,7 +68,7 @@ describe('the API', () => { expect(data.nativeValidator).toBe(false); }); - it('saves over the version read, and returns the new ETag', async () => { + it('saves over the version read, and returns 200 and the new ETag', async () => { const res = await put('a.xml', 'new', { 'If-Match': etagOf(XML) }); expect(res.status).toBe(200); expect(res.etag).toBe(etagOf('new')); @@ -92,7 +93,7 @@ describe('the API', () => { it('creates a file only if it does not exist, with If-None-Match: *', async () => { expect((await put('a.xml', 'mine', { 'If-None-Match': '*' })).status).toBe(412); - expect((await put('sub/new.xml', XML, { 'If-None-Match': '*' })).status).toBe(200); + expect((await put('sub/new.xml', XML, { 'If-None-Match': '*' })).status).toBe(201); expect(await onDisk('sub/new.xml')).toBe(XML); await rm(join(root, 'sub'), { recursive: true }); }); @@ -103,7 +104,7 @@ describe('the API', () => { }); it('deletes a file only if it is the version read', async () => { - const path = '/api/file?path=a.xml'; + const path = '/api/files/a.xml'; expect((await call('DELETE', path, { headers: { 'If-Match': etagOf('other') } })).status).toBe(412); expect((await call('DELETE', path, { headers: { 'If-Match': etagOf(XML) } })).status).toBe(200); await expect(onDisk('a.xml')).rejects.toThrow(); @@ -117,7 +118,9 @@ describe('the API', () => { }); it('refuses paths outside the folder and bodies too large', async () => { - expect((await put('../x.xml', XML)).status).toBe(400); + // fetch would resolve an unencoded ../ itself. + expect((await put('..%2Fx.xml', XML)).status).toBe(400); + expect((await put('%E0.xml', XML)).status).toBe(400); expect((await put('a.xml', 'x'.repeat(11 * 1024 * 1024))).status).toBe(413); }); @@ -135,7 +138,56 @@ describe('the API', () => { await call('PUT', '/api/root', { body: JSON.stringify({ path: root }) }); }); - it('answers 404 to unknown routes', async () => { + it('accepts the slashes of a path encoded, as Swagger UI sends them', async () => { + expect((await put('sub%2Fencoded.xml', XML)).status).toBe(201); + expect(await onDisk('sub/encoded.xml')).toBe(XML); + await rm(join(root, 'sub'), { recursive: true }); + }); + + it('answers 404 to unknown routes, and 405 to other methods on a file', async () => { expect((await call('GET', '/api/nope')).status).toBe(404); + const res = await fetch(url + '/api/files/a.xml', { method: 'POST' }); + expect(res.status).toBe(405); + expect(res.headers.get('allow')).toBe('PUT, DELETE'); + }); +}); + +describe('the API documentation', () => { + let root: string; + let server: Server; + let url: string; + + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'api-docs-test-')); + const api = createApi(root); + server = createServer((req, res) => void api(req, res)); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + server.close(); + await rm(root, { recursive: true, force: true }); + }); + + it('describes only routes that the API has', async () => { + const spec = await (await fetch(url + '/api/openapi.json')).json() as typeof OPENAPI; + expect(spec).toEqual({ ...OPENAPI, servers: [{ url }] }); + for (const [path, methods] of Object.entries(spec.paths)) { + for (const method of Object.keys(methods)) { + const res = await fetch(url + path.replace('{path}', 'a.xml'), { method: method.toUpperCase() }); + expect(res.status).not.toBe(404); + expect(res.status).not.toBe(405); + } + } + }); + + it('serves Swagger UI', async () => { + const page = await fetch(url + '/api/docs'); + expect(page.headers.get('content-type')).toMatch(/^text\/html/); + for (const [file] of (await page.text()).matchAll(/\/api\/docs\/[\w.-]+/g)) { + expect((await fetch(url + file)).status).toBe(200); + } + expect((await fetch(url + '/api/docs/constructor')).status).toBe(404); }); });