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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4845-the-openapi-document-describes-the-calle → main 9 commits fork 6 threads · 6 unresolved
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" }, | |
| 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 |
||
| 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 |
||
| 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: |
||
| 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 | ||
| 22 | 22 | export { route as deleteApiV1SecretsScopeName } from "./deleteApiV1SecretsScopeName.ts"; |
| 23 | 23 | export { route as deleteApiV1Sessions } from "./deleteApiV1Sessions.ts"; |
| 24 | 24 | export { route as deleteApiV1TokensId } from "./deleteApiV1TokensId.ts"; |
| 25 | export { route as deleteApiV1UserKeysId } from "./deleteApiV1UserKeysId.ts"; | |
| 25 | 26 | export { route as deleteApiV1UserOriginAppsId } from "./deleteApiV1UserOriginAppsId.ts"; |
| 27 | export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwnerName.ts"; | |
| 26 | 28 | export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts"; |
| 27 | 29 | export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts"; |
| 28 | 30 | export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts"; |
| @@ -115,6 +117,10 @@ export { route as getApiV1TelemetryHome } from "./getApiV1TelemetryHome.ts"; | ||
| 115 | 117 | export { route as getApiV1TelemetrySources } from "./getApiV1TelemetrySources.ts"; |
| 116 | 118 | export { route as getApiV1TelemetrySystem } from "./getApiV1TelemetrySystem.ts"; |
| 117 | 119 | 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"; | |
| 118 | 124 | export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts"; |
| 119 | 125 | export { route as getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath.ts"; |
| 120 | 126 | export { route as getApiV1UserOriginsIdBrowseOwnerNameBranches } from "./getApiV1UserOriginsIdBrowseOwnerNameBranches.ts"; |
| @@ -131,6 +137,7 @@ export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRef } from "./getApiV1 | ||
| 131 | 137 | export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath.ts"; |
| 132 | 138 | export { route as getApiV1UserOriginsIdMirrorJob } from "./getApiV1UserOriginsIdMirrorJob.ts"; |
| 133 | 139 | export { route as getApiV1UserOriginsIdMirrorJobStream } from "./getApiV1UserOriginsIdMirrorJobStream.ts"; |
| 140 | export { route as getApiV1UserPins } from "./getApiV1UserPins.ts"; | |
| 134 | 141 | export { route as getApiV1UsersUsername } from "./getApiV1UsersUsername.ts"; |
| 135 | 142 | export { route as getApiV1UsersUsernameRepos } from "./getApiV1UsersUsernameRepos.ts"; |
| 136 | 143 | export { route as getApiV1UsersUsernameSignins } from "./getApiV1UsersUsernameSignins.ts"; |
| @@ -142,6 +149,7 @@ export { route as patchApiV1ReposOwnerName } from "./patchApiV1ReposOwnerName.ts | ||
| 142 | 149 | export { route as patchApiV1ReposOwnerNameApp } from "./patchApiV1ReposOwnerNameApp.ts"; |
| 143 | 150 | export { route as patchApiV1ReposOwnerNameIssuesNumber } from "./patchApiV1ReposOwnerNameIssuesNumber.ts"; |
| 144 | 151 | export { route as patchApiV1ReposOwnerNamePullsNumber } from "./patchApiV1ReposOwnerNamePullsNumber.ts"; |
| 152 | export { route as patchApiV1User } from "./patchApiV1User.ts"; | |
| 145 | 153 | export { route as postApiV1AdminAccountsUsernameCeiling } from "./postApiV1AdminAccountsUsernameCeiling.ts"; |
| 146 | 154 | export { route as postApiV1AdminAccountsUsernameModelGrant } from "./postApiV1AdminAccountsUsernameModelGrant.ts"; |
| 147 | 155 | export { route as postApiV1AdminAccountsUsernameRole } from "./postApiV1AdminAccountsUsernameRole.ts"; |
| @@ -213,6 +221,7 @@ export { route as postApiV1Share } from "./postApiV1Share.ts"; | ||
| 213 | 221 | export { route as postApiV1Thoughts } from "./postApiV1Thoughts.ts"; |
| 214 | 222 | export { route as postApiV1Tokens } from "./postApiV1Tokens.ts"; |
| 215 | 223 | export { route as postApiV1UserEmailVerify } from "./postApiV1UserEmailVerify.ts"; |
| 224 | export { route as postApiV1UserKeys } from "./postApiV1UserKeys.ts"; | |
| 216 | 225 | export { route as postApiV1UserOriginApps } from "./postApiV1UserOriginApps.ts"; |
| 217 | 226 | export { route as postApiV1UserOriginsIdBrowseOwnerNameContribute } from "./postApiV1UserOriginsIdBrowseOwnerNameContribute.ts"; |
| 218 | 227 | export { route as postApiV1UserOriginsIdFork } from "./postApiV1UserOriginsIdFork.ts"; |
| @@ -237,3 +246,4 @@ export { route as putApiV1ReposOwnerNamePullsNumberHold } from "./putApiV1ReposO | ||
| 237 | 246 | export { route as putApiV1ReposOwnerNamePullsNumberLabels } from "./putApiV1ReposOwnerNamePullsNumberLabels.ts"; |
| 238 | 247 | export { route as putApiV1ReposOwnerNameUpstream } from "./putApiV1ReposOwnerNameUpstream.ts"; |
| 239 | 248 | 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", () | ||
| 131 | 131 | expect(post.responses.default.description).toContain(`\`${code}\``); |
| 132 | 132 | }); |
| 133 | 133 | |
| 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 |
||
| 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 | ||
| 134 | 172 | test("the boards routes are described", () => { |
| 135 | 173 | const doc = openapi("https://f.test", "1") as any; |
| 136 | 174 | const B = "/api/v1/boards"; |
respondAccess("not_found", 422)returnsnotFound(), a 404, before it ever looks at the 422 (respond.ts:237). The row should say 404.