diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 98ae47ca7c..9e9f74e73c 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -14340,6 +14340,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -14367,6 +14377,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -14394,6 +14414,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -14587,6 +14617,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14611,6 +14659,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14638,6 +14704,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14665,6 +14749,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14689,6 +14791,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14713,6 +14833,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14743,6 +14881,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] }, "put": { @@ -14774,6 +14930,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14804,6 +14978,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14932,6 +15124,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14992,6 +15202,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15025,6 +15253,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15058,6 +15304,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15082,6 +15346,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15109,6 +15391,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15133,6 +15433,32 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" + } ] } }, @@ -15157,6 +15483,32 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" + } ] } }, @@ -15181,6 +15533,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } ] } }, @@ -15215,6 +15577,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } ] } }, @@ -15239,6 +15611,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } ] } }, @@ -15273,6 +15655,32 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15476,6 +15884,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -15690,6 +16108,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -15717,6 +16145,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, diff --git a/package.json b/package.json index d95c646b4c..f505af589b 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "ui:openapi": "tsx scripts/write-ui-openapi.ts", "ui:openapi:check": "tsx scripts/write-ui-openapi.ts --check", "ui:openapi:settings-parity": "tsx scripts/check-openapi-settings-parity.mjs", + "cloudflare:schema": "tsx scripts/write-cloudflare-schema.ts", "ui:version-audit": "node scripts/check-ui-mcp-version-copy.mjs", "docs:drift-check": "node scripts/check-docs-drift.mjs", "manifest:drift-check": "tsx scripts/check-manifest-drift.mjs", diff --git a/scripts/write-cloudflare-schema.ts b/scripts/write-cloudflare-schema.ts new file mode 100644 index 0000000000..8f35cef0af --- /dev/null +++ b/scripts/write-cloudflare-schema.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env tsx +// Cloudflare's Schema Validation (Security > Settings > Schema validation) rejects an uploaded schema +// once it exceeds the zone's schema-storage limit — 204800 bytes (200 KiB) on the Free plan. The full +// public apps/gittensory-ui/public/openapi.json (pretty-printed, full response bodies + component +// schemas + prose descriptions) is ~480KB, since it's meant for human developers and doesn't need to fit +// under a WAF's storage cap. +// +// Schema Validation only inspects INCOMING requests (path/method/parameters/request body), never +// responses, so this strips everything response- and prose-related and re-derives a minimal, +// spec-valid variant sized for upload. Prints compact (no whitespace) JSON to stdout — redirect it to a +// file to upload: `npm run cloudflare:schema --silent > cloudflare-schema.json` (plain `npm run` +// without --silent interleaves npm's own "> package@version script-name" banner into stdout ahead of +// the JSON, corrupting the file). +import { buildOpenApiSpec } from "../src/openapi/spec"; + +type JsonValue = { [key: string]: unknown } | unknown[] | string | number | boolean | null; + +export function buildCloudflareSchema(): Record { + const spec = buildOpenApiSpec() as unknown as Record; + spec.servers = [{ url: "https://api.loopover.ai", description: "Production" }]; + + const paths = spec.paths as Record>>; + for (const methods of Object.values(paths ?? {})) { + for (const operation of Object.values(methods)) { + delete operation.description; + delete operation.summary; + for (const param of (operation.parameters as Array> | undefined) ?? []) { + delete param.description; + } + const responses = operation.responses as Record> | undefined; + for (const response of Object.values(responses ?? {})) { + delete response.content; + // OpenAPI 3.0 requires responses..description to be present; Schema Validation never + // reads it, so an empty string satisfies the spec without spending upload-size budget on prose. + response.description = ""; + } + } + } + + // components.schemas is response-only in this generator (every request body is inline Zod, never a + // registered $ref) as of this writing, so it becomes fully unreferenced once response content is + // stripped above. Prune by actual reachability rather than assuming that stays true forever: walk + // every remaining $ref under paths, expand transitively through the schemas they point to, and keep + // only what is (still) reachable. + const components = spec.components as { schemas?: Record } | undefined; + const schemas = components?.schemas ?? {}; + const reachable = new Set(); + const frontier = [...collectRefs(paths)]; + while (frontier.length > 0) { + const ref = frontier.pop()!; + const name = ref.split("/").pop()!; + if (reachable.has(name)) continue; + reachable.add(name); + frontier.push(...collectRefs(schemas[name])); + } + if (components) { + components.schemas = Object.fromEntries(Object.entries(schemas).filter(([name]) => reachable.has(name))); + } + + return spec; +} + +export function collectRefs(value: unknown): string[] { + const refs: string[] = []; + walk(value as JsonValue); + return refs; + + function walk(node: JsonValue) { + if (Array.isArray(node)) { + for (const item of node) walk(item as JsonValue); + return; + } + if (node && typeof node === "object") { + const ref = (node as Record).$ref; + if (typeof ref === "string") refs.push(ref); + for (const v of Object.values(node)) walk(v as JsonValue); + } + } +} + +if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file://").href) { + process.stdout.write(JSON.stringify(buildCloudflareSchema())); +} diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 14ad4892c1..31c06bc553 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -300,6 +300,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/installations/{id}/health", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "GitHub App installation health", content: { "application/json": { schema: InstallationHealthSchema } } }, 404: { description: "Installation health not found" }, @@ -308,6 +309,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/installations/{id}/repair", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "GitHub App installation repair diagnostics", content: { "application/json": { schema: InstallationRepairSchema } } }, 404: { description: "Installation health not found" }, @@ -316,6 +318,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/installations/{id}/repair/refresh", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Refreshed GitHub App installation repair diagnostics", content: { "application/json": { schema: InstallationRepairSchema } } }, 404: { description: "Installation not found" }, @@ -370,6 +373,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Repository detail", content: { "application/json": { schema: RepositorySchema } } }, 404: { description: "Repository not found" }, @@ -378,6 +382,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/intelligence", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Canonical repository intelligence bundle", content: { "application/json": { schema: RepoIntelligenceSchema } } }, }, @@ -385,6 +390,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/issue-quality", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Cached or computed issue quality report for the repo", content: { "application/json": { schema: IssueQualityResponseSchema } } }, 404: { description: "Repo is unknown or has no issue-quality coverage yet" }, @@ -393,6 +399,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/outcome-patterns", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Cached or freshly-computed per-repo accepted/rejected PR outcome patterns with freshness envelope and explicit evidence-completeness", content: { "application/json": { schema: RepoOutcomePatternsResponseSchema } } }, 404: { description: "Repo is unknown or has no outcome-pattern coverage yet" }, @@ -401,6 +408,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/registration-readiness", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Gittensor registration readiness signal for repo owners", content: { "application/json": { schema: RegistrationReadinessSchema } } }, }, @@ -408,6 +416,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/gittensor-config-recommendation", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Private Gittensor config recommendation for repo owners", content: { "application/json": { schema: GittensorConfigRecommendationSchema } } }, }, @@ -415,6 +424,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/focus-manifest", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Repo focus manifest and compiled policy for maintainers", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Insufficient role" }, @@ -423,6 +433,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/focus-manifest/refresh", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Refresh the persisted focus manifest cache from the repo file", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Insufficient role" }, @@ -431,6 +442,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "put", path: "/v1/repos/{owner}/{repo}/focus-manifest", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Persist API-backed focus manifest for a repo", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Malformed JSON request body" }, @@ -440,6 +452,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/agent/audit-feed", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: @@ -493,6 +506,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/self-dogfood-registration-pack", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Private self-dogfood registration pack when repo matches configured Gittensory target", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Insufficient role or repo is not the configured self-dogfood target" }, @@ -501,6 +515,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/onboarding-pack/preview", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Preview-only repo onboarding pack for accepted repositories", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Insufficient role" }, @@ -510,6 +525,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Invalid request or explicit create without dryRun false" }, @@ -519,6 +535,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/settings", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Gittensory repository automation settings", content: { "application/json": { schema: RepositorySettingsSchema } } }, }, @@ -526,6 +543,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/settings-preview", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Maintainer dry-run preview of the public surface decision for a sample PR (no GitHub mutation)", content: { "application/json": { schema: RepoSettingsPreviewSchema } } }, 400: { description: "Invalid settings preview request" }, @@ -534,6 +552,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet", + request: { params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }) }, responses: { 200: { description: "PR-specific maintainer review packet", content: { "application/json": { schema: PullRequestMaintainerPacketSchema } } }, }, @@ -541,6 +560,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/pulls/{number}/reviewability", + request: { params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }) }, responses: { 200: { description: "Private PR reviewability score and maintainer action", content: { "application/json": { schema: PullRequestReviewabilitySchema } } }, }, @@ -548,6 +568,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/profile", + request: { params: z.object({ login: z.string() }) }, responses: { 200: { description: "Contributor evidence profile", content: { "application/json": { schema: ContributorProfileSchema } } }, }, @@ -555,6 +576,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/decision-pack", + request: { params: z.object({ login: z.string() }) }, responses: { 200: { description: "Canonical private contributor decision pack. May carry freshness 'stale' or 'rebuilding' when a background rebuild is in progress.", @@ -566,6 +588,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/open-pr-monitor", + request: { params: z.object({ login: z.string() }) }, responses: { 200: { description: "Contributor open-PR monitor with classifications and public-safe next-step packets from cached metadata.", @@ -576,6 +599,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", + request: { params: z.object({ login: z.string(), owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Repo-specific contributor decision from decision pack. May carry freshness 'stale' or 'rebuilding'.", content: { "application/json": { schema: RepoDecisionResponseSchema } } }, 202: { description: "Decision pack snapshot is missing; a background rebuild has been requested", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, @@ -649,6 +673,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/agent/runs/{id}", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Persisted agent run bundle", content: { "application/json": { schema: AgentRunBundleSchema } } }, 404: { description: "Agent run not found" }, @@ -676,6 +701,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/bounties/{id}/advisory", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Bounty lifecycle advisory", content: { "application/json": { schema: BountyAdvisorySchema } } }, 404: { description: "Bounty not found" }, @@ -684,6 +710,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/bounties/{id}/lifecycle", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Bounty lifecycle transition history", content: { "application/json": { schema: BountyLifecycleEventsSchema } } }, 404: { description: "Bounty not found" }, diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index ddcc3b3ca0..a625a6bf9c 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -129,4 +129,18 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/auth/logout"]?.post?.security).toBeUndefined(); expect(spec.paths["/v1/auth/extension/session"]?.post?.security).toEqual([{ GittensoryBearer: [] }, { GittensorySessionCookie: [] }]); }); + + it("declares an `in: path` parameter for every {templated} path segment (Cloudflare schema-validation warning 30046)", () => { + const spec = buildOpenApiSpec(); + for (const [path, methods] of Object.entries(spec.paths ?? {})) { + const templateParams = [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!); + if (templateParams.length === 0) continue; + for (const [method, operation] of Object.entries(methods as Record }>)) { + const declared = new Set((operation.parameters ?? []).filter((p) => p.in === "path").map((p) => p.name)); + for (const param of templateParams) { + expect(declared.has(param), `${method.toUpperCase()} ${path} is missing a declared path parameter for {${param}}`).toBe(true); + } + } + } + }); }); diff --git a/test/unit/write-cloudflare-schema.test.ts b/test/unit/write-cloudflare-schema.test.ts new file mode 100644 index 0000000000..0dab8cf125 --- /dev/null +++ b/test/unit/write-cloudflare-schema.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { buildCloudflareSchema, collectRefs } from "../../scripts/write-cloudflare-schema"; + +// Cloudflare Schema Validation rejects an upload once it exceeds the zone's schema-storage limit +// (204800 bytes / 200 KiB on the Free plan, confirmed against a real "Zone schema storage limit of +// 204800 bytes exceeded" (code 20400) upload error). This pins the trimmed variant well under that +// budget with real margin for the API surface to keep growing. +describe("buildCloudflareSchema", () => { + it("stays comfortably under Cloudflare's 204800-byte Free-plan schema-storage limit", () => { + const compact = JSON.stringify(buildCloudflareSchema()); + expect(compact.length).toBeLessThan(100_000); + }); + + it("is valid, spec-compliant JSON with every response carrying the OpenAPI-required description field", () => { + const spec = buildCloudflareSchema() as { paths: Record }>> }; + for (const methods of Object.values(spec.paths)) { + for (const operation of Object.values(methods)) { + for (const response of Object.values(operation.responses ?? {})) { + expect(typeof response.description).toBe("string"); + expect(response.content).toBeUndefined(); + } + } + } + }); + + it("keeps an in:path parameter declared for every {templated} path segment", () => { + const spec = buildCloudflareSchema() as { paths: Record }>> }; + for (const [path, methods] of Object.entries(spec.paths)) { + const templateParams = [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!); + if (templateParams.length === 0) continue; + for (const operation of Object.values(methods)) { + const declared = new Set((operation.parameters ?? []).filter((p) => p.in === "path").map((p) => p.name)); + for (const param of templateParams) expect(declared.has(param)).toBe(true); + } + } + }); + + it("prunes components.schemas down to only what's still $ref-reachable from paths", () => { + const spec = buildCloudflareSchema() as { paths: unknown; components?: { schemas?: Record } }; + const reachable = new Set(collectRefs(spec.paths).map((ref) => ref.split("/").pop())); + const kept = Object.keys(spec.components?.schemas ?? {}); + for (const name of kept) expect(reachable.has(name)).toBe(true); + }); +}); + +describe("collectRefs", () => { + it("finds every $ref in a nested structure, including inside arrays", () => { + const refs = collectRefs({ + a: { $ref: "#/components/schemas/Foo" }, + b: [{ $ref: "#/components/schemas/Bar" }, { c: { $ref: "#/components/schemas/Baz" } }], + d: "not a ref object", + }); + expect(refs.sort()).toEqual(["#/components/schemas/Bar", "#/components/schemas/Baz", "#/components/schemas/Foo"]); + }); + + it("returns an empty array when nothing references a schema", () => { + expect(collectRefs({ a: { type: "string" }, b: [1, 2, 3] })).toEqual([]); + }); +});