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

Merged opened this

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

Merged

scout/forge:scout/5156-the-openapi-document-describes-the-feed → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/5156-the-openapi-document-describes-the-feed was merged into main as 0056fc1.

Review

Sign in to review this change.
14 files changed +161 −5
src/contract/openapi.ts +1
@@ -85,6 +85,7 @@ export function openapi(origin: string, version: string): Record<string, unknown
8585 },
8686 }),
8787 },
88 ...Object.fromEntries((r.alsoStatus ?? []).map((c) => [String(c), { description: "Done" }])),
8889 default: {
8990 description:
9091 "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";
3535 export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts";
3636 export { route as getApiV1Apps } from "./getApiV1Apps.ts";
3737 export { route as getApiV1Asked } from "./getApiV1Asked.ts";
38 export { route as getApiV1Asks } from "./getApiV1Asks.ts";
3839 export { route as getApiV1Boards } from "./getApiV1Boards.ts";
3940 export { route as getApiV1BoardsPins } from "./getApiV1BoardsPins.ts";
4041 export { route as getApiV1BoardsSlug } from "./getApiV1BoardsSlug.ts";
@@ -44,6 +45,8 @@ export { route as getApiV1Contribute } from "./getApiV1Contribute.ts";
4445 export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts";
4546 export { route as getApiV1Explore } from "./getApiV1Explore.ts";
4647 export { route as getApiV1Family } from "./getApiV1Family.ts";
48 export { route as getApiV1Feed } from "./getApiV1Feed.ts";
49 export { route as getApiV1FeedChips } from "./getApiV1FeedChips.ts";
4750 export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts";
4851 export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts";
4952 export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts";
@@ -151,9 +154,15 @@ export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminA
151154 export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts";
152155 export { route as postApiV1Agents } from "./postApiV1Agents.ts";
153156 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";
154162 export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts";
155163 export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts";
156164 export { route as postApiV1Drills } from "./postApiV1Drills.ts";
165 export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts";
157166 export { route as postApiV1FeedInteractions } from "./postApiV1FeedInteractions.ts";
158167 export { route as postApiV1FeedTells } from "./postApiV1FeedTells.ts";
159168 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";
33 export const route: Route = {
44 method: "post",
55 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 }",
87 auth: true,
98 status: 201,
109 body: { item: "string", kind: "string", app: "string" },
1110 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",
1514 },
1615 };
src/contract/openapi/shared.ts +2
@@ -16,6 +16,8 @@ export interface Route {
1616 readonly query?: Readonly<Record<string, string>>;
1717 /** The success status, when it is not 200. */
1818 readonly status?: number;
19 /** Other success statuses it answers, when a flag in the request changes it. */
20 readonly alsoStatus?: readonly number[];
1921 /** The success body's fields, by name and type, when it is worth a schema. */
2022 readonly reply?: Readonly<Record<string, string>>;
2123 /** The refusals it answers, by stable `error` code. */
test/openapi.test.ts +34
@@ -391,6 +391,40 @@ test("the origin applications are described, and a client secret is written, nev
391391 expect(JSON.stringify(add.responses["201"])).not.toContain("clientSecret");
392392 });
393393
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
394428 test("the served document is tagged by version and origin and answers 304 to its tag", async () => {
395429 const { openapiText } = await import("../src/contract/served.ts");
396430 const { weakEtag } = await import("../src/http/etag.ts");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs