Public: anyone can read this; the crew can change it.
The OpenAPI document describes feed interactions and sharing: POST /api/v1/feed/interactions and POST /api/v1/share #5468
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5313-the-openapi-document-describes-feed-inte → main 3 commits fork 2 threads · 2 unresolved
5 files
changed +88
−2
src/contract/openapi/routes/index.ts +2
| @@ -130,6 +130,7 @@ export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts"; | ||
| 130 | 130 | export { route as postApiV1Agents } from "./postApiV1Agents.ts"; |
| 131 | 131 | export { route as postApiV1Apps } from "./postApiV1Apps.ts"; |
| 132 | 132 | export { route as postApiV1Drills } from "./postApiV1Drills.ts"; |
| 133 | export { route as postApiV1FeedInteractions } from "./postApiV1FeedInteractions.ts"; | |
| 133 | 134 | export { route as postApiV1FeedTells } from "./postApiV1FeedTells.ts"; |
| 134 | 135 | export { route as postApiV1FirstAdmin } from "./postApiV1FirstAdmin.ts"; |
| 135 | 136 | export { route as postApiV1FirstAdminRecover } from "./postApiV1FirstAdminRecover.ts"; |
| @@ -180,6 +181,7 @@ export { route as postApiV1ReposOwnerNameSpend } from "./postApiV1ReposOwnerName | ||
| 180 | 181 | export { route as postApiV1ReposOwnerNameSync } from "./postApiV1ReposOwnerNameSync.ts"; |
| 181 | 182 | export { route as postApiV1ReposOwnerNameUpstreamCheck } from "./postApiV1ReposOwnerNameUpstreamCheck.ts"; |
| 182 | 183 | export { route as postApiV1Sessions } from "./postApiV1Sessions.ts"; |
| 184 | export { route as postApiV1Share } from "./postApiV1Share.ts"; | |
| 183 | 185 | export { route as postApiV1Thoughts } from "./postApiV1Thoughts.ts"; |
| 184 | 186 | export { route as postApiV1Tokens } from "./postApiV1Tokens.ts"; |
| 185 | 187 | export { route as postApiV1UserEmailVerify } from "./postApiV1UserEmailVerify.ts"; |
src/contract/openapi/routes/postApiV1FeedInteractions.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/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", | |
| 8 | auth: true, | |
| 9 | status: 201, | |
| 10 | body: { item: "string", kind: "string", app: "string" }, | |
| 11 | 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", | |
| 15 | }, | |
| 16 | }; |
test/openapi-coverage.test.ts −2
| @@ -70,7 +70,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 70 | 70 | "POST /api/v1/boards/pins", |
| 71 | 71 | "POST /api/v1/boards/query", |
| 72 | 72 | "POST /api/v1/feed/chips", |
| 73 | "POST /api/v1/feed/interactions", | |
| 74 | 73 | "POST /api/v1/repos/{}/{}/issues/{}/dependencies/{}/confirm", |
| 75 | 74 | "POST /api/v1/repos/{}/{}/labels", |
| 76 | 75 | "POST /api/v1/repos/{}/{}/milestones/{}/limb", |
| @@ -83,7 +82,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 83 | 82 | "POST /api/v1/repos/{}/{}/reports/{}/accept", |
| 84 | 83 | "POST /api/v1/repos/{}/{}/reports/{}/dismiss", |
| 85 | 84 | "POST /api/v1/repos/{}/{}/runs/{}/steer", |
| 86 | "POST /api/v1/share", | |
| 87 | 85 | "POST /api/v1/user/keys", |
| 88 | 86 | "POST /api/v1/user/origins", |
| 89 | 87 | "POST /api/v1/user/origins/oauth", |
test/openapi-oa-feed.test.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** The OpenAPI document describes feed interactions and sharing. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { serve } from "../src/http/server.ts"; | |
| 9 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 10 | import { openapi } from "../src/contract/openapi.ts"; | |
| 11 | ||
| 12 | test("both writes are in the document with the refusals they answer", () => { | |
| 13 | const doc = openapi("https://f.test", "1") as any; | |
| 14 | for (const [path, codes] of [ | |
| 15 | ["/api/v1/feed/interactions", ["invalid_item", "invalid_kind", "not_found"]], | |
| 16 | ["/api/v1/share", ["invalid_item", "invalid_target", "not_found"]], | |
| 17 | ] as const) { | |
| 18 | const op = doc.paths[path]?.post; | |
| 19 | expect(op, path).toBeDefined(); | |
| 20 | expect(op.responses["201"]).toBeDefined(); | |
| 21 | for (const c of codes) expect(JSON.stringify(op), `${path} ${c}`).toContain(`\`${c}\``); | |
| 22 | } | |
| 23 | }); | |
| 24 | ||
| 25 | test("the refusals are what the routes answer", async () => { | |
| 26 | const dir = await mkdtemp(join(tmpdir(), "forge-oa-feed-")); | |
| 27 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 28 | const servers = await serve(app); | |
| 29 | try { | |
| 30 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 31 | if (!a.ok) throw new Error(a.error); | |
| 32 | const token = await tokenFor(app, "alice", "admin"); | |
| 33 | const call = async (path: string, body: unknown) => { | |
| 34 | const res = await fetch(`${servers.url}${path}`, { | |
| 35 | method: "POST", | |
| 36 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 37 | body: JSON.stringify(body), | |
| 38 | }); | |
| 39 | return { status: res.status, body: (await res.json()) as any }; | |
| 40 | }; | |
| 41 | const I = "/api/v1/feed/interactions"; | |
| 42 | expect(await call(I, { item: "!!", kind: "opened" })).toMatchObject({ status: 422, body: { error: "invalid_item" } }); | |
| 43 | expect(await call(I, { item: "event:abc", kind: "nope" })).toMatchObject({ status: 422, body: { error: "invalid_kind" } }); | |
| 44 | expect(await call(I, { item: "event:abc", kind: "opened" })).toMatchObject({ status: 404, body: { error: "not_found" } }); | |
| 45 | const S = "/api/v1/share"; | |
| 46 | expect(await call(S, { item: "!!" })).toMatchObject({ status: 422, body: { error: "invalid_item" } }); | |
| 47 | expect(await call(S, { item: "event:abc", to: "bad name!" })).toMatchObject({ status: 422, body: { error: "invalid_target" } }); | |
| 48 | expect(await call(S, { item: "event:abc", to: "bob" })).toMatchObject({ status: 404, body: { error: "not_found" } }); | |
| 49 | } finally { | |
| 50 | servers.stop(); | |
| 51 | app.close(); | |
| 52 | await rm(dir, { recursive: true, force: true }); | |
| 53 | } | |
| 54 | }); |
new above
offline