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); }); });