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

The OpenAPI document describes the caller's own routes: profile, SSH keys, pins, assigned and orgs #4855

Merged opened this

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

Merged

scout/forge:scout/4845-the-openapi-document-describes-the-calle → main 9 commits fork 6 threads · 6 unresolved

  • Merged. scout/4845-the-openapi-document-describes-the-calle was merged into main as 388317d.

Review

Sign in to review this change.
12 files changed +148 −0
src/contract/openapi/routes/deleteApiV1UserKeysId.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/user/keys/{id}",
6 summary: "Remove one of your SSH keys; needs a token with scope rw or better; answers 204",
7 auth: true,
8 status: 204,
9 refusals: { unauthorized: "401: no valid credentials were sent", forbidden: "403: the token scope is below rw, or a browser session sent a cross-origin request", not_found: "404: none of your keys has this id" },
outdated

respondAccess("not_found", 422) returns notFound(), a 404, before it ever looks at the 422 (respond.ts:237). The row should say 404.

10 };
src/contract/openapi/routes/deleteApiV1UserPinsOwnerName.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/user/pins/{owner}/{name}",
6 summary: "Unpin a repository; needs a token with scope rw or better; answers 204",
7 auth: true,
8 status: 204,
9 refusals: { unauthorized: "401: no valid credentials were sent", forbidden: "403: the token scope is below rw, or a browser session sent a cross-origin request", not_found: "404: no such repository, or it is not readable by the caller" },
10 };
src/contract/openapi/routes/getApiV1User.ts added +8
@@ -0,0 +1,8 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/user",
6 summary: "Read your own profile: the public fields plus emailVerified",
7 auth: true,
8 };
src/contract/openapi/routes/getApiV1UserAssigned.ts added +8
@@ -0,0 +1,8 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/user/assigned",
6 summary: "List the open issues and pulls assigned to you, in repositories you can read; needs a token with scope ro or better; answers { items, next_cursor } in one page: it takes no limit or cursor, and next_cursor is always null",
7 auth: true,
8 };
src/contract/openapi/routes/getApiV1UserKeys.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/user/keys",
6 summary: "List your SSH public keys; answers { items: [keys], next_cursor }; limit and cursor page it",
outdated

The handler answers keyedPage(...), which is { items, next_cursor } with limit/cursor query parameters. It does not answer an array. Describe it the way getApiV1UserAssigned.ts does.

7 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 };
src/contract/openapi/routes/getApiV1UserOrgs.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/user/orgs",
6 summary: "List the organisations you belong to; answers { items: [profiles], next_cursor }; limit and cursor page it",
outdated

Same as keys: this is { items: [profile], next_cursor }, not an array (api.ts:1985, keyedPage).

7 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 };
src/contract/openapi/routes/getApiV1UserPins.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/user/pins",
6 summary: "List the repositories you pinned; answers { items: [{ repoId, owner, name, defaultBranch, cloneUrl, sshUrl, origin, pinnedAt }], next_cursor }; limit and cursor page it",
outdated

Same as keys: json(keyedPage(items, ...)) means { items: [{ repoId, owner, ... }], next_cursor }.

