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

Merged opened this

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

Merged

scout/forge:scout/5313-the-openapi-document-describes-feed-inte → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/5313-the-openapi-document-describes-feed-inte was merged into main as c7a5b14.

Review

Sign in to review this change.
5 files changed +88 −2
src/contract/openapi/routes/index.ts +2
@@ -130,6 +130,7 @@ export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts";
130130 export { route as postApiV1Agents } from "./postApiV1Agents.ts";
131131 export { route as postApiV1Apps } from "./postApiV1Apps.ts";
132132 export { route as postApiV1Drills } from "./postApiV1Drills.ts";
133 export { route as postApiV1FeedInteractions } from "./postApiV1FeedInteractions.ts";
133134 export { route as postApiV1FeedTells } from "./postApiV1FeedTells.ts";
134135 export { route as postApiV1FirstAdmin } from "./postApiV1FirstAdmin.ts";
135136 export { route as postApiV1FirstAdminRecover } from "./postApiV1FirstAdminRecover.ts";
@@ -180,6 +181,7 @@ export { route as postApiV1ReposOwnerNameSpend } from "./postApiV1ReposOwnerName
180181 export { route as postApiV1ReposOwnerNameSync } from "./postApiV1ReposOwnerNameSync.ts";
181182 export { route as postApiV1ReposOwnerNameUpstreamCheck } from "./postApiV1ReposOwnerNameUpstreamCheck.ts";
182183 export { route as postApiV1Sessions } from "./postApiV1Sessions.ts";
184 export { route as postApiV1Share } from "./postApiV1Share.ts";
183185 export { route as postApiV1Thoughts } from "./postApiV1Thoughts.ts";
184186 export { route as postApiV1Tokens } from "./postApiV1Tokens.ts";
185187 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 };
src/contract/openapi/routes/postApiV1Share.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/share",
6 summary:
7 "Send a feed item to up to 10 people: `to` is one username or a list of them, each with or without a leading @; left out, the item goes to the app's crew and owners (or its followers). Answers 201 with { sent: [{ to, outcome }] }, one entry per recipient, outcome being delivered, queued, steered, told, duplicate, skipped or unreachable (no such person, or not one who can see the item); an item the caller cannot read is a 404",
8 auth: true,
9 status: 201,
10 body: { item: "string", to: "array" },
11 refusals: {
12 invalid_item: "422, item is not a feed item id",
13 invalid_target: "422, to is more than 10 usernames or not usernames",
14 not_found: "404, the item is not readable by the caller",
15 },
16 };
test/openapi-coverage.test.ts −2
@@ -70,7 +70,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [
7070 "POST /api/v1/boards/pins",
7171 "POST /api/v1/boards/query",
7272 "POST /api/v1/feed/chips",
73 "POST /api/v1/feed/interactions",
7473 "POST /api/v1/repos/{}/{}/issues/{}/dependencies/{}/confirm",
7574 "POST /api/v1/repos/{}/{}/labels",
7675 "POST /api/v1/repos/{}/{}/milestones/{}/limb",
@@ -83,7 +82,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [
8382 "POST /api/v1/repos/{}/{}/reports/{}/accept",
8483 "POST /api/v1/repos/{}/{}/reports/{}/dismiss",
8584 "POST /api/v1/repos/{}/{}/runs/{}/steer",
86 "POST /api/v1/share",
8785 "POST /api/v1/user/keys",
8886 "POST /api/v1/user/origins",
8987 "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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs