From e9b9028f17cb3a10e3886533f7aa38052e1f21d0 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Thu, 24 Sep 2026 21:43:07 +0900 Subject: [PATCH 1/2] fix(openapi-spec): honor declared success statuses in generated responses --- .changeset/calm-status-contracts.md | 8 ++ .../protocols-core/src/interfaces/RouteIR.md | 2 +- packages/openapi-spec/src/libs/emitOpenAPI.ts | 36 +++++--- .../src/tests/emitOpenAPI.spec.ts | 58 +++++++++++- .../openapi-spec/src/tests/round-trip.spec.ts | 90 +++++++++++++++++++ .../protocols-core/src/libs/ContractGraph.ts | 16 ++++ packages/protocols-core/src/libs/RouteIR.ts | 2 +- .../protocols-core/src/libs/extractRouteIR.ts | 2 +- .../src/tests/ContractGraph.spec.ts | 18 ++++ .../src/tests/extractRouteIR.spec.ts | 1 + .../src/libs/CrocoRouteRegistrar.ts | 4 +- .../src/tests/CrocoApp.spec.ts | 17 +++- 12 files changed, 235 insertions(+), 19 deletions(-) create mode 100644 .changeset/calm-status-contracts.md diff --git a/.changeset/calm-status-contracts.md b/.changeset/calm-status-contracts.md new file mode 100644 index 0000000000..9628aca333 --- /dev/null +++ b/.changeset/calm-status-contracts.md @@ -0,0 +1,8 @@ +--- +"@croco/protocols-core": patch +"@croco/openapi-spec": patch +"@croco/transports-http": patch +--- + +- Keep declared route success statuses consistent across extraction, OpenAPI responses, and HTTP output. +- Preserve legacy 200 JSON and 204 empty responses when no status is declared, and reject response schemas on body-forbidden statuses. diff --git a/packages/docs/src/content/docs/api/protocols-core/src/interfaces/RouteIR.md b/packages/docs/src/content/docs/api/protocols-core/src/interfaces/RouteIR.md index 63f76220ff..7690435ca4 100644 --- a/packages/docs/src/content/docs/api/protocols-core/src/interfaces/RouteIR.md +++ b/packages/docs/src/content/docs/api/protocols-core/src/interfaces/RouteIR.md @@ -83,4 +83,4 @@ title: "RouteIR" > `optional` **successStatus?**: `number` -Declared successful HTTP status. Defaults to 200 for legacy RouteIR artifacts. +Declared successful HTTP status. Omitted when the route does not declare one. diff --git a/packages/openapi-spec/src/libs/emitOpenAPI.ts b/packages/openapi-spec/src/libs/emitOpenAPI.ts index 646dc7d97e..568bd041d9 100644 --- a/packages/openapi-spec/src/libs/emitOpenAPI.ts +++ b/packages/openapi-spec/src/libs/emitOpenAPI.ts @@ -150,7 +150,11 @@ export function emitOpenAPIFromContractGraph( : {}), }; - assertContractGraphConsumerRouteCoverage(graph, "openapi", collectOpenAPICoveredRoutes(document)); + assertContractGraphConsumerRouteCoverage( + graph, + "openapi", + collectOpenAPICoveredRoutes(document, routes), + ); return document; } @@ -288,25 +292,26 @@ function toResponseConfig( problemDetailsRef: OpenAPIReference, ): RouteResponses { const outputSchema = unwrapZodEffectsSchema(route.outputSchema); + const successStatus = getSuccessStatus(route); const responses = { ...defaultResponses, ...toDeclaredProblemResponseConfig(route, problemDetailsRef), }; - if (!outputSchema) delete responses[200]; - return { ...responses, - ...(outputSchema + [successStatus]: outputSchema ? { - 200: { - description: "Successful response", - content: { "application/json": { schema: outputSchema } }, - }, + description: "Successful response", + content: { "application/json": { schema: outputSchema } }, } - : { 204: { description: "No content" } }), + : { description: "No content" }, }; } +function getSuccessStatus(route: ContractGraphRoute): number { + return route.successStatus ?? (route.outputSchema ? 200 : 204); +} + function toDeclaredProblemResponseConfig( route: ContractGraphRoute, problemDetailsRef: OpenAPIReference, @@ -498,8 +503,10 @@ function formatRoute(route: ContractGraphRoute): string { function collectOpenAPICoveredRoutes( document: OpenAPIDocument, + routes: readonly ContractGraphRoute[], ): ContractGraphObservedConsumerRoute[] { const coveredRoutes: ContractGraphObservedConsumerRoute[] = []; + const routesById = new Map(routes.map((route) => [route.routeId, route])); for (const [path, pathItem] of Object.entries(document.paths ?? {})) { if (!isRecord(pathItem)) { @@ -529,7 +536,9 @@ function collectOpenAPICoveredRoutes( "request.path": hasOpenAPIParameters(operation, "path") ? "present" : "absent", "request.query": hasOpenAPIParameters(operation, "query") ? "present" : "absent", "request.headers": hasOpenAPIParameters(operation, "header") ? "present" : "absent", - response: hasOpenAPIJsonSuccessResponse(operation) ? "present" : "absent", + response: hasOpenAPIJsonSuccessResponse(operation, routesById.get(operation.summary)) + ? "present" + : "absent", problems: openAPIProblemsFingerprint(operation), entitlements: openAPIEntitlementsFingerprint(operation), }, @@ -581,9 +590,12 @@ function hasOpenAPIParameters( .some((parameter) => parameter.in === location && typeof parameter.name === "string"); } -function hasOpenAPIJsonSuccessResponse(operation: Record): boolean { +function hasOpenAPIJsonSuccessResponse( + operation: Record, + route: ContractGraphRoute | undefined, +): boolean { const responses = isRecord(operation.responses) ? operation.responses : {}; - const successResponse = responses["200"]; + const successResponse = route ? responses[String(getSuccessStatus(route))] : undefined; if (!isRecord(successResponse) || !isRecord(successResponse.content)) { return false; diff --git a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts index f7d0730b33..054d4393bd 100644 --- a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts +++ b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts @@ -25,7 +25,9 @@ import { ProblemResponse, Query, RequestValidationProblem, + REST_ROUTES_KEY, ResponseSchema, + type RouteMetadata, type RouteBody, type RouteMethodReturn, } from "@croco/protocols-rest"; @@ -626,6 +628,60 @@ describe("emitOpenAPI", () => { }); }); + it("should emit a declared 201 JSON success without an inferred 200", () => { + const createdSchema = z.object({ id: z.string() }); + + @Controller("/orders") + class OrdersController { + @Post("/") + @ResponseSchema(createdSchema) + createOrder(): z.infer { + return { id: "order-1" }; + } + } + + const metadata = Reflect.getMetadata(REST_ROUTES_KEY, OrdersController) as RouteMetadata[]; + const route = metadata[0]; + if (!route) throw new TypeError("Expected order route metadata."); + route.statusCode = 201; + + const responses = emitOpenAPI([OrdersController]).paths?.["/orders"]?.post?.responses; + + expect(responses?.[201]).toMatchObject({ + content: { "application/json": { schema: { properties: { id: { type: "string" } } } } }, + }); + expect(responses?.[200]).toBeUndefined(); + }); + + it("should keep a declared bodyless success status and reject a body on 204 or 205", () => { + @Controller("/orders") + class OrdersController { + @Post("/accepted") + acceptOrder(): void {} + + @Post("/invalid") + @ResponseSchema(z.object({ id: z.string() })) + invalidOrder(): void {} + } + + const metadata = Reflect.getMetadata(REST_ROUTES_KEY, OrdersController) as RouteMetadata[]; + const accepted = metadata.find((route) => route.methodName === "acceptOrder"); + const invalid = metadata.find((route) => route.methodName === "invalidOrder"); + if (!accepted || !invalid) throw new TypeError("Expected order route metadata."); + accepted.statusCode = 202; + invalid.statusCode = 204; + + expect(() => emitOpenAPI([OrdersController])).toThrow(/contract-route-body-forbidden-status/); + + invalid.statusCode = 205; + expect(() => emitOpenAPI([OrdersController])).toThrow(/contract-route-body-forbidden-status/); + + invalid.statusCode = 201; + const responses = emitOpenAPI([OrdersController]).paths?.["/orders/accepted"]?.post?.responses; + expect(responses?.[202]).toEqual({ description: "No content" }); + expect(responses?.[204]).toBeUndefined(); + }); + it("should emit request and response contracts from one route schema object", () => { const createUserRoute = defineRouteSchema({ request: { @@ -843,7 +899,7 @@ describe("emitOpenAPI", () => { }, }); expect(responses?.[429]).toEqual({ description: "Too many requests" }); - expect(responses?.[200]).toBeUndefined(); + expect(responses?.[200]).toEqual({ description: "Inherited success response" }); expect(responses?.[204]).toEqual({ description: "No content" }); }); diff --git a/packages/openapi-spec/src/tests/round-trip.spec.ts b/packages/openapi-spec/src/tests/round-trip.spec.ts index a898ff4963..1de1f39548 100644 --- a/packages/openapi-spec/src/tests/round-trip.spec.ts +++ b/packages/openapi-spec/src/tests/round-trip.spec.ts @@ -16,12 +16,14 @@ import { Post, ProblemResponse, Query, + REST_ROUTES_KEY, ResponseSchema, } from "@croco/protocols-rest"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import { createApp } from "../../../transports-http/src/libs/CrocoApp"; import { emitOpenAPI } from "../libs/emitOpenAPI"; +import type { RouteMetadata } from "@croco/protocols-rest"; type User = { readonly id: number; @@ -56,6 +58,13 @@ type GeneratedUsersClient = { }>; }; +type GeneratedCreatedClient = { + readonly createdControllerCreateWidget: (options?: RequestInit) => Promise<{ + readonly data: { readonly id: string }; + readonly status: number; + }>; +}; + type OpenAPIDocument = ReturnType; type OpenAPIPathItem = NonNullable[string]; type OpenAPIOperation = NonNullable["post"]; @@ -128,6 +137,87 @@ describe("OpenAPI round trip", () => { }, ); + it( + "should preserve an explicit 201 response through extraction, OpenAPI, client generation, and HTTP transport", + { timeout: 30000 }, + async () => { + @Controller("/created") + class CreatedController { + @Post("/widgets") + @ResponseSchema(z.object({ id: z.string() })) + createWidget(): { id: string } { + return { id: "widget-1" }; + } + } + + const metadata = Reflect.getMetadata(REST_ROUTES_KEY, CreatedController) as RouteMetadata[]; + const createRoute = metadata.find((route) => route.methodName === "createWidget"); + if (!createRoute) { + throw new TypeError("Expected metadata for CreatedController.createWidget."); + } + createRoute.statusCode = 201; + + const routes = extractRouteIR(CreatedController); + const spec = emitOpenAPI([CreatedController]); + const operation = readOperation(spec, "post", "/created/widgets"); + const app = createApp({ + controllers: [CreatedController], + diValidation: "off", + securityValidation: "off", + }); + const tempDirectory = mkdtempSync(join(tmpdir(), "openapi-created-roundtrip-")); + const specPath = join(tempDirectory, "openapi.json"); + const clientPath = join(tempDirectory, "client.ts"); + const originalFetch = globalThis.fetch; + + try { + expect(routes).toHaveLength(1); + expect(routes[0]?.successStatus).toBe(201); + expect(operation.responses?.[200]).toBeUndefined(); + expect(operation.responses?.[201]).toMatchObject({ + content: { + "application/json": { + schema: expect.objectContaining({ + properties: expect.objectContaining({ + id: expect.objectContaining({ type: "string" }), + }), + }), + }, + }, + }); + + writeFileSync(specPath, JSON.stringify(spec, null, 2)); + runOrval(specPath, clientPath); + expect(readFileSync(clientPath, "utf8")).toContain( + "data: CreatedControllerCreateWidget201", + ); + + globalThis.fetch = (input, init) => { + if (typeof input !== "string" || !input.startsWith("/")) { + return originalFetch(input, init); + } + return app.fetch(new Request(`http://localhost${input}`, init)); + }; + const client = (await import(pathToFileURL(clientPath).href)) as GeneratedCreatedClient; + const response = await client.createdControllerCreateWidget(); + + expect(response.status).toBe(201); + expect(response.data).toEqual({ id: "widget-1" }); + assertRuntimeResponseMatchesOpenAPI( + spec, + operation, + "/created/widgets", + response.status, + "application/json", + response.data, + ); + } finally { + globalThis.fetch = originalFetch; + rmSync(tempDirectory, { force: true, recursive: true }); + } + }, + ); + it("should validate generated operations against real runtime responses", async () => { @Controller("/round-trip") class RoundTripController { diff --git a/packages/protocols-core/src/libs/ContractGraph.ts b/packages/protocols-core/src/libs/ContractGraph.ts index d88fd31207..5b8d57b74c 100644 --- a/packages/protocols-core/src/libs/ContractGraph.ts +++ b/packages/protocols-core/src/libs/ContractGraph.ts @@ -448,6 +448,7 @@ function validateRoute( diagnostics.push(...validatePathParams(route)); diagnostics.push(...validateNamedParams(route)); diagnostics.push(...validateBodyParams(route)); + diagnostics.push(...validateSuccessStatus(route)); diagnostics.push(...validateRouteContract(route)); diagnostics.push(...validateBindingContractSchemas(route)); diagnostics.push(...validateStrictSchemas(route, options)); @@ -459,6 +460,21 @@ function validateRoute( return diagnostics; } +function validateSuccessStatus(route: ContractGraphRoute): ContractDiagnostic[] { + if ((route.successStatus !== 204 && route.successStatus !== 205) || !route.outputSchema) { + return []; + } + + return [ + createRouteDiagnostic( + route, + "contract-route-body-forbidden-status", + "error", + `Success status ${route.successStatus} cannot have a response schema because HTTP forbids a response body.`, + ), + ]; +} + function validateStrictSchemas( route: ContractGraphRoute, options: BuildContractGraphOptions, diff --git a/packages/protocols-core/src/libs/RouteIR.ts b/packages/protocols-core/src/libs/RouteIR.ts index 5470ce733c..89b9ac165a 100644 --- a/packages/protocols-core/src/libs/RouteIR.ts +++ b/packages/protocols-core/src/libs/RouteIR.ts @@ -17,7 +17,7 @@ export interface RouteIR { inputSchema: z.ZodType | null; inputSchemas: RouteInputSchemas; outputSchema: z.ZodType | null; - /** Declared successful HTTP status. Defaults to 200 for legacy RouteIR artifacts. */ + /** Declared successful HTTP status. Omitted when the route does not declare one. */ successStatus?: number; problemResponses?: readonly ProblemResponseIR[]; domain: string | null; diff --git a/packages/protocols-core/src/libs/extractRouteIR.ts b/packages/protocols-core/src/libs/extractRouteIR.ts index b137f6c6c7..5884f8cb57 100644 --- a/packages/protocols-core/src/libs/extractRouteIR.ts +++ b/packages/protocols-core/src/libs/extractRouteIR.ts @@ -70,7 +70,7 @@ export function extractRouteIR(controllerCtor: Constructor): RouteIR[] { inputSchema: inputSchemas.body, inputSchemas, outputSchema, - successStatus: routeMeta.statusCode ?? 200, + ...(routeMeta.statusCode === undefined ? {} : { successStatus: routeMeta.statusCode }), problemResponses, domain: null, }; diff --git a/packages/protocols-core/src/tests/ContractGraph.spec.ts b/packages/protocols-core/src/tests/ContractGraph.spec.ts index 51e443a298..0d58548e9b 100644 --- a/packages/protocols-core/src/tests/ContractGraph.spec.ts +++ b/packages/protocols-core/src/tests/ContractGraph.spec.ts @@ -61,6 +61,24 @@ describe("buildContractGraph", () => { vi.restoreAllMocks(); }); + it.each([204, 205])("rejects response schemas for bodyless success status %i", (status) => { + @Controller("/orders") + class OrdersController { + @Post("/") + @ResponseSchema(z.object({ id: z.string() })) + createOrder(): void {} + } + + const metadata = Reflect.getMetadata(REST_ROUTES_KEY, OrdersController) as RouteMetadata[]; + const route = metadata[0]; + if (!route) throw new TypeError("Expected order route metadata."); + route.statusCode = status; + + expect(buildContractGraph([OrdersController]).diagnostics).toContainEqual( + expect.objectContaining({ code: "contract-route-body-forbidden-status", severity: "error" }), + ); + }); + it("should build stable controller, route id, operation id, and schema graph nodes", () => { const createUserSchema = z.object({ name: z.string() }); diff --git a/packages/protocols-core/src/tests/extractRouteIR.spec.ts b/packages/protocols-core/src/tests/extractRouteIR.spec.ts index ba5b6517f1..5374cedbef 100644 --- a/packages/protocols-core/src/tests/extractRouteIR.spec.ts +++ b/packages/protocols-core/src/tests/extractRouteIR.spec.ts @@ -58,6 +58,7 @@ describe("extractRouteIR", () => { outputSchema: null, }); expect(routes[0]?.params).toEqual([{ index: 0, kind: "path", name: "id", schema: null }]); + expect(routes[0]?.successStatus).toBeUndefined(); }); it("should extract a POST route with body schema as input schema", () => { diff --git a/packages/transports-http/src/libs/CrocoRouteRegistrar.ts b/packages/transports-http/src/libs/CrocoRouteRegistrar.ts index 56627faa0e..269064a2b1 100644 --- a/packages/transports-http/src/libs/CrocoRouteRegistrar.ts +++ b/packages/transports-http/src/libs/CrocoRouteRegistrar.ts @@ -388,8 +388,8 @@ export class CrocoRouteRegistrar { } if (result === undefined || result === null) { - ctx.res.status = 204; - return this.toEmptyResponse(ctx); + ctx.res.status = successStatus ?? 204; + return this.toEmptyResponse(ctx, ctx.res.status); } return ctx.jsonResponse(result, successStatus ?? 200); diff --git a/packages/transports-http/src/tests/CrocoApp.spec.ts b/packages/transports-http/src/tests/CrocoApp.spec.ts index 3c1a71f74a..3f9043735c 100644 --- a/packages/transports-http/src/tests/CrocoApp.spec.ts +++ b/packages/transports-http/src/tests/CrocoApp.spec.ts @@ -492,7 +492,7 @@ describe("CrocoApp", () => { expect(json).toEqual({ message: "Hello, World!" }); }); - it("should apply declared route success statuses to JSON responses", async () => { + it("should apply declared route success statuses to JSON and empty responses", async () => { @Controller("/success-status") class SuccessStatusController { @Post("/created") @@ -509,6 +509,11 @@ describe("CrocoApp", () => { empty() { return null; } + + @Post("/accepted") + accepted() { + return undefined; + } } const routeMetadata = Reflect.getMetadata( @@ -520,6 +525,11 @@ describe("CrocoApp", () => { throw new TypeError("Expected metadata for SuccessStatusController.create."); } createdRoute.statusCode = 201; + const acceptedRoute = routeMetadata.find((route) => route.methodName === "accepted"); + if (!acceptedRoute) { + throw new TypeError("Expected metadata for SuccessStatusController.accepted."); + } + acceptedRoute.statusCode = 202; const app = createApp({ controllers: [SuccessStatusController] }); const createdResponse = await app.fetch( @@ -529,11 +539,16 @@ describe("CrocoApp", () => { const emptyResponse = await app.fetch( new Request("http://localhost/success-status/empty", { method: "POST" }), ); + const acceptedResponse = await app.fetch( + new Request("http://localhost/success-status/accepted", { method: "POST" }), + ); expect(createdResponse.status).toBe(201); await expect(createdResponse.json()).resolves.toEqual({ created: true }); expect(defaultResponse.status).toBe(200); expect(emptyResponse.status).toBe(204); + expect(acceptedResponse.status).toBe(202); + await expect(acceptedResponse.text()).resolves.toBe(""); }); it("should run HTTP middlewares around the controller handler", async () => { From cc7fe332cbf76fcca4ac8516b26a2cc490ae9620 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Thu, 24 Sep 2026 23:39:50 +0900 Subject: [PATCH 2/2] fix(protocols-core): reject invalid declared success statuses --- packages/openapi-spec/src/libs/emitOpenAPI.ts | 2 +- .../src/tests/emitOpenAPI.spec.ts | 19 +++++ .../protocols-core/src/libs/ContractGraph.ts | 16 ++++ .../src/tests/ContractGraph.spec.ts | 17 +++++ tsconfig/contract-strict.baseline.json | 76 +++++++++---------- 5 files changed, 91 insertions(+), 39 deletions(-) diff --git a/packages/openapi-spec/src/libs/emitOpenAPI.ts b/packages/openapi-spec/src/libs/emitOpenAPI.ts index 568bd041d9..bded57c4aa 100644 --- a/packages/openapi-spec/src/libs/emitOpenAPI.ts +++ b/packages/openapi-spec/src/libs/emitOpenAPI.ts @@ -536,7 +536,7 @@ function collectOpenAPICoveredRoutes( "request.path": hasOpenAPIParameters(operation, "path") ? "present" : "absent", "request.query": hasOpenAPIParameters(operation, "query") ? "present" : "absent", "request.headers": hasOpenAPIParameters(operation, "header") ? "present" : "absent", - response: hasOpenAPIJsonSuccessResponse(operation, routesById.get(operation.summary)) + response: hasOpenAPIJsonSuccessResponse(operation, routesById.get(operation["summary"])) ? "present" : "absent", problems: openAPIProblemsFingerprint(operation), diff --git a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts index 054d4393bd..090209c6e1 100644 --- a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts +++ b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts @@ -653,6 +653,25 @@ describe("emitOpenAPI", () => { expect(responses?.[200]).toBeUndefined(); }); + it("should reject a non-success status before it can replace a Problem response", () => { + @Controller("/orders") + class OrdersController { + @Post("/") + createOrder(): void {} + } + + const metadata = Reflect.getMetadata(REST_ROUTES_KEY, OrdersController) as RouteMetadata[]; + const route = metadata[0]; + if (!route) throw new TypeError("Expected order route metadata."); + route.statusCode = 404; + + expect(() => + emitOpenAPI([OrdersController], { + problemResponses: [{ status: 404, description: "Order not found" }], + }), + ).toThrow(/contract-route-invalid-success-status/); + }); + it("should keep a declared bodyless success status and reject a body on 204 or 205", () => { @Controller("/orders") class OrdersController { diff --git a/packages/protocols-core/src/libs/ContractGraph.ts b/packages/protocols-core/src/libs/ContractGraph.ts index 5b8d57b74c..2c6a6a5a3c 100644 --- a/packages/protocols-core/src/libs/ContractGraph.ts +++ b/packages/protocols-core/src/libs/ContractGraph.ts @@ -461,6 +461,22 @@ function validateRoute( } function validateSuccessStatus(route: ContractGraphRoute): ContractDiagnostic[] { + if ( + route.successStatus !== undefined && + (!Number.isInteger(route.successStatus) || + route.successStatus < 200 || + route.successStatus > 299) + ) { + return [ + createRouteDiagnostic( + route, + "contract-route-invalid-success-status", + "error", + `Success status ${route.successStatus} must be an integer between 200 and 299.`, + ), + ]; + } + if ((route.successStatus !== 204 && route.successStatus !== 205) || !route.outputSchema) { return []; } diff --git a/packages/protocols-core/src/tests/ContractGraph.spec.ts b/packages/protocols-core/src/tests/ContractGraph.spec.ts index 0d58548e9b..43b486656b 100644 --- a/packages/protocols-core/src/tests/ContractGraph.spec.ts +++ b/packages/protocols-core/src/tests/ContractGraph.spec.ts @@ -79,6 +79,23 @@ describe("buildContractGraph", () => { ); }); + it.each([199, 300, 404, 200.5, Number.NaN])("rejects invalid success status %s", (status) => { + @Controller("/orders") + class OrdersController { + @Post("/") + createOrder(): void {} + } + + const metadata = Reflect.getMetadata(REST_ROUTES_KEY, OrdersController) as RouteMetadata[]; + const route = metadata[0]; + if (!route) throw new TypeError("Expected order route metadata."); + route.statusCode = status; + + expect(buildContractGraph([OrdersController]).diagnostics).toContainEqual( + expect.objectContaining({ code: "contract-route-invalid-success-status", severity: "error" }), + ); + }); + it("should build stable controller, route id, operation id, and schema graph nodes", () => { const createUserSchema = z.object({ name: z.string() }); diff --git a/tsconfig/contract-strict.baseline.json b/tsconfig/contract-strict.baseline.json index 8045454d07..b6c6db730c 100644 --- a/tsconfig/contract-strict.baseline.json +++ b/tsconfig/contract-strict.baseline.json @@ -363,7 +363,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 259, + "line": 263, "column": 3, "code": "TS2375", "message": "Type '{ request?: { body?: ZodRequestBody; params?: RouteParameter; query?: RouteParameter; cookies?: RouteParameter; headers?: RouteParameter | ZodType[]; } | undefined; ... 6 more ...; responses: { ...; }; }' is not assignable to type 'RouteConfig' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties." @@ -371,7 +371,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 512, + "line": 519, "column": 52, "code": "TS4111", "message": "Property 'summary' comes from an index signature, so it must be accessed with ['summary']." @@ -379,7 +379,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 517, + "line": 524, "column": 26, "code": "TS4111", "message": "Property 'operationId' comes from an index signature, so it must be accessed with ['operationId']." @@ -387,7 +387,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 517, + "line": 524, "column": 63, "code": "TS4111", "message": "Property 'operationId' comes from an index signature, so it must be accessed with ['operationId']." @@ -395,7 +395,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 520, + "line": 527, "column": 28, "code": "TS4111", "message": "Property 'summary' comes from an index signature, so it must be accessed with ['summary']." @@ -403,7 +403,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 524, + "line": 531, "column": 30, "code": "TS4111", "message": "Property 'summary' comes from an index signature, so it must be accessed with ['summary']." @@ -411,7 +411,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 552, + "line": 561, "column": 26, "code": "TS4111", "message": "Property 'operationId' comes from an index signature, so it must be accessed with ['operationId']." @@ -419,7 +419,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 570, + "line": 579, "column": 29, "code": "TS4111", "message": "Property 'requestBody' comes from an index signature, so it must be accessed with ['requestBody']." @@ -427,7 +427,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 577, + "line": 586, "column": 46, "code": "TS4111", "message": "Property 'parameters' comes from an index signature, so it must be accessed with ['parameters']." @@ -435,7 +435,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 577, + "line": 586, "column": 70, "code": "TS4111", "message": "Property 'parameters' comes from an index signature, so it must be accessed with ['parameters']." @@ -443,7 +443,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 581, + "line": 590, "column": 36, "code": "TS4111", "message": "Property 'in' comes from an index signature, so it must be accessed with ['in']." @@ -451,7 +451,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 581, + "line": 590, "column": 72, "code": "TS4111", "message": "Property 'name' comes from an index signature, so it must be accessed with ['name']." @@ -459,7 +459,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 585, + "line": 597, "column": 40, "code": "TS4111", "message": "Property 'responses' comes from an index signature, so it must be accessed with ['responses']." @@ -467,7 +467,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 585, + "line": 597, "column": 63, "code": "TS4111", "message": "Property 'responses' comes from an index signature, so it must be accessed with ['responses']." @@ -475,7 +475,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 588, + "line": 600, "column": 63, "code": "TS4111", "message": "Property 'content' comes from an index signature, so it must be accessed with ['content']." @@ -483,7 +483,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 592, + "line": 604, "column": 39, "code": "TS4111", "message": "Property 'content' comes from an index signature, so it must be accessed with ['content']." @@ -491,7 +491,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 594, + "line": 606, "column": 56, "code": "TS4111", "message": "Property 'schema' comes from an index signature, so it must be accessed with ['schema']." @@ -499,7 +499,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 598, + "line": 610, "column": 40, "code": "TS4111", "message": "Property 'responses' comes from an index signature, so it must be accessed with ['responses']." @@ -507,7 +507,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 598, + "line": 610, "column": 63, "code": "TS4111", "message": "Property 'responses' comes from an index signature, so it must be accessed with ['responses']." @@ -515,7 +515,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 620, + "line": 632, "column": 26, "code": "TS4111", "message": "Property 'code' comes from an index signature, so it must be accessed with ['code']." @@ -523,7 +523,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 621, + "line": 633, "column": 30, "code": "TS4111", "message": "Property 'category' comes from an index signature, so it must be accessed with ['category']." @@ -531,7 +531,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 622, + "line": 634, "column": 28, "code": "TS4111", "message": "Property 'status' comes from an index signature, so it must be accessed with ['status']." @@ -539,7 +539,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 622, + "line": 634, "column": 58, "code": "TS4111", "message": "Property 'status' comes from an index signature, so it must be accessed with ['status']." @@ -547,7 +547,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 622, + "line": 634, "column": 82, "code": "TS4111", "message": "Property 'status' comes from an index signature, so it must be accessed with ['status']." @@ -555,7 +555,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 623, + "line": 635, "column": 24, "code": "TS4111", "message": "Property 'description' comes from an index signature, so it must be accessed with ['description']." @@ -563,7 +563,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 623, + "line": 635, "column": 74, "code": "TS4111", "message": "Property 'description' comes from an index signature, so it must be accessed with ['description']." @@ -571,7 +571,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 624, + "line": 636, "column": 24, "code": "TS4111", "message": "Property 'type' comes from an index signature, so it must be accessed with ['type']." @@ -579,7 +579,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 624, + "line": 636, "column": 60, "code": "TS4111", "message": "Property 'type' comes from an index signature, so it must be accessed with ['type']." @@ -587,7 +587,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 643, + "line": 655, "column": 33, "code": "TS4111", "message": "Property 'feature' comes from an index signature, so it must be accessed with ['feature']." @@ -595,7 +595,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 644, + "line": 656, "column": 28, "code": "TS4111", "message": "Property 'description' comes from an index signature, so it must be accessed with ['description']." @@ -603,7 +603,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 645, + "line": 657, "column": 36, "code": "TS4111", "message": "Property 'description' comes from an index signature, so it must be accessed with ['description']." @@ -611,7 +611,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 647, + "line": 659, "column": 30, "code": "TS4111", "message": "Property 'resource' comes from an index signature, so it must be accessed with ['resource']." @@ -619,7 +619,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 648, + "line": 660, "column": 73, "code": "TS4111", "message": "Property 'resource' comes from an index signature, so it must be accessed with ['resource']." @@ -627,7 +627,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 657, + "line": 669, "column": 27, "code": "TS4111", "message": "Property 'type' comes from an index signature, so it must be accessed with ['type']." @@ -635,7 +635,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 658, + "line": 670, "column": 25, "code": "TS4111", "message": "Property 'id' comes from an index signature, so it must be accessed with ['id']." @@ -643,7 +643,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 658, + "line": 670, "column": 58, "code": "TS4111", "message": "Property 'id' comes from an index signature, so it must be accessed with ['id']." @@ -651,7 +651,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 659, + "line": 671, "column": 25, "code": "TS4111", "message": "Property 'idParam' comes from an index signature, so it must be accessed with ['idParam']." @@ -659,7 +659,7 @@ { "packageName": "@croco/openapi-spec", "file": "packages/openapi-spec/src/libs/emitOpenAPI.ts", - "line": 659, + "line": 671, "column": 68, "code": "TS4111", "message": "Property 'idParam' comes from an index signature, so it must be accessed with ['idParam']."