7 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 };
src/contract/openapi/routes/index.ts +10
@@ -22,7 +22,9 @@ export { route as deleteApiV1ReposOwnerNamePullsNumberThreadsAnchorResolve } fro
2222 export { route as deleteApiV1SecretsScopeName } from "./deleteApiV1SecretsScopeName.ts";
2323 export { route as deleteApiV1Sessions } from "./deleteApiV1Sessions.ts";
2424 export { route as deleteApiV1TokensId } from "./deleteApiV1TokensId.ts";
25 export { route as deleteApiV1UserKeysId } from "./deleteApiV1UserKeysId.ts";
2526 export { route as deleteApiV1UserOriginAppsId } from "./deleteApiV1UserOriginAppsId.ts";
27 export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwnerName.ts";
2628 export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts";
2729 export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts";
2830 export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts";
@@ -115,6 +117,10 @@ export { route as getApiV1TelemetryHome } from "./getApiV1TelemetryHome.ts";
115117 export { route as getApiV1TelemetrySources } from "./getApiV1TelemetrySources.ts";
116118 export { route as getApiV1TelemetrySystem } from "./getApiV1TelemetrySystem.ts";
117119 export { route as getApiV1Tokens } from "./getApiV1Tokens.ts";
120 export { route as getApiV1User } from "./getApiV1User.ts";
121 export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts";
122 export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts";
123 export { route as getApiV1UserOrgs } from "./getApiV1UserOrgs.ts";
118124 export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts";
119125 export { route as getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath.ts";
120126 export { route as getApiV1UserOriginsIdBrowseOwnerNameBranches } from "./getApiV1UserOriginsIdBrowseOwnerNameBranches.ts";
@@ -131,6 +137,7 @@ export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRef } from "./getApiV1
131137 export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath.ts";
132138 export { route as getApiV1UserOriginsIdMirrorJob } from "./getApiV1UserOriginsIdMirrorJob.ts";
133139 export { route as getApiV1UserOriginsIdMirrorJobStream } from "./getApiV1UserOriginsIdMirrorJobStream.ts";
140 export { route as getApiV1UserPins } from "./getApiV1UserPins.ts";
134141 export { route as getApiV1UsersUsername } from "./getApiV1UsersUsername.ts";
135142 export { route as getApiV1UsersUsernameRepos } from "./getApiV1UsersUsernameRepos.ts";
136143 export { route as getApiV1UsersUsernameSignins } from "./getApiV1UsersUsernameSignins.ts";
@@ -142,6 +149,7 @@ export { route as patchApiV1ReposOwnerName } from "./patchApiV1ReposOwnerName.ts
142149 export { route as patchApiV1ReposOwnerNameApp } from "./patchApiV1ReposOwnerNameApp.ts";
143150 export { route as patchApiV1ReposOwnerNameIssuesNumber } from "./patchApiV1ReposOwnerNameIssuesNumber.ts";
144151 export { route as patchApiV1ReposOwnerNamePullsNumber } from "./patchApiV1ReposOwnerNamePullsNumber.ts";
152 export { route as patchApiV1User } from "./patchApiV1User.ts";
145153 export { route as postApiV1AdminAccountsUsernameCeiling } from "./postApiV1AdminAccountsUsernameCeiling.ts";
146154 export { route as postApiV1AdminAccountsUsernameModelGrant } from "./postApiV1AdminAccountsUsernameModelGrant.ts";
147155 export { route as postApiV1AdminAccountsUsernameRole } from "./postApiV1AdminAccountsUsernameRole.ts";
@@ -213,6 +221,7 @@ export { route as postApiV1Share } from "./postApiV1Share.ts";
213221 export { route as postApiV1Thoughts } from "./postApiV1Thoughts.ts";
214222 export { route as postApiV1Tokens } from "./postApiV1Tokens.ts";
215223 export { route as postApiV1UserEmailVerify } from "./postApiV1UserEmailVerify.ts";
224 export { route as postApiV1UserKeys } from "./postApiV1UserKeys.ts";
216225 export { route as postApiV1UserOriginApps } from "./postApiV1UserOriginApps.ts";
217226 export { route as postApiV1UserOriginsIdBrowseOwnerNameContribute } from "./postApiV1UserOriginsIdBrowseOwnerNameContribute.ts";
218227 export { route as postApiV1UserOriginsIdFork } from "./postApiV1UserOriginsIdFork.ts";
@@ -237,3 +246,4 @@ export { route as putApiV1ReposOwnerNamePullsNumberHold } from "./putApiV1ReposO
237246 export { route as putApiV1ReposOwnerNamePullsNumberLabels } from "./putApiV1ReposOwnerNamePullsNumberLabels.ts";
238247 export { route as putApiV1ReposOwnerNameUpstream } from "./putApiV1ReposOwnerNameUpstream.ts";
239248 export { route as putApiV1SecretsScopeName } from "./putApiV1SecretsScopeName.ts";
249 export { route as putApiV1UserPinsOwnerName } from "./putApiV1UserPinsOwnerName.ts";
src/contract/openapi/routes/patchApiV1User.ts added +17
@@ -0,0 +1,17 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "patch",
5 path: "/api/v1/user",
6 summary: "Change your own profile (a session, the password or an admin token); answers the profile",
7 auth: true,
8 body: { displayName: "string", email: "string", commitEmail: "string" },
9 refusals: {
10 forbidden: "403: the token is ro or rw; only a session, the password or an admin token may change the profile",
11 invalid_commit_email: "`commitEmail` is neither `noreply` nor `account` (422)",
12 invalid_email: "`email` is not an address an account may have (422)",
13 email_taken: "another account has this `email` (422)",
14 display_name_reserved: "`displayName` is a name only the forge, the crew or its owner may wear (422)",
15 display_name_mixed_script: "`displayName` mixes alphabets (422)",
16 },
17 };
src/contract/openapi/routes/postApiV1UserKeys.ts added +11
@@ -0,0 +1,11 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/user/keys",
6 summary: "Add an SSH public key (an authorized_keys line); needs a token with scope rw or better; answers 201 with the key",
7 auth: true,
8 status: 201,
9 body: { name: "string", key: "string" },
10 refusals: { unauthorized: "401: no valid credentials were sent", forbidden: "403: the token scope is below rw, or a browser session sent a cross-origin request", invalid_key: "`key` is not a usable public key (422)", key_exists: "the key is already registered (422)" },
11 };
src/contract/openapi/routes/putApiV1UserPinsOwnerName.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "put",
5 path: "/api/v1/user/pins/{owner}/{name}",
6 summary: "Pin a repository you can read, again without harm; needs a token with scope rw or better; answers { pinned: true }",
7 auth: true,
8 refusals: { unauthorized: "401: no valid credentials were sent", forbidden: "403: the token scope is below rw, or a browser session sent a cross-origin request", not_found: "404: no such repository, or it is not readable by the caller" },
9 };
test/openapi.test.ts +38
@@ -131,6 +131,44 @@ test("making and listing apps is described with every field the route reads", ()
131131 expect(post.responses.default.description).toContain(`\`${code}\``);
132132 });
133133
134 test("the caller's own routes are described, pinning included", () => {
135 const doc = openapi("https://f.test", "1") as any;
136 const want: Record<string, string[]> = {
137 "/api/v1/user": ["get", "patch"],
138 "/api/v1/user/keys": ["get", "post"],
139 "/api/v1/user/keys/{id}": ["delete"],
140 "/api/v1/user/pins": ["get"],
141 "/api/v1/user/pins/{owner}/{name}": ["put", "delete"],
142 "/api/v1/user/assigned": ["get"],
outdated

This pins a false belief ("waits for the put method"). Put put in the want map for the pins path and drop this assertion; also rename the test.

143 "/api/v1/user/orgs": ["get"],
144 };
145 for (const [path, methods] of Object.entries(want))
146 for (const m of methods) {
147 expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined();
148 expect(doc.paths[path][m].security).toBeDefined();
149 }
150 expect(doc.paths["/api/v1/user/keys"].post.summary).toContain("scope rw");
151 const refusals = (path: string, m: string): string => doc.paths[path][m].responses.default.description;
152 for (const code of ["forbidden", "invalid_commit_email", "invalid_email", "email_taken", "display_name_reserved", "display_name_mixed_script"])
153 expect(refusals("/api/v1/user", "patch")).toContain(`\`${code}\``);
154 for (const code of ["invalid_key", "key_exists"]) expect(refusals("/api/v1/user/keys", "post")).toContain(`\`${code}\``);
155 expect(refusals("/api/v1/user/keys/{id}", "delete")).toContain("`not_found`");
156 expect(refusals("/api/v1/user/keys/{id}", "delete")).toContain("404");
157 for (const m of ["put", "delete"]) expect(refusals("/api/v1/user/pins/{owner}/{name}", m)).toContain("`not_found`");
158 for (const [path, m] of [["/api/v1/user/keys", "post"], ["/api/v1/user/keys/{id}", "delete"], ["/api/v1/user/pins/{owner}/{name}", "put"], ["/api/v1/user/pins/{owner}/{name}", "delete"]] as [string, string][])
159 for (const code of ["unauthorized", "forbidden"]) expect(refusals(path, m), `${m} ${path}`).toContain(`\`${code}\``);
160 const assigned = doc.paths["/api/v1/user/assigned"].get;
161 expect(assigned.summary).toContain("next_cursor is always null");
162 expect(assigned.parameters?.map((p: any) => p.name) ?? []).not.toContain("limit");
163 for (const path of ["/api/v1/user/keys", "/api/v1/user/orgs", "/api/v1/user/pins"]) {
164 const get = doc.paths[path].get;
165 expect(get.summary, path).toContain("{ items");
166 expect(get.summary, path).toContain("next_cursor");
167 expect(get.summary, path).not.toContain("array");
168 expect(get.parameters.map((p: any) => p.name), path).toEqual(expect.arrayContaining(["limit", "cursor"]));
169 }
170 });
171
134172 test("the boards routes are described", () => {
135173 const doc = openapi("https://f.test", "1") as any;
136174 const B = "/api/v1/boards";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs