Public: anyone can read this; the crew can change it.

The OpenAPI document describes itself, a migration job and its stream, and opening an app #5262

Merged opened this

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

Merged

scout/forge:scout/5245-the-openapi-document-describes-itself-a → main 3 commits fork

  • Merged. scout/5245-the-openapi-document-describes-itself-a was merged into main as 1d1f092.

Review

Sign in to review this change.
2 files changed +144 −2
src/contract/openapi.ts +45 −2
@@ -693,6 +693,19 @@ export const ROUTES: readonly Route[] = [
693693 auth: false,
694694 },
695695 { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false },
696 {
697 method: "get",
698 path: `${REPO}/app/{env}/open`,
699 summary:
700 "The URL to open an environment at, as { url }; for a private app it carries a grant for the caller alone and is not cached",
701 auth: false,
702 refusals: {
703 not_found: "404: no such repository or app, or not the caller's to see",
704 no_such_env: "422: `env` is not an environment name",
705 no_runtime: "422: this forge runs no apps",
706 unauthorized: "401: a private app and no signed-in account",
707 },
708 },
696709 { method: "get", path: `${REPO}/app`, summary: "Read the repository's app: its settings, environments and state", auth: false },
697710 {
698711 method: "post",
@@ -1052,11 +1065,41 @@ export const ROUTES: readonly Route[] = [
10521065 {
10531066 method: "post",
10541067 path: "/api/v1/migrate",
1055 summary: "Migrate a repository here: source is { url } (needs rw scope) or { originId, fullName } (needs admin scope); after is leave, archive or mirror; answers 202 with the repo and its clone job",
1068 summary:
1069 "Migrate a repository here: source is { url } (needs rw scope) or { originId, fullName } (needs admin scope); after is leave, archive or mirror; branch defaults to the one with the newest commits; crew is { agent, autonomous }; answers 202 with { repo, job }, the clone job watched at /api/v1/migrate/{job}",
10561070 auth: true,
1071 status: 202,
10571072 body: { source: "object", owner: "string", name: "string", visibility: "string", branch: "string", after: "string", crew: "object" },
1073 refusals: {
1074 unauthorized: "401: no signed-in account",
1075 invalid_source: "422: source is neither { url } nor { originId, fullName }",
1076 invalid_after: "400: `after` is not leave, archive or mirror",
1077 unverified: "403: the account has not verified its email",
1078 repository_limit: "403: the owner is at the repository limit",
1079 storage_limit: "403: the owner is at the storage limit",
1080 invalid_url: "422: the URL is not one this forge will clone from",
1081 private_host: "422: the URL is inside the network",
1082 no_such_origin: "422: the origin is not the caller's, or has no such repository",
1083 unreachable: "422: the source could not be reached",
1084 invalid_branch: "422: the branch is not in the source",
1085 no_such_agent: "422: `crew.agent` names no one on the crew",
1086 },
1087 },
1088 {
1089 method: "get",
1090 path: "/api/v1/migrate/{job}",
1091 summary: "Read a migration job's stage as { job }; only the account that started it can",
1092 auth: true,
1093 refusals: { unauthorized: "401: no signed-in account", not_found: "404: no such job, or not the caller's" },
1094 },
1095 {
1096 method: "get",
1097 path: "/api/v1/migrate/{job}/stream",
1098 summary:
1099 "Watch a migration job as an event stream (text/event-stream, not JSON): one `migrate.<stage>` event per stage with the job as data, closing after done or failed",
1100 auth: true,
1101 refusals: { unauthorized: "401: no signed-in account", not_found: "404: no such job, or not the caller's" },
10581102 },
1059 { method: "get", path: "/api/v1/migrate/{job}", summary: "Read a migration job's stage", auth: true },
10601103 {
10611104 method: "get",
10621105 path: `${REPO}/flow`,
test/openapi-selfopen.test.ts added +99
@@ -0,0 +1,99 @@
1 /** The document describes itself, migration jobs and opening an app, and the routes answer as it says. */
2 import { afterAll, beforeAll, 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, type App } from "../src/app.ts";
7 import { openapi } from "../src/contract/openapi.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { seedAdmin } from "./helpers/admin.ts";
10 import { tokenFor } from "./helpers/tokens.ts";
11
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15 let token = "";
16
17 beforeAll(async () => {
18 dataDir = await mkdtemp(join(tmpdir(), "forge-openapi-selfopen-"));
19 app = createApp({ dataDir, port: 0 });
20 servers = await serve(app);
21 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
22 token = tokenFor(app, "alice", "admin");
23 });
24
25 afterAll(async () => {
26 servers.stop();
27 app.close();
28 await rm(dataDir, { recursive: true, force: true });
29 });
30
31 const R = "/api/v1/repos/{owner}/{name}";
32 const WANT: readonly [string, string][] = [
33 ["get", "/api/v1/openapi.json"],
34 ["post", "/api/v1/migrate"],
35 ["get", "/api/v1/migrate/{job}"],
36 ["get", "/api/v1/migrate/{job}/stream"],
37 ["get", `${R}/app/{env}/open`],
38 ];
39
40 const call = (path: string, init: RequestInit = {}) =>
41 fetch(`${servers.url}${path}`, {
42 redirect: "manual",
43 ...init,
44 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
45 });
46 const refusal = async (res: Response) => ((await res.json()) as { error: string }).error;
47
48 /** The codes the document names for a row. */
49 function codes(path: string, method: string): string[] {
50 const doc = openapi("http://x", "1") as any;
51 const text: string = doc.paths[path][method].responses.default.description;
52 return [...text.matchAll(/`(\w+)` \(/g)].map((m) => m[1]!);
53 }
54
55 test("each path and method is in the served document", async () => {
56 const res = await fetch(`${servers.url}/contract/openapi.json`);
57 const paths = ((await res.json()) as any).paths;
58 for (const [method, path] of WANT) expect(paths[path]?.[method]).toBeDefined();
59 expect(paths["/api/v1/openapi.json"].get.responses["308"]).toBeDefined();
60 expect(paths["/api/v1/migrate"].post.responses["202"]).toBeDefined();
61 expect(paths["/api/v1/migrate/{job}/stream"].get.summary).toContain("event stream");
62 });
63
64 test("the redirect to the document is a 308", async () => {
65 const res = await call("/api/v1/openapi.json");
66 expect(res.status).toBe(308);
67 expect(res.headers.get("location")).toBe("/contract/openapi.json");
68 });
69
70 test("a bad source and a bad after answer the codes the rows name", async () => {
71 const bad = await call("/api/v1/migrate", { method: "POST", body: "{}" });
72 expect(bad.status).toBe(422);
73 const code = await refusal(bad);
74 expect(code).toBe("invalid_source");
75 expect(codes("/api/v1/migrate", "post")).toContain(code);
76
77 const after = await call("/api/v1/migrate", {
78 method: "POST",
79 body: JSON.stringify({ source: { url: "https://example.com/a/b" }, after: "burn" }),
80 });
81 expect(after.status).toBe(400);
82 expect(codes("/api/v1/migrate", "post")).toContain(await refusal(after));
83 });
84
85 test("an unknown job, read or streamed, is not_found", async () => {
86 for (const path of ["/api/v1/migrate/nope", "/api/v1/migrate/nope/stream"]) {
87 const res = await call(path);
88 expect(res.status).toBe(404);
89 expect(codes(path.endsWith("stream") ? "/api/v1/migrate/{job}/stream" : "/api/v1/migrate/{job}", "get")).toContain(
90 await refusal(res),
91 );
92 }
93 });
94
95 test("opening a repository with no app, or a bad environment, answers a listed code", async () => {
96 const res = await call("/api/v1/repos/alice/none/app/production/open");
97 expect(res.status).toBe(404);
98 expect(codes(`${R}/app/{env}/open`, "get")).toContain(await refusal(res));
99 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs