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
164 changes: 164 additions & 0 deletions apps/api/src/openapi-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Static drift guard between three independent descriptions of the public
* API (issue #829 §1): the routes actually registered on the Hono `app`
* (`app.routes`), the OpenAPI document served at
* `apps/web/public/.well-known/openapi.json`, and the narrative reference in
* `docs/api.md`. Deterministic — no network, no fixtures — so it fails the
* moment a canonical route is added, renamed, or removed without updating
* both docs.
*
* Scope: the canonical `/v1/workspaces/:workspace/files...`, `/usage...`,
* and `/galleries...` verticals (issue #829 §1), plus
* `/public/galleries/:id`. These are the token-authable "public developer
* API" surface documented in `docs/api.md`'s "Canonical routes" table. The
* canonical `github`/`members`/`storage`/`billing`/`comment-settings`
* verticals (issue #613 phases 2-3) are mostly session-only account
* management, covered by their own docs, and out of scope here — folding
* them in is tracked separately, not silently expanded into this guard. The
* legacy `/v1/:workspace/...` bearer-only alias family is also excluded:
* `docs/api.md`'s "Compatibility routes" section documents that whole family
* by convention rather than path by path, and the OpenAPI document does the
* same (see its description field). `KNOWN_UNDOCUMENTED` below is the
* explicit, reasoned exception list for in-scope routes that stay out of the
* OpenAPI document on purpose.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { app } from "./index";

interface OpenApiDoc {
paths: Record<string, Record<string, unknown>>;
}

const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];

const openapiPath = join(import.meta.dirname, "../../web/public/.well-known/openapi.json");
const apiMdPath = join(import.meta.dirname, "../../../docs/api.md");

const openapi = JSON.parse(readFileSync(openapiPath, "utf8")) as OpenApiDoc;
const apiMd = readFileSync(apiMdPath, "utf8");

/**
* Hono param syntax (`:workspace`, `:key{.+}`) -> OpenAPI path templates
* (`{workspace}`, `{key}`), then every `{name}` collapses to `{param}` — the
* two documents don't always spell a path param the same way (e.g. this
* router's `:id` vs. the OpenAPI document's `{galleryId}`), and this guard
* cares about route shape, not param naming.
*/
function toComparablePath(honoOrOpenApiPath: string): string {
return honoOrOpenApiPath
.replace(/:([A-Za-z0-9_]+)(\{[^}]*\})?/g, "{$1}")
.replace(/\{[A-Za-z0-9_]+\}/g, "{param}");
}

/** Path prefixes under `/v1/workspaces/:workspace/` that are in scope — see the module docblock. */
const IN_SCOPE_VERTICALS = ["files", "usage", "galleries"];

/**
* Canonical public-API routes registered on the live app, restricted to the
* files/usage/galleries verticals and the one public gallery read. Each is a
* "METHOD comparable-path" string. A Hono `.all(...)` route (method `ALL`)
* expands to one entry per HTTP method, matching how a real client request
* actually reaches it.
*/
function canonicalRoutes(): Set<string> {
const routes = new Set<string>();
for (const route of app.routes) {
const path = route.path;
const isPublicGallery = path === "/public/galleries/:id";
const isInScopeWorkspaceRoute = IN_SCOPE_VERTICALS.some(
(vertical) =>
path === `/v1/workspaces/:workspace/${vertical}` ||
path.startsWith(`/v1/workspaces/:workspace/${vertical}/`),
);
if (!isInScopeWorkspaceRoute && !isPublicGallery) continue;

const methods =
route.method.toLowerCase() === "all" ? HTTP_METHODS : [route.method.toLowerCase()];
for (const method of methods) {
if (!HTTP_METHODS.includes(method)) continue;
routes.add(`${method} ${toComparablePath(path)}`);
}
}
return routes;
}

/**
* Registered in-scope routes that are deliberately absent from the OpenAPI
* document, with the reason inline. Keep this list short — a new omission
* should be a conscious decision, not a silent gap. Empty today: the
* files-sdk folder-browser gateway (`/v1/workspaces/:workspace/file-browser`)
* is a sibling of the `files` vertical rather than a route under it, so it
* never enters `canonicalRoutes()` in the first place and needs no entry
* here.
*/
const KNOWN_UNDOCUMENTED = new Set<string>();

function openApiRoutes(): Set<string> {
const routes = new Set<string>();
for (const [path, methods] of Object.entries(openapi.paths)) {
if (!path.startsWith("/v1/workspaces/") && path !== "/public/galleries/{galleryId}") continue;
for (const method of Object.keys(methods)) {
if (!HTTP_METHODS.includes(method)) continue; // skips the sibling "parameters" key
routes.add(`${method} ${toComparablePath(path)}`);
}
}
return routes;
}

describe("openapi.json vs. registered canonical routes", () => {
it("documents every registered canonical route (or lists it as a known exception)", () => {
const registered = canonicalRoutes();
const documented = openApiRoutes();
const missing = [...registered].filter(
(route) => !documented.has(route) && !KNOWN_UNDOCUMENTED.has(route),
);
expect(missing).toEqual([]);
});

it("has no stale exceptions — every KNOWN_UNDOCUMENTED entry is still a real route", () => {
const registered = canonicalRoutes();
const stale = [...KNOWN_UNDOCUMENTED].filter((route) => !registered.has(route));
expect(stale).toEqual([]);
});

it("has no stale paths — every documented in-scope route still exists on the app", () => {
const registered = canonicalRoutes();
const documented = openApiRoutes();
const stale = [...documented].filter((route) => !registered.has(route));
expect(stale).toEqual([]);
});
});

describe("docs/api.md vs. registered canonical routes", () => {
it("mentions every registered canonical route's literal path at least once", () => {
const registered = new Set<string>();
for (const route of app.routes) {
const path = route.path;
const isPublicGallery = path === "/public/galleries/:id";
const isInScopeWorkspaceRoute = IN_SCOPE_VERTICALS.some(
(vertical) =>
path === `/v1/workspaces/:workspace/${vertical}` ||
path.startsWith(`/v1/workspaces/:workspace/${vertical}/`),
);
if (isInScopeWorkspaceRoute || isPublicGallery) registered.add(path);
}

const undocumentedComparable = new Set(
[...KNOWN_UNDOCUMENTED].map((route) => route.split(" ")[1]),
);
// Param *names* legitimately differ between the route source and the
// prose (`:item` vs. `:itemId`); normalize both sides to `:param` before
// substring-matching so this checks path *shape*, not naming.
const normalizedApiMd = apiMd.replace(/:([A-Za-z0-9_]+)/g, ":param");
const toDocsPath = (honoPath: string) =>
honoPath.replace(/:([A-Za-z0-9_]+)(\{[^}]*\})?/g, ":param");
const missing = [...registered].filter(
(path) =>
!undocumentedComparable.has(toComparablePath(path)) &&
!normalizedApiMd.includes(toDocsPath(path)),
);
expect(missing).toEqual([]);
});
});
113 changes: 113 additions & 0 deletions apps/web/public/.well-known/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,107 @@
}
}
},
"/v1/workspaces/{workspace}/files/by-path": {
"parameters": [{ "$ref": "#/components/parameters/Workspace" }],
"get": {
"operationId": "listFilesByPath",
"summary": "Group workspace files by GitHub path",
"description": "Powers the web Screenshots page's grouped-by-path view. Groups recently uploaded `gh.*`-tagged files by project and path.",
"parameters": [
{
"name": "merged",
"in": "query",
"schema": { "type": "string", "enum": ["1"] },
"description": "Set to `1` to include only files tagged from a merged PR."
}
],
"responses": {
"200": {
"description": "Path groups, a flat catalog, known projects, and a flat recent feed.",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/FileByPath" } }
}
},
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" }
}
}
},
"/v1/workspaces/{workspace}/files/file-url": {
"parameters": [{ "$ref": "#/components/parameters/Workspace" }],
"get": {
"operationId": "getFileUrl",
"summary": "Resolve a usable URL for a file",
"description": "Returns the workspace's public URL when configured, otherwise a short-lived signed download URL. Resolves across storage lanes so a file uploaded before a storage switch still resolves.",
"parameters": [
{ "name": "key", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "A usable URL.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["url"],
"properties": { "url": { "type": "string", "format": "uri" } }
}
}
}
},
"400": { "$ref": "#/components/responses/Validation" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/v1/workspaces/{workspace}/files/visibility": {
"parameters": [{ "$ref": "#/components/parameters/Workspace" }],
"patch": {
"operationId": "setFileVisibility",
"summary": "Set a file's visibility flag",
"parameters": [
{ "name": "key", "in": "query", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["visibility"],
"properties": {
"visibility": { "type": "string", "enum": ["public", "private"] }
}
}
}
}
},
"responses": {
"200": {
"description": "The updated visibility.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["key", "visibility"],
"properties": {
"key": { "type": "string" },
"visibility": { "type": "string", "enum": ["public", "private"] }
}
}
}
}
},
"400": { "$ref": "#/components/responses/Validation" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" },
"404": { "$ref": "#/components/responses/NotFound" },
"429": { "$ref": "#/components/responses/RateLimited" }
}
}
},
"/v1/workspaces/{workspace}/files/{key}": {
"parameters": [
{ "$ref": "#/components/parameters/Workspace" },
Expand Down Expand Up @@ -812,6 +913,18 @@
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"FileByPath": {
"type": "object",
"required": ["groups", "catalog", "projects", "latest", "truncated", "catalogTruncated"],
"properties": {
"groups": { "type": "array", "items": { "type": "object" } },
"catalog": { "type": "array", "items": { "type": "object" } },
"projects": { "type": "array", "items": { "type": "string" } },
"latest": { "type": "array", "items": { "type": "object" } },
"truncated": { "type": "boolean" },
"catalogTruncated": { "type": "boolean" }
}
},
"FileFacets": {
"oneOf": [
{
Expand Down
35 changes: 35 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ Every non-2xx response uses one nested envelope (same shape as either/releases):
| `message` | Human-readable; may change; never parse this |
| `details` | Optional structured context for select codes |

`type` is the broad category — validation, not_found, conflict, and so on.
Every response of a given `type` uses the same default HTTP status. Branch
client logic on `code`, not `type` or `status`. Many `code` values share one
`type`. Each still names one specific failure: `key_exists`,
`idempotency_key_reused`, and `key_prefix_not_allowed` are all `validation`
or `conflict`, each with its own distinct `code`.

An individual `AppError` can override its default HTTP status. That override
applies only to the response actually sent. It never travels on the wire, so
a client decoding the envelope always recomputes status from `type`. No route
in this API overrides status today. The mechanism exists in
`packages/errors/src/base.ts` for a case that needs it later.

Throw `AppError` subclasses from `@uploads/errors` in route code; the API's
`onError` serializes them. See `packages/errors`.

Expand All @@ -88,6 +101,9 @@ Throw `AppError` subclasses from `@uploads/errors` in route code; the API's
| `GET /v1/workspaces/:workspace/files/:key` | Read object metadata. `?metadata=1` returns only the queryable metadata map |
| `PATCH /v1/workspaces/:workspace/files/:key` | Merge queryable metadata with `{ set?, delete? }` |
| `DELETE /v1/workspaces/:workspace/files/:key` | Delete an object. Returns `200 { key, deleted: true }` |
| `GET /v1/workspaces/:workspace/files/by-path?merged=` | Group recently uploaded `gh.*`-tagged files by project and path. Powers the web Screenshots page |
| `GET /v1/workspaces/:workspace/files/file-url?key=` | Resolve a usable URL for a key: the workspace's public URL, else a short-lived signed URL, else an error |
| `PATCH /v1/workspaces/:workspace/files/visibility?key=` | Set a file's `visibility` flag with `{ visibility: "public" \| "private" }` |
| `GET /v1/workspaces/:workspace/usage` | Read workspace usage, limits, token scopes, and plan; requires `files:read` |
| `POST /v1/workspaces/:workspace/usage/reconcile` | Maintenance operation. Potentially expensive: scans storage and rebuilds `bytes` and `objects`; bearer token only; requires `files:write` |
| `POST /v1/workspaces/:workspace/usage/purge-expired` | Destructive maintenance operation. Permanently deletes objects older than `retentionDays`, then reconciles; bearer token only; requires `files:delete` |
Expand Down Expand Up @@ -121,6 +137,25 @@ File search also keeps its pre-existing `truncated` flag. The flag reports the
same thing from the other direction. `truncated: true` and a non-null `cursor`
always travel together.

### Collection envelope shapes

Every list endpoint below is a stable v1 contract: existing fields keep
their names and types, and any new field is additive. `cursor`/`nextCursor` behave as
described above in every row.

| Endpoint | Envelope |
| ----------------------------------------- | -------------------------------------------------------------- |
| `GET …/files` | `{ files, prefixes, cursor }` |
| `GET …/files/search` | `{ items, truncated, cursor }` |
| `GET …/files/facets` (no `key`) | `{ keys, truncated }` |
| `GET …/files/facets?key=` | `{ key, values, truncated }` |
| `GET …/galleries` | `{ galleries, nextCursor }` |
| `GET …/galleries/by-reference` | `{ galleries, nextCursor }` |
| `GET …/galleries/:id/external-references` | `{ references }` (no pagination; the set is small per gallery) |

`nextCursor` on the gallery endpoints predates the `cursor` convention below
and keeps its name — see "Existing fields keep their names."

## Compatibility routes

The bearer-only `/v1/:workspace/files`, `/v1/:workspace/usage`, and
Expand Down
Loading