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

Merged opened this

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

Merged

scout/forge:scout/9087-the-openapi-document-gives-every-limit-i → main 1 commit fork

  • Merged. scout/9087-the-openapi-document-gives-every-limit-i was merged into main as e66bfac.

Review

Sign in to review this change.
30 files changed +147 −59
src/contract/openapi.ts +5 −2
@@ -11,7 +11,7 @@
1111 * so pulls describing different routes do not collide (#5936).
1212 */
1313
14 import type { Route } from "./openapi/shared.ts";
14 import type { QueryType, Route } from "./openapi/shared.ts";
1515 import { OAUTH_SCOPES, type OAuthScope } from "../core/oauth.ts";
1616 import * as each from "./openapi/routes/index.ts";
1717
@@ -41,7 +41,10 @@ export function operationId(r: Pick<Route, "method" | "path">): string {
4141 return `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`;
4242 }
4343
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 });
4548
4649 /** The document, for the forge served at `origin`. */
4750 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";
23
34 export const route: Route = {
45 method: "get",
@@ -6,5 +7,5 @@ export const route: Route = {
67 summary:
78 "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",
89 auth: true,
9 query: { limit: "integer", cursor: "string" },
10 query: { limit: limitTo(OFFSET_LIMIT_MAX), cursor: "string" },
1011 };
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";
23
34 export const route: Route = {
45 method: "get",
56 path: "/api/v1/admin/invites",
67 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",
78 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 query: { limit: limitTo(OFFSET_LIMIT_MAX), cursor: "string" },
910 };
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";
23
34 export const route: Route = {
45 method: "get",
@@ -6,5 +7,5 @@ export const route: Route = {
67 summary:
78 "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",
89 auth: true,
9 query: { limit: "integer", cursor: "string" },
10 query: { limit: limitTo(OFFSET_LIMIT_MAX), cursor: "string" },
1011 };
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";
23
34 export const route: Route = {
45 method: "get",
56 path: "/api/v1/conversation/turns",
67 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",
78 auth: true,
8 query: { limit: "integer", before: "string", conversation: "string" },
9 query: { limit: limitTo(MAX_PAGE), before: "string", conversation: "string" },
910 reply: { running: "boolean", turns: "array" },
1011 };
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";
23
34 export const route: Route = {
45 method: "get",
56 path: "/api/v1/feed",
67 summary: "The caller's own feed, newest first (ro scope; never cached); answers { items, next cursor } to pass back as `cursor`",
78 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 query: { limit: limitTo(FEED_LIMIT_MAX), cursor: "string" },
910 refusals: { invalid_limit: "limit is not a whole number from 1 to 50 (422)", invalid_cursor: "the cursor was not issued here (422)" },
1011 };
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";
23
34 export const route: Route = {
45 method: "get",
56 path: "/api/v1/fleet/ops",
67 summary: "List the fleet operations, newest first; answers { items, next_cursor }",
78 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
910 refusals: { invalid_limit: "limit is not a whole number from 1 to 200 (422)" },
1011 };
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";
23
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";
23
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";
23
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";
23
34 export const route: Route = {
45 method: "get",
@@ -6,7 +7,7 @@ export const route: Route = {
67 summary:
78 "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",
89 auth: false,
9 query: { limit: "integer", cursor: "string" },
10 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
1011 refusals: {
1112 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
1213 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";
22
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";
23
34 export const route: Route = {
45 method: "get",
56 path: `${REPO}/decisions`,
67 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",
78 auth: false,
8 query: { kind: "string", limit: "integer", cursor: "string" },
9 query: { kind: "string", limit: limitTo(DECISIONS_LIMIT_MAX), cursor: "string" },
910 };
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";
23
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";
23
34 export const route: Route = {
45 method: "get",
@@ -6,7 +7,7 @@ export const route: Route = {
67 summary:
78 "List the forks of a repository you can read; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor",
89 auth: false,
9 query: { limit: "integer", cursor: "string" },
10 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
1011 refusals: {
1112 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
1213 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";
23
34 export const route: Route = {
45 method: "get",
@@ -6,7 +7,7 @@ export const route: Route = {
67 summary:
78 "List the repository's webhooks (admin scope), each without its secret; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor",
89 auth: true,
9 query: { limit: "integer", cursor: "string" },
10 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
1011 refusals: {
1112 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
1213 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";
22
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";
23
34 export const route: Route = {
45 method: "get",
@@ -6,7 +7,7 @@ export const route: Route = {
67 summary:
78 "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",
89 auth: false,
9 query: { limit: "integer", cursor: "string" },
10 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
1011 refusals: {
1112 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
1213 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";
23
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";
22
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";
23
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";
23
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";
23
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";
23
34 export const route: Route = {
45 method: "get",
56 path: "/api/v1/user/keys",
67 summary: "List your SSH public keys; answers { items: [keys], next_cursor }; limit and cursor page it",
78 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
910 };
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";
23
34 export const route: Route = {
45 method: "get",
56 path: "/api/v1/user/orgs",
67 summary: "List the organisations you belong to; answers { items: [profiles], next_cursor }; limit and cursor page it",
78 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
910 };
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";
23
34 export const route: Route = {
45 method: "get",
56 path: "/api/v1/user/pins",
67 summary: "List the repositories you pinned; answers { items: [{ repoId, owner, name, defaultBranch, cloneUrl, sshUrl, origin, pinnedAt }], next_cursor }; limit and cursor page it",
78 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
910 };
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";
23
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)" } };
src/contract/openapi/shared.ts +11 −3
@@ -4,6 +4,14 @@
44 * uses lives in that route's file.
55 */
66
7 import { LIST_LIMIT_MAX } from "../../core/issues.ts";
8
9 /** A plain type name, or an integer with the bounds its handler refuses outside of (`min`/`max` become `minimum`/`maximum`). */
10 export type QueryType = string | { readonly type: "integer"; readonly min?: number; readonly max?: number };
11
12 /** A `limit` from 1 to `max`, the constant its handler uses; no `max` when the handler has no upper bound. */
13 export const limitTo = (max?: number): QueryType => (max === undefined ? { type: "integer", min: 1 } : { type: "integer", min: 1, max });
14
715 export interface Route {
816 readonly method: "get" | "post" | "put" | "patch" | "delete";
917 readonly path: string;
@@ -12,8 +20,8 @@ export interface Route {
1220 readonly auth: boolean;
1321 /** A JSON body's fields, when it takes one. */
1422 readonly body?: Readonly<Record<string, string>>;
15 /** The query parameters its handler reads, by name and type. */
16 readonly query?: Readonly<Record<string, string>>;
23 /** The query parameters its handler reads, by name and type; an integer may carry the bounds its handler enforces. */
24 readonly query?: Readonly<Record<string, QueryType>>;
1725 /** The success status, when it is not 200. */
1826 readonly status?: number;
1927 /** Other success statuses it answers, when a flag in the request changes it. */
@@ -25,7 +33,7 @@ export interface Route {
2533 }
2634
2735 /** What `listQuery` in `http/api.ts` reads from a list of issues or pulls. */
28 export const LIST_QUERY = { state: "string", limit: "integer", cursor: "integer", label: "string", author: "string" };
36 export const LIST_QUERY = { state: "string", limit: limitTo(LIST_LIMIT_MAX), cursor: "integer", label: "string", author: "string" };
2937
3038 /** What the label routes for issues and pulls refuse by stable `error` code. */
3139 export const LABEL_REFUSALS = {
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) => {
1414 expect(typeof v).toBe("object");
1515 for (const [k, t] of Object.entries(v as object)) {
1616 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);
1921 }
2022 };
2123

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs