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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3672-the-openapi-document-describes-making-an → main 3 commits fork 2 threads · 2 unresolved
2 files
changed +50
−2
src/contract/openapi.ts +30 −2
| @@ -17,14 +17,38 @@ interface Route { | ||
| 17 | 17 | readonly body?: Readonly<Record<string, string>>; |
| 18 | 18 | /** The query parameters its handler reads, by name and type. */ |
| 19 | 19 | 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>>; | |
| 20 | 24 | } |
| 21 | 25 | |
| 22 | 26 | /** What `listQuery` in `http/api.ts` reads from a list of issues or pulls. */ |
| 23 | 27 | const LIST_QUERY = { state: "string", limit: "integer", cursor: "integer", label: "string", author: "string" }; |
| 24 | 28 | |
| 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 | ||
| 25 | 39 | const REPO = "/api/v1/repos/{owner}/{name}"; |
| 26 | 40 | |
| 27 | 41 | 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 | }, | |
| 28 | 52 | { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false }, |
| 29 | 53 | { method: "get", path: REPO, summary: "Read one repository", auth: false }, |
| 30 | 54 | { |
| @@ -148,9 +172,13 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 148 | 172 | }), |
| 149 | 173 | ...(r.auth ? { security: [{ token: [] }] } : {}), |
| 150 | 174 | responses: { |
| 151 | "200": { description: "Done" }, | |
| 175 | [String(r.status ?? 200)]: { description: "Done" }, | |
| 152 | 176 | 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("; ")}`), | |
| 154 | 182 | }, |
| 155 | 183 | }, |
| 156 | 184 | }; |
test/openapi.test.ts +20
| @@ -73,3 +73,23 @@ test("reading and closing one issue and reading one pull are described", () => { | ||
| 73 | 73 | expect(issue.patch.parameters.find((p: any) => p.name === "number").in).toBe("path"); |
| 74 | 74 | expect(doc.paths[`${R}/pulls/{number}`].get.security).toBeUndefined(); |
| 75 | 75 | }); |
| 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 |
||
| 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 | }); |
new above
offline
Every route gets "200": Done, but POST /api/v1/apps returns 201. Add an optional
statusto Route and use it here.