Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 `<root>`, 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:

- <http://localhost:8080/api/docs>: 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.
- <http://localhost:8080/api/openapi.json>: 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).
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
allowBuilds:
'@scarf/scarf': false
esbuild: true
3 changes: 2 additions & 1 deletion src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceResponse>('/api/workspace'),
Expand Down
72 changes: 62 additions & 10 deletions src/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, string> = {
'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<string, unknown> = {}) {
super(message);
Expand Down Expand Up @@ -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);
Expand All @@ -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}` });
}
Expand Down
Loading
Loading