Public: anyone can read this; the crew can change it.
The OpenAPI document describes the feed and the inbox: feed, chips, asks, bulk decide and their undo #5166
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5156-the-openapi-document-describes-the-feed → main 6 commits fork 3 threads · 3 unresolved
14 files
changed +161
−5
src/contract/openapi.ts +1
| @@ -85,6 +85,7 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 85 | 85 | }, |
| 86 | 86 | }), |
| 87 | 87 | }, |
| 88 | ...Object.fromEntries((r.alsoStatus ?? []).map((c) => [String(c), { description: "Done" }])), | |
| 88 | 89 | default: { |
| 89 | 90 | description: |
| 90 | 91 | "An error: { error, message }; `error` is a stable code" + |
src/contract/openapi/routes/getApiV1Asks.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/asks", | |
| 6 | summary: "What asks the caller, ranked, with the week's measure (ro scope; never cached)", | |
| 7 | auth: true, | |
| 8 | }; |
src/contract/openapi/routes/getApiV1Feed.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/feed", | |
| 6 | summary: "The caller's own feed, newest first (ro scope; never cached); answers { items, next cursor } to pass back as `cursor`", | |
| 7 | auth: true, | |
| 8 | query: { limit: "integer", cursor: "string" }, | |
| 9 | refusals: { invalid_limit: "limit is not a whole number from 1 to 50 (422)", invalid_cursor: "the cursor was not issued here (422)" }, | |
| 10 | }; |
src/contract/openapi/routes/getApiV1FeedChips.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/feed/chips", | |
| 6 | summary: "The chips the caller switched off, kinds of post and people (ro scope; never cached); answers { kinds, people }", | |
| 7 | auth: true, | |
| 8 | }; |
src/contract/openapi/routes/index.ts +9
| @@ -35,6 +35,7 @@ export { route as getApiV1Agents } from "./getApiV1Agents.ts"; | ||
| 35 | 35 | export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts"; |
| 36 | 36 | export { route as getApiV1Apps } from "./getApiV1Apps.ts"; |
| 37 | 37 | export { route as getApiV1Asked } from "./getApiV1Asked.ts"; |
| 38 | export { route as getApiV1Asks } from "./getApiV1Asks.ts"; | |
| 38 | 39 | export { route as getApiV1Boards } from "./getApiV1Boards.ts"; |
| 39 | 40 | export { route as getApiV1BoardsPins } from "./getApiV1BoardsPins.ts"; |
| 40 | 41 | export { route as getApiV1BoardsSlug } from "./getApiV1BoardsSlug.ts"; |
| @@ -44,6 +45,8 @@ export { route as getApiV1Contribute } from "./getApiV1Contribute.ts"; | ||
| 44 | 45 | export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts"; |
| 45 | 46 | export { route as getApiV1Explore } from "./getApiV1Explore.ts"; |
| 46 | 47 | export { route as getApiV1Family } from "./getApiV1Family.ts"; |
| 48 | export { route as getApiV1Feed } from "./getApiV1Feed.ts"; | |
| 49 | export { route as getApiV1FeedChips } from "./getApiV1FeedChips.ts"; | |
| 47 | 50 | export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts"; |
| 48 | 51 | export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts"; |
| 49 | 52 | export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts"; |
| @@ -151,9 +154,15 @@ export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminA | ||
| 151 | 154 | export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts"; |
| 152 | 155 | export { route as postApiV1Agents } from "./postApiV1Agents.ts"; |
| 153 | 156 | export { route as postApiV1Apps } from "./postApiV1Apps.ts"; |
| 157 | export { route as postApiV1AsksBulk } from "./postApiV1AsksBulk.ts"; | |
| 158 | export { route as postApiV1AsksBulkIdUndo } from "./postApiV1AsksBulkIdUndo.ts"; | |
| 159 | export { route as postApiV1AsksDecide } from "./postApiV1AsksDecide.ts"; | |
| 160 | export { route as postApiV1AsksDecideIdUndo } from "./postApiV1AsksDecideIdUndo.ts"; | |
| 161 | export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts"; | |
| 154 | 162 | export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts"; |
| 155 | 163 | export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts"; |
| 156 | 164 | export { route as postApiV1Drills } from "./postApiV1Drills.ts"; |
| 165 | export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts"; | |
| 157 | 166 | export { route as postApiV1FeedInteractions } from "./postApiV1FeedInteractions.ts"; |
| 158 | 167 | export { route as postApiV1FeedTells } from "./postApiV1FeedTells.ts"; |
| 159 | 168 | export { route as postApiV1FirstAdmin } from "./postApiV1FirstAdmin.ts"; |
src/contract/openapi/routes/postApiV1AsksBulk.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/asks/bulk", | |
| 6 | summary: | |
| 7 | "One action on many asks. The default is a preview: unless `preview` is false it answers 200 { preview: true, ... } with what would happen to each ask and changes nothing (ro scope). With `preview` false it acts (rw scope) and answers 201 { preview: false, ... } with a receipt naming an operation, to undo at /api/v1/asks/bulk/{id}/undo. `action` is dismiss, decline, mute, ship, close or replan; only close takes a `reason`; `items` is up to 100 ask ids", | |
| 8 | auth: true, | |
| 9 | status: 201, | |
| 10 | alsoStatus: [200], | |
| 11 | body: { action: "string", items: "array", reason: "string", preview: "boolean" }, | |
| 12 | refusals: { | |
| 13 | bad_action: "`action` is unknown, `items` is not a list of ask ids, or `reason` is not a sentence or not for this action (422)", | |
| 14 | too_many: "more than 100 asks at once (429)", | |
| 15 | }, | |
| 16 | }; |
src/contract/openapi/routes/postApiV1AsksBulkIdUndo.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/asks/bulk/{id}/undo", | |
| 6 | summary: "Undo a bulk action on asks by its operation id (rw scope); answers 201 with the undoing operation", | |
| 7 | auth: true, | |
| 8 | status: 201, | |
| 9 | refusals: { | |
| 10 | not_found: "no such asks operation here", | |
| 11 | already_undone: "the operation was undone already (409)", | |
| 12 | expired: "too long has passed to undo it (409)", | |
| 13 | }, | |
| 14 | }; |
src/contract/openapi/routes/postApiV1AsksDecide.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/asks/decide", | |
| 6 | summary: "Let the decider answer a hand-over ask (rw scope); answers 201 with a receipt naming an operation, to undo at /api/v1/asks/decide/{id}/undo", | |
| 7 | auth: true, | |
| 8 | status: 201, | |
| 9 | body: { item: "string" }, | |
| 10 | refusals: { | |
| 11 | bad_action: "the ask is not a hand-over, or no decider is set up on this forge (422)", | |
| 12 | not_found: "the ask no longer asks the caller", | |
| 13 | too_many: "the decider answered this a moment ago; try again in a few minutes (429)", | |
| 14 | }, | |
| 15 | }; |
src/contract/openapi/routes/postApiV1AsksDecideIdUndo.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/asks/decide/{id}/undo", | |
| 6 | summary: "Undo a decision by its id (rw scope); answers 201", | |
| 7 | auth: true, | |
| 8 | status: 201, | |
| 9 | refusals: { not_found: "no such decision", expired: "a day has passed; it stays set aside (409)" }, | |
| 10 | }; |
src/contract/openapi/routes/postApiV1AsksProposalIdVerb.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/asks/proposal/{id}/{verb}", | |
| 6 | summary: "Act on a wave proposal (rw scope); `verb` is approve, redirect (with `words` to say what to do instead), later or again; answers 201", | |
| 7 | auth: true, | |
| 8 | status: 201, | |
| 9 | body: { words: "string" }, | |
| 10 | refusals: { | |
| 11 | forbidden: "a proposal is the owner's to decide, or the caller is not on the crew (403)", | |
| 12 | conflict: "it was already decided (409)", | |
| 13 | invalid: "no words for redirect, too long, no planner or direction issue, or the app is archived (422)", | |
| 14 | not_found: "no such proposal, or no such verb", | |
| 15 | }, | |
| 16 | }; |
src/contract/openapi/routes/postApiV1FeedChips.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/feed/chips", | |
| 6 | summary: "Switch a chip off (`hidden` true) or on (rw scope): `kind` is `kind` with a kind of post as `value`, or `person` with a username; answers { kinds, people }", | |
| 7 | auth: true, | |
| 8 | body: { kind: "string", value: "string", hidden: "boolean" }, | |
| 9 | refusals: { | |
| 10 | invalid_hidden: "hidden is not true or false (422)", | |
| 11 | invalid_chip: "`kind` is neither `kind` nor `person`, or that kind of post has no chip (422)", | |
| 12 | not_found: "no such person, or an organization", | |
| 13 | }, | |
| 14 | }; |
src/contract/openapi/routes/postApiV1FeedInteractions.ts +4 −5
| @@ -3,14 +3,13 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "post", |
| 5 | 5 | path: "/api/v1/feed/interactions", |
| 6 | summary: | |
| 7 | "Record the viewer's act on a feed item (item is a feed item id; kind is opened, dwelled, steered, told, swiped, shipped, shared, dismissed or muted; app is owner/name, read when item is not an event): answers 201 with { id }; an item or app the caller cannot read is a 404", | |
| 6 | summary: "Record an act on a feed item, so the feed learns what the caller wants more or less of (rw scope); `item` is a feed item id, `kind` one the feed knows, `app` is owner/name and names the repository when the item is not an event; answers 201 with { id }", | |
| 8 | 7 | auth: true, |
| 9 | 8 | status: 201, |
| 10 | 9 | body: { item: "string", kind: "string", app: "string" }, |
| 11 | 10 | refusals: { |
| 12 | invalid_item: "422, item is not a feed item id", | |
| 13 | invalid_kind: "422, kind is not one the feed knows", | |
| 14 | not_found: "404, the event or app is not readable by the caller", | |
| 11 | invalid_item: "`item` is not a feed item id (422)", | |
| 12 | invalid_kind: "`kind` is not one the feed knows (422)", | |
| 13 | not_found: "the item or repository is not one the caller can read", | |
| 15 | 14 | }, |
| 16 | 15 | }; |
test/openapi.test.ts +34
| @@ -391,6 +391,40 @@ test("the origin applications are described, and a client secret is written, nev | ||
| 391 | 391 | expect(JSON.stringify(add.responses["201"])).not.toContain("clientSecret"); |
| 392 | 392 | }); |
| 393 | 393 | |
| 394 | test("the feed, chips and asks routes are described, and the bulk default is a preview", () => { | |
| 395 | const doc = openapi("https://f.test", "1") as any; | |
| 396 | const want: Record<string, string[]> = { | |
| 397 | "/api/v1/feed": ["get"], | |
| 398 | "/api/v1/feed/chips": ["get", "post"], | |
| 399 | "/api/v1/feed/interactions": ["post"], | |
| 400 | "/api/v1/feed/tells": ["post"], | |
| 401 | "/api/v1/asks": ["get"], | |
| 402 | "/api/v1/asks/bulk": ["post"], | |
| 403 | "/api/v1/asks/bulk/{id}/undo": ["post"], | |
| 404 | "/api/v1/asks/decide": ["post"], | |
| 405 | "/api/v1/asks/decide/{id}/undo": ["post"], | |
| 406 | "/api/v1/asks/proposal/{id}/{verb}": ["post"], | |
| 407 | }; | |
| 408 | for (const [path, methods] of Object.entries(want)) | |
| 409 | for (const m of methods) expect(doc.paths[path]?.[m]?.security, `${m} ${path}`).toBeDefined(); | |
| 410 | const bulk = doc.paths["/api/v1/asks/bulk"].post; | |
| 411 | expect(bulk.summary).toContain("default is a preview"); | |
| 412 | expect(bulk.responses["201"]).toBeDefined(); | |
| 413 | expect(bulk.responses["200"]).toBeDefined(); | |
| 414 | expect(Object.keys(bulk.requestBody.content["application/json"].schema.properties)).toEqual(["action", "items", "reason", "preview"]); | |
| 415 | expect(bulk.responses.default.description).toContain("too_many"); | |
| 416 | expect(doc.paths["/api/v1/asks/decide"].post.responses.default.description).toContain("too_many"); | |
| 417 | }); | |
| 418 | ||
| 419 | test("every feed and asks route src/http/feed.ts registers is described", () => { | |
| 420 | const src = readFileSync("src/http/feed.ts", "utf8"); | |
| 421 | const doc = openapi("https://f.test", "1") as any; | |
| 422 | const registered = [...src.matchAll(/router\.add\("(\w+)",\s*"([^"]+)"/g)].filter((m) => /^\/api\/v1\/(feed|asks)\b/.test(m[2]!)) | |
| 423 | .map((m) => [m[1]!.toLowerCase(), m[2]!.replace(/:(\w+)/g, "{$1}")] as const); | |
| 424 | expect(registered.length).toBeGreaterThan(8); | |
| 425 | for (const [method, path] of registered) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); | |
| 426 | }); | |
| 427 | ||
| 394 | 428 | test("the served document is tagged by version and origin and answers 304 to its tag", async () => { |
| 395 | 429 | const { openapiText } = await import("../src/contract/served.ts"); |
| 396 | 430 | const { weakEtag } = await import("../src/http/etag.ts"); |
new above
offline