Public: anyone can read this; the crew can change it.
The OpenAPI document gives every limit its bounds, so an agent learns them before a 422 #9092
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9087-the-openapi-document-gives-every-limit-i → main 1 commit fork
30 files
changed +147
−59
src/contract/openapi.ts +5 −2
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | * so pulls describing different routes do not collide (#5936). |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import type { Route } from "./openapi/shared.ts"; | |
| 14 | import type { QueryType, Route } from "./openapi/shared.ts"; | |
| 15 | 15 | import { OAUTH_SCOPES, type OAuthScope } from "../core/oauth.ts"; |
| 16 | 16 | import * as each from "./openapi/routes/index.ts"; |
| 17 | 17 | |
| @@ -41,7 +41,10 @@ export function operationId(r: Pick<Route, "method" | "path">): string { | ||
| 41 | 41 | return `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`; |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | const schema = (t: string) => (t === "object" ? { type: "object" } : t === "array" ? { type: "array", items: { type: "object" } } : { type: t }); | |
| 44 | const schema = (t: QueryType) => | |
| 45 | typeof t !== "string" | |
| 46 | ? { type: t.type, ...(t.min === undefined ? {} : { minimum: t.min }), ...(t.max === undefined ? {} : { maximum: t.max }) } | |
| 47 | : (t === "object" ? { type: "object" } : t === "array" ? { type: "array", items: { type: "object" } } : { type: t }); | |
| 45 | 48 | |
| 46 | 49 | /** The document, for the forge served at `origin`. */ |
| 47 | 50 | export function openapi(origin: string, version: string): Record<string, unknown> { |
src/contract/openapi/routes/getApiV1AdminAccounts.ts +3 −2
| @@ -1,4 +1,5 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { OFFSET_LIMIT_MAX } from "../../../http/offset_page.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| @@ -6,5 +7,5 @@ export const route: Route = { | ||
| 6 | 7 | summary: |
| 7 | 8 | "List every person and agent, newest first (a forge admin's, read scope; anyone else gets 404); answers { items: [{ username, kind, displayName, isAdmin, createdAt, via, signsInThrough, address, repos, apps, spentUsd, suspendedAt, removedAt, verified }], next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", |
| 8 | 9 | auth: true, |
| 9 | query: { limit: "integer", cursor: "string" }, | |
| 10 | query: { limit: limitTo(OFFSET_LIMIT_MAX), cursor: "string" }, | |
| 10 | 11 | }; |
src/contract/openapi/routes/getApiV1AdminInvites.ts +3 −2
| @@ -1,9 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { OFFSET_LIMIT_MAX } from "../../../http/offset_page.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| 5 | 6 | path: "/api/v1/admin/invites", |
| 6 | 7 | summary: "List invite links without their tokens (an admin's, read scope); answers { items, next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", |
| 7 | 8 | auth: true, |
| 8 | query: { limit: "integer", cursor: "string" }, | |
| 9 | query: { limit: limitTo(OFFSET_LIMIT_MAX), cursor: "string" }, | |
| 9 | 10 | }; |
src/contract/openapi/routes/getApiV1AdminPeople.ts +3 −2
| @@ -1,4 +1,5 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { OFFSET_LIMIT_MAX } from "../../../http/offset_page.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| @@ -6,5 +7,5 @@ export const route: Route = { | ||
| 6 | 7 | summary: |
| 7 | 8 | "List every person and their role (admin scope); answers { items: [{ username, role, usdPerMonth }], next_cursor }, role being admin, member or guest; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", |
| 8 | 9 | auth: true, |
| 9 | query: { limit: "integer", cursor: "string" }, | |
| 10 | query: { limit: limitTo(OFFSET_LIMIT_MAX), cursor: "string" }, | |
| 10 | 11 | }; |
src/contract/openapi/routes/getApiV1ConversationTurns.ts +3 −2
| @@ -1,10 +1,11 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { MAX_PAGE } from "../../../core/conversations.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| 5 | 6 | path: "/api/v1/conversation/turns", |
| 6 | 7 | summary: "The signed-in person's conversation with the forge's agent: the latest turns, oldest first, and whether an answer is being written now", |
| 7 | 8 | auth: true, |
| 8 | query: { limit: "integer", before: "string", conversation: "string" }, | |
| 9 | query: { limit: limitTo(MAX_PAGE), before: "string", conversation: "string" }, | |
| 9 | 10 | reply: { running: "boolean", turns: "array" }, |
| 10 | 11 | }; |
src/contract/openapi/routes/getApiV1Feed.ts +3 −2
| @@ -1,10 +1,11 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { FEED_LIMIT_MAX } from "../../../feed/feed.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| 5 | 6 | path: "/api/v1/feed", |
| 6 | 7 | summary: "The caller's own feed, newest first (ro scope; never cached); answers { items, next cursor } to pass back as `cursor`", |
| 7 | 8 | auth: true, |
| 8 | query: { limit: "integer", cursor: "string" }, | |
| 9 | query: { limit: limitTo(FEED_LIMIT_MAX), cursor: "string" }, | |
| 9 | 10 | refusals: { invalid_limit: "limit is not a whole number from 1 to 50 (422)", invalid_cursor: "the cursor was not issued here (422)" }, |
| 10 | 11 | }; |
src/contract/openapi/routes/getApiV1FleetOps.ts +3 −2
| @@ -1,10 +1,11 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| 5 | 6 | path: "/api/v1/fleet/ops", |
| 6 | 7 | summary: "List the fleet operations, newest first; answers { items, next_cursor }", |
| 7 | 8 | auth: true, |
| 8 | query: { limit: "integer", cursor: "string" }, | |
| 9 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, | |
| 9 | 10 | refusals: { invalid_limit: "limit is not a whole number from 1 to 200 (422)" }, |
| 10 | 11 | }; |
src/contract/openapi/routes/getApiV1OrgsOrgTeams.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/orgs/{org}/teams", summary: "List an organization's teams; answers { items, next_cursor }, paged by `limit` and `cursor`: all for an org admin, only the caller's own teams for a member, [] for a stranger or anonymous caller", auth: false, query: { limit: "integer", cursor: "string" } }; | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/orgs/{org}/teams", summary: "List an organization's teams; answers { items, next_cursor }, paged by `limit` and `cursor`: all for an org admin, only the caller's own teams for a member, [] for a stranger or anonymous caller", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" } }; |
src/contract/openapi/routes/getApiV1OrgsOrgTeamsTeamMembers.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/orgs/{org}/teams/{team}/members", summary: "List a team's members as public profiles; answers { items, next_cursor }, paged by `limit` and `cursor`; 404 when the caller may not see the team", auth: false, query: { limit: "integer", cursor: "string" } }; | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/orgs/{org}/teams/{team}/members", summary: "List a team's members as public profiles; answers { items, next_cursor }, paged by `limit` and `cursor`; 404 when the caller may not see the team", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" } }; |
src/contract/openapi/routes/getApiV1Repos.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false, query: { limit: "integer", cursor: "string" }, refusals: { invalid_limit: "limit is not a whole number from 1 to 200 (422)", invalid_cursor: "cursor is not the next_cursor of a previous page (422)" } }; | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, refusals: { invalid_limit: "limit is not a whole number from 1 to 200 (422)", invalid_cursor: "cursor is not the next_cursor of a previous page (422)" } }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameBranches.ts +3 −2
| @@ -1,4 +1,5 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| @@ -6,7 +7,7 @@ export const route: Route = { | ||
| 6 | 7 | summary: |
| 7 | 8 | "List a repository's branches; answers { items: [{ name, oid, isDefault, ahead, behind, protected }], next_cursor }; ahead and behind are null past the comparison cap; limit 1 to 200, cursor from next_cursor; a private repository needs a token", |
| 8 | 9 | auth: false, |
| 9 | query: { limit: "integer", cursor: "string" }, | |
| 10 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, | |
| 10 | 11 | refusals: { |
| 11 | 12 | invalid_limit: "limit is not a whole number from 1 to 200 (422)", |
| 12 | 13 | invalid_cursor: "cursor is not the next_cursor of a page before (422)", |
src/contract/openapi/routes/getApiV1ReposOwnerNameCommits.ts +2 −2
| @@ -1,3 +1,3 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/commits`, summary: "List commits newest first from `ref` (default: the default branch) as a bare array of { oid, parents, author, email, date, subject, body }; limit 1 to 100 (default 30); an unsafe ref is a 422 bad_ref; a private repository needs a token", auth: false, query: { ref: "string", limit: "integer" } }; | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/commits`, summary: "List commits newest first from `ref` (default: the default branch) as a bare array of { oid, parents, author, email, date, subject, body }; limit 1 to 100 (default 30); an unsafe ref is a 422 bad_ref; a private repository needs a token", auth: false, query: { ref: "string", limit: limitTo(100) } }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameDecisions.ts +3 −2
| @@ -1,9 +1,10 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { DECISIONS_LIMIT_MAX } from "../../../core/decisions.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| 5 | 6 | path: `${REPO}/decisions`, |
| 6 | 7 | summary: "Page through the decision ledger as the caller may see it, newest first; `kind` is next, land, retry or guard; `limit` 1 to 100 (default 30), `cursor` is the next_cursor of the page before; answers { decisions, week, next_cursor }; a bad limit or cursor is a 422", |
| 7 | 8 | auth: false, |
| 8 | query: { kind: "string", limit: "integer", cursor: "string" }, | |
| 9 | query: { kind: "string", limit: limitTo(DECISIONS_LIMIT_MAX), cursor: "string" }, | |
| 9 | 10 | }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameEvents.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { EVENTS_LIMIT_MAX } from "../../../core/events_page.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/events`, summary: "Page through a repository's events as the caller may see them, newest first: {items, next_cursor}; limit 1 to 200 (default 50), cursor from next_cursor, kind keeps one event kind (e.g. repo.settings_updated); a bad limit, kind or cursor is a 422", auth: false, query: { kind: "string", limit: "integer", cursor: "string" } }; | |
| 4 | export const route: Route = { method: "get", path: `${REPO}/events`, summary: "Page through a repository's events as the caller may see them, newest first: {items, next_cursor}; limit 1 to 200 (default 50), cursor from next_cursor, kind keeps one event kind (e.g. repo.settings_updated); a bad limit, kind or cursor is a 422", auth: false, query: { kind: "string", limit: limitTo(EVENTS_LIMIT_MAX), cursor: "string" } }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameForks.ts +3 −2
| @@ -1,4 +1,5 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| @@ -6,7 +7,7 @@ export const route: Route = { | ||
| 6 | 7 | summary: |
| 7 | 8 | "List the forks of a repository you can read; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor", |
| 8 | 9 | auth: false, |
| 9 | query: { limit: "integer", cursor: "string" }, | |
| 10 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, | |
| 10 | 11 | refusals: { |
| 11 | 12 | invalid_limit: "limit is not a whole number from 1 to 200 (422)", |
| 12 | 13 | invalid_cursor: "cursor is not the next_cursor of a page before, or names a row deleted since (422)", |
src/contract/openapi/routes/getApiV1ReposOwnerNameHooks.ts +3 −2
| @@ -1,4 +1,5 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| @@ -6,7 +7,7 @@ export const route: Route = { | ||
| 6 | 7 | summary: |
| 7 | 8 | "List the repository's webhooks (admin scope), each without its secret; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor", |
| 8 | 9 | auth: true, |
| 9 | query: { limit: "integer", cursor: "string" }, | |
| 10 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, | |
| 10 | 11 | refusals: { |
| 11 | 12 | invalid_limit: "limit is not a whole number from 1 to 200 (422)", |
| 12 | 13 | invalid_cursor: "cursor is not the next_cursor of a page before, or names a row deleted since (422)", |
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumberComments.ts +2 −2
| @@ -1,3 +1,3 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id; cursor (an issue's commentsNext) answers the page of older comments before it, next being the cursor for older still", auth: false, query: { after: "string", cursor: "string", limit: "integer" } }; | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id; cursor (an issue's commentsNext) answers the page of older comments before it, next being the cursor for older still", auth: false, query: { after: "string", cursor: "string", limit: limitTo(100) } }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameLabels.ts +3 −2
| @@ -1,4 +1,5 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| @@ -6,7 +7,7 @@ export const route: Route = { | ||
| 6 | 7 | summary: |
| 7 | 8 | "List a repository's labels by name, each { id, name, color } and, for a repository writer, open (its open issues and pulls, as on the labels page; a reader is told none, since sealed ones are counted); a private repository needs a token; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor", |
| 8 | 9 | auth: false, |
| 9 | query: { limit: "integer", cursor: "string" }, | |
| 10 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, | |
| 10 | 11 | refusals: { |
| 11 | 12 | invalid_limit: "limit is not a whole number from 1 to 200 (422)", |
| 12 | 13 | invalid_cursor: "cursor is not the next_cursor of a page before, or names a row deleted since (422)", |
src/contract/openapi/routes/getApiV1ReposOwnerNameMilestones.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { OFFSET_LIMIT_MAX } from "../../../http/offset_page.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/milestones`, summary: "Read the plan: { items, next_cursor }, one limb per milestone with its weights (and askToLive for a pillar's umbrella); limit 1–200 (default 50), cursor is the next_cursor of the page before; a private repository needs a token", auth: false, query: { limit: "integer", cursor: "string" } }; | |
| 4 | export const route: Route = { method: "get", path: `${REPO}/milestones`, summary: "Read the plan: { items, next_cursor }, one limb per milestone with its weights (and askToLive for a pillar's umbrella); limit 1–200 (default 50), cursor is the next_cursor of the page before; a private repository needs a token", auth: false, query: { limit: limitTo(OFFSET_LIMIT_MAX), cursor: "string" } }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameQueueGates.ts +2 −2
| @@ -1,3 +1,3 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/queue/gates`, summary: "List the merge queue's recent gates, newest first: { items, next_cursor }; each with its pulls, state (green, red, cut_off), reason, passed and failed counts, the failing tests (up to 20) by name with their file, first error lines and a flaky flag (red in two gates that share no pull), and its duration; kept 7 days; limit 1 to 100 (default 20), cursor a gate id", auth: false, query: { limit: "integer", cursor: "string" } }; | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/queue/gates`, summary: "List the merge queue's recent gates, newest first: { items, next_cursor }; each with its pulls, state (green, red, cut_off), reason, passed and failed counts, the failing tests (up to 20) by name with their file, first error lines and a flaky flag (red in two gates that share no pull), and its duration; kept 7 days; limit 1 to 100 (default 20), cursor a gate id", auth: false, query: { limit: limitTo(100), cursor: "string" } }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameRuns.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/runs`, summary: "List a repository's runs, newest first: { items, next_cursor }; kind deploy or agent, state queued, running, ok, failed or cancelled (another is a 422), env an environment name, limit 1 to 200 (default 50), cursor a run id; each row carries `shown`, the state worked out from what the run did; a stranger's runs carry no cost, turns, agent session or node", auth: false, query: { kind: "string", state: "string", env: "string", limit: "integer", cursor: "string" } }; | |
| 4 | export const route: Route = { method: "get", path: `${REPO}/runs`, summary: "List a repository's runs, newest first: { items, next_cursor }; kind deploy or agent, state queued, running, ok, failed or cancelled (another is a 422), env an environment name, limit 1 to 200 (default 50), cursor a run id; each row carries `shown`, the state worked out from what the run did; a stranger's runs carry no cost, turns, agent session or node", auth: false, query: { kind: "string", state: "string", env: "string", limit: limitTo(LIST_LIMIT_MAX), cursor: "string" } }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameTags.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 1 | import { REPO, type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/tags`, summary: "List a repository's tags, newest first; answers { items: [{ name, oid, commit }], next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", auth: false, query: { limit: "integer", cursor: "string" } }; | |
| 4 | export const route: Route = { method: "get", path: `${REPO}/tags`, summary: "List a repository's tags, newest first; answers { items: [{ name, oid, commit }], next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" } }; |
src/contract/openapi/routes/getApiV1Search.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { SEARCH_LIMIT_MAX } from "../../../actions/search.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string", limit: "integer" }, refusals: { invalid_kind: "kind is not all, repos, issues or code (422)", invalid_limit: "limit is not a whole number from 1 to 100 (422)" }, reply: { repos: "array", issues: "array", code: "array", truncated: "object", moreCode: "boolean", codeSkipped: "boolean", note: "string, only when q is blank: what to send" } }; | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string", limit: limitTo(SEARCH_LIMIT_MAX) }, refusals: { invalid_kind: "kind is not all, repos, issues or code (422)", invalid_limit: "limit is not a whole number from 1 to 100 (422)" }, reply: { repos: "array", issues: "array", code: "array", truncated: "object", moreCode: "boolean", codeSkipped: "boolean", note: "string, only when q is blank: what to send" } }; |
src/contract/openapi/routes/getApiV1UserKeys.ts +3 −2
| @@ -1,9 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| 5 | 6 | path: "/api/v1/user/keys", |
| 6 | 7 | summary: "List your SSH public keys; answers { items: [keys], next_cursor }; limit and cursor page it", |
| 7 | 8 | auth: true, |
| 8 | query: { limit: "integer", cursor: "string" }, | |
| 9 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, | |
| 9 | 10 | }; |
src/contract/openapi/routes/getApiV1UserOrgs.ts +3 −2
| @@ -1,9 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| 5 | 6 | path: "/api/v1/user/orgs", |
| 6 | 7 | summary: "List the organisations you belong to; answers { items: [profiles], next_cursor }; limit and cursor page it", |
| 7 | 8 | auth: true, |
| 8 | query: { limit: "integer", cursor: "string" }, | |
| 9 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, | |
| 9 | 10 | }; |
src/contract/openapi/routes/getApiV1UserPins.ts +3 −2
| @@ -1,9 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| 4 | 5 | method: "get", |
| 5 | 6 | path: "/api/v1/user/pins", |
| 6 | 7 | summary: "List the repositories you pinned; answers { items: [{ repoId, owner, name, defaultBranch, cloneUrl, sshUrl, origin, pinnedAt }], next_cursor }; limit and cursor page it", |
| 7 | 8 | auth: true, |
| 8 | query: { limit: "integer", cursor: "string" }, | |
| 9 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, | |
| 9 | 10 | }; |
src/contract/openapi/routes/getApiV1UsersUsernameRepos.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 1 | import { type Route, limitTo } from "../shared.ts"; | |
| 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; | |
| 2 | 3 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/users/{username}/repos", summary: "List the repositories a person owns that the caller may see (private ones only to those with access); answers { items, next_cursor }, paged by `limit` and `cursor`", auth: false, query: { limit: "integer", cursor: "string" }, refusals: { invalid_limit: "limit is not a whole number from 1 to 200 (422)", invalid_cursor: "cursor is not the next_cursor of a previous page (422)" } }; | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/users/{username}/repos", summary: "List the repositories a person owns that the caller may see (private ones only to those with access); answers { items, next_cursor }, paged by `limit` and `cursor`", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, refusals: { invalid_limit: "limit is not a whole number from 1 to 200 (422)", invalid_cursor: "cursor is not the next_cursor of a previous page (422)" } }; |
test/openapi-limit-bounds.test.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** Every `limit` in the served OpenAPI document states its bounds, and the routes keep to them (#9087). */ | |
| 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 { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | ||
| 9 | let dataDir: string; | |
| 10 | let app: App; | |
| 11 | let servers: Servers; | |
| 12 | type Param = { name: string; in: string; schema: { type: string; minimum?: number; maximum?: number } }; | |
| 13 | let doc: { paths: Record<string, Record<string, { parameters: Param[] }>> }; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | dataDir = await mkdtemp(join(tmpdir(), "forge-openapi-limit-")); | |
| 17 | app = createApp({ dataDir, port: 0 }); | |
| 18 | servers = await serve(app); | |
| 19 | doc = (await (await fetch(`${servers.url}/contract/openapi.json`)).json()) as typeof doc; | |
| 20 | }); | |
| 21 | ||
| 22 | afterAll(async () => { | |
| 23 | servers.stop(); | |
| 24 | app.close(); | |
| 25 | await rm(dataDir, { recursive: true, force: true }); | |
| 26 | }); | |
| 27 | ||
| 28 | const limits = () => | |
| 29 | Object.entries(doc.paths).flatMap(([path, ops]) => | |
| 30 | Object.entries(ops).flatMap(([method, op]) => | |
| 31 | op.parameters.filter((p) => p.in === "query" && p.name === "limit").map((p) => ({ at: `${method.toUpperCase()} ${path}`, schema: p.schema })), | |
| 32 | ), | |
| 33 | ); | |
| 34 | ||
| 35 | test("every limit parameter in the served document is an integer with both bounds", () => { | |
| 36 | const all = limits(); | |
| 37 | expect(all.length).toBeGreaterThanOrEqual(28); | |
| 38 | const bad = all.filter(({ schema }) => schema.type !== "integer" || schema.minimum !== 1 || !Number.isInteger(schema.maximum) || schema.maximum! < 1); | |
| 39 | expect(bad.map((b) => b.at)).toEqual([]); | |
| 40 | }); | |
| 41 | ||
| 42 | for (const path of ["/api/v1/search", "/api/v1/repos"]) { | |
| 43 | test(`${path} answers at the documented maximum and refuses one past it`, async () => { | |
| 44 | const max = doc.paths[path]!["get"]!.parameters.find((p) => p.name === "limit")!.schema.maximum!; | |
| 45 | expect(max).toBeGreaterThan(0); | |
| 46 | const at = await fetch(`${servers.url}${path}?q=x&limit=${max}`); | |
| 47 | expect(at.status).toBe(200); | |
| 48 | const past = await fetch(`${servers.url}${path}?q=x&limit=${max + 1}`); | |
| 49 | expect(past.status).toBe(422); | |
| 50 | expect(((await past.json()) as { error: string }).error).toBe("invalid_limit"); | |
| 51 | }); | |
| 52 | } |
test/openapi-routes.test.ts +4 −2
| @@ -14,8 +14,10 @@ const fields = (v: unknown) => { | ||
| 14 | 14 | expect(typeof v).toBe("object"); |
| 15 | 15 | for (const [k, t] of Object.entries(v as object)) { |
| 16 | 16 | expect(k.length).toBeGreaterThan(0); |
| 17 | expect(typeof t).toBe("string"); | |
| 18 | expect((t as string).trim().length).toBeGreaterThan(0); | |
| 17 | // A query integer may carry its bounds: { type: "integer", min, max }. | |
| 18 | if (typeof t === "object" && t !== null) expect((t as { type?: unknown }).type).toBe("integer"); | |
| 19 | else expect(typeof t).toBe("string"); | |
| 20 | if (typeof t === "string") expect(t.trim().length).toBeGreaterThan(0); | |
| 19 | 21 | } |
| 20 | 22 | }; |
| 21 | 23 |
new above
offline