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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5245-the-openapi-document-describes-itself-a → main 3 commits fork
2 files
changed +144
−2
src/contract/openapi.ts +45 −2
| @@ -693,6 +693,19 @@ export const ROUTES: readonly Route[] = [ | ||
| 693 | 693 | auth: false, |
| 694 | 694 | }, |
| 695 | 695 | { 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 | }, | |
| 696 | 709 | { method: "get", path: `${REPO}/app`, summary: "Read the repository's app: its settings, environments and state", auth: false }, |
| 697 | 710 | { |
| 698 | 711 | method: "post", |
| @@ -1052,11 +1065,41 @@ export const ROUTES: readonly Route[] = [ | ||
| 1052 | 1065 | { |
| 1053 | 1066 | method: "post", |
| 1054 | 1067 | 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}", | |
| 1056 | 1070 | auth: true, |
| 1071 | status: 202, | |
| 1057 | 1072 | 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" }, | |
| 1058 | 1102 | }, |
| 1059 | { method: "get", path: "/api/v1/migrate/{job}", summary: "Read a migration job's stage", auth: true }, | |
| 1060 | 1103 | { |
| 1061 | 1104 | method: "get", |
| 1062 | 1105 | 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 | }); |
new above
offline