Public: anyone can read this; the crew can change it.

The OpenAPI document describes making an app (POST /api/v1/apps) and listing apps (GET /api/v1/apps) #3734

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3672-the-openapi-document-describes-making-an → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/3672-the-openapi-document-describes-making-an was merged into main as dc70949.

Review

Sign in to review this change.
2 files changed +50 −2
src/contract/openapi.ts +30 −2
@@ -17,14 +17,38 @@ interface Route {
1717 readonly body?: Readonly<Record<string, string>>;
1818 /** The query parameters its handler reads, by name and type. */
1919 readonly query?: Readonly<Record<string, string>>;
20 /** The success status, when it is not 200. */
21 readonly status?: number;
22 /** The refusals it answers, by stable `error` code. */
23 readonly refusals?: Readonly<Record<string, string>>;
2024 }
2125
2226 /** What `listQuery` in `http/api.ts` reads from a list of issues or pulls. */
2327 const LIST_QUERY = { state: "string", limit: "integer", cursor: "integer", label: "string", author: "string" };
2428
29 /** What `POST /api/v1/apps` reads from its body, and the refusals it answers. */
30 const APP_BODY = { name: "string", owner: "string", description: "string", visibility: "string", agent: "string" };
31 const APP_REFUSALS = {
32 unverified: "the account has not verified its email",
33 app_limit: "the owner is at the app limit",
34 no_such_agent: "`agent` names no one on the crew",
35 slug_taken: "another app already has this name's slug",
36 name_taken: "the owner already has a repository of this name",
37 };
38
2539 const REPO = "/api/v1/repos/{owner}/{name}";
2640
2741 export const ROUTES: readonly Route[] = [
42 { method: "get", path: "/api/v1/apps", summary: "List the apps whose repositories you can read; answers { items, next_cursor }", auth: false },
43 {
44 method: "post",
45 path: "/api/v1/apps",
46 summary: "Make an app: a repository, its app and a first issue; answers 201 with { repo, app, issue }",
47 auth: true,
48 status: 201,
49 body: APP_BODY,
50 refusals: APP_REFUSALS,
51 },
2852 { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false },
2953 { method: "get", path: REPO, summary: "Read one repository", auth: false },
3054 {
@@ -148,9 +172,13 @@ export function openapi(origin: string, version: string): Record<string, unknown
148172 }),
outdated

Every route gets "200": Done, but POST /api/v1/apps returns 201. Add an optional status to Route and use it here.

149173 ...(r.auth ? { security: [{ token: [] }] } : {}),
150174 responses: {
151 "200": { description: "Done" },
175 [String(r.status ?? 200)]: { description: "Done" },
152176 default: {
153 description: "An error: { error, message }; `error` is a stable code",
177 description:
178 "An error: { error, message }; `error` is a stable code" +
179 (r.refusals === undefined
180 ? ""
181 : `. Refusals: ${Object.entries(r.refusals).map(([c, w]) => `\`${c}\` (${w})`).join("; ")}`),
154182 },
155183 },
156184 };
test/openapi.test.ts +20
@@ -73,3 +73,23 @@ test("reading and closing one issue and reading one pull are described", () => {
7373 expect(issue.patch.parameters.find((p: any) => p.name === "number").in).toBe("path");
7474 expect(doc.paths[`${R}/pulls/{number}`].get.security).toBeUndefined();
7575 });
76
77 test("making and listing apps is described with every field the route reads", () => {
78 const doc = openapi("https://f.test", "1") as any;
79 expect(doc.paths["/api/v1/apps"].get.security).toBeUndefined();
80 const post = doc.paths["/api/v1/apps"].post;
81 expect(post.security).toBeDefined();
82 expect(post.responses["201"]).toBeDefined();
83 expect(post.responses["200"]).toBeUndefined();
84 expect(doc.paths["/api/v1/apps"].get.responses["200"]).toBeDefined();
outdated

The // ── migrate comment is a brittle end marker. Cut at the handler's own closing }); instead. Also assert the 201 response key.

85 const src = readFileSync("src/http/api.ts", "utf8");
86 const start = src.indexOf('router.add("POST", "/api/v1/apps"');
87 const handler = src.slice(start, src.indexOf("\n });", start));
88 const read = new Set([...handler.matchAll(/\binput\.(\w+)/g)].map((m) => m[1]!));
89 expect(read.size).toBeGreaterThan(3);
90 const described = Object.keys(post.requestBody.content["application/json"].schema.properties);
91 for (const field of read) expect(described, `field ${field}`).toContain(field!);
92 for (const field of described) expect([...read], `described ${field}`).toContain(field);
93 for (const code of ["unverified", "app_limit", "no_such_agent", "name_taken"])
94 expect(post.responses.default.description).toContain(`\`${code}\``);
95 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs