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

The OpenAPI document describes an app's routes: members, domains, env, redeploy and stop #4854

Merged opened this

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

Merged

scout/forge:scout/4844-the-openapi-document-describes-an-app-s → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/4844-the-openapi-document-describes-an-app-s was merged into main as f13388c.

Review

Sign in to review this change.
2 files changed +85 −1
src/contract/openapi.ts +62 −1
@@ -8,7 +8,7 @@
88 */
99
1010 interface Route {
11 readonly method: "get" | "post" | "patch" | "delete";
11 readonly method: "get" | "post" | "put" | "patch" | "delete";
1212 readonly path: string;
1313 readonly summary: string;
1414 /** Whether it needs a token; reads of public things do not. */
@@ -122,6 +122,67 @@ export const ROUTES: readonly Route[] = [
122122 },
123123 },
124124 { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false },
125 { method: "get", path: `${REPO}/app`, summary: "Read the repository's app: its settings, environments and state", auth: false },
126 {
127 method: "post",
128 path: `${REPO}/app`,
129 summary: "Turn the repository into an app (admin scope); `access` is public or private, `previewData` is copy or empty; answers 201 with the app",
130 auth: true,
131 body: { slug: "string", access: "string", previewData: "string" },
outdated

Wrong fields: appPatch reads slug, access (public|private) and previewData (copy|empty), not name/description/visibility. Same on the PATCH row below.

132 status: 201,
133 refusals: { app_limit: "the owner is at the app limit" },
134 },
135 {
136 method: "patch",
137 path: `${REPO}/app`,
138 summary: "Change the app's settings (admin scope); `access` is public or private, `previewData` is copy or empty",
139 auth: true,
140 body: { slug: "string", access: "string", previewData: "string" },
141 },
142 { method: "delete", path: `${REPO}/app`, summary: "Turn the app off (admin scope); answers {}", auth: true },
143 { method: "get", path: `${REPO}/app/members`, summary: "Read the people a private app names; answers the members", auth: true },
144 {
145 method: "put",
146 path: `${REPO}/app/members`,
147 summary: "Set the people a private app names (admin scope, a person's, never an agent's); a plan unless dryRun is false",
148 auth: true,
149 body: { members: "array", dryRun: "boolean" },
150 refusals: { invalid: "`members` is not a list of usernames" },
151 },
152 { method: "get", path: `${REPO}/app/domains`, summary: "List the app's own domains (admin scope); answers { items, next_cursor }", auth: true },
153 {
154 method: "post",
155 path: `${REPO}/app/domains`,
156 summary: "Add a domain of your own to the app (admin scope); answers 201 with the domain",
157 auth: true,
158 body: { host: "string" },
159 status: 201,
160 },
161 { method: "delete", path: `${REPO}/app/domains/{host}`, summary: "Remove a domain from the app (admin scope); answers {}", auth: true },
162 { method: "post", path: `${REPO}/app/domains/{host}/check`, summary: "Check that a domain points at the forge (admin scope); answers the domain", auth: true },
163 { method: "get", path: `${REPO}/app/env`, summary: "List the names of the app's environment variables (admin scope); a value is written and never read back; answers { items, next_cursor }", auth: true },
164 {
165 method: "put",
166 path: `${REPO}/app/env/{var}`,
167 summary: "Set an environment variable (admin scope); the value is written and never read back; scope is all (default), production or preview",
168 auth: true,
169 body: { value: "string", scope: "string" },
170 },
171 {
172 method: "delete",
173 path: `${REPO}/app/env/{var}`,
174 summary: "Unset an environment variable (admin scope); scope is all (default), production or preview; answers {}",
175 auth: true,
176 query: { scope: "string" },
177 },
178 {
179 method: "post",
180 path: `${REPO}/app/{env}/redeploy`,
181 summary: "Redeploy an environment (rw scope); answers 202 with { run }",
182 auth: true,
183 status: 202,
184 },
185 { method: "post", path: `${REPO}/app/{env}/stop`, summary: "Stop an environment (rw scope); answers {}", auth: true },
125186 { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false },
126187 {
127188 method: "post",
test/openapi.test.ts +23
@@ -115,3 +115,26 @@ test("making and listing apps is described with every field the route reads", ()
115115 for (const code of ["unverified", "app_limit", "no_such_agent", "name_taken"])
116116 expect(post.responses.default.description).toContain(`\`${code}\``);
117117 });
118
119 test("an app's routes are described, and env values are never read back", () => {
120 const doc = openapi("https://f.test", "1") as any;
121 const A = "/api/v1/repos/{owner}/{name}/app";
122 const want: Record<string, string[]> = {
123 [A]: ["get", "post", "patch", "delete"],
124 [`${A}/members`]: ["get", "put"],
125 [`${A}/domains`]: ["get", "post"],
126 [`${A}/domains/{host}`]: ["delete"],
127 [`${A}/domains/{host}/check`]: ["post"],
128 [`${A}/env`]: ["get"],
129 [`${A}/env/{var}`]: ["put", "delete"],
130 [`${A}/{env}/redeploy`]: ["post"],
131 [`${A}/{env}/stop`]: ["post"],
132 };
133 for (const [path, methods] of Object.entries(want))
134 for (const m of methods) expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined();
135 for (const m of ["post", "patch"])
136 expect(Object.keys(doc.paths[A][m].requestBody.content["application/json"].schema.properties)).toEqual(["slug", "access", "previewData"]);
137 expect(doc.paths[`${A}/env/{var}`].put.summary).toContain("never read back");
138 expect(doc.paths[`${A}/env`].get.summary).toContain("never read back");
139 expect(doc.paths[`${A}/{env}/redeploy`].post.responses["202"]).toBeDefined();
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs