diff --git a/.changeset/contract-first-rest-routes.md b/.changeset/contract-first-rest-routes.md new file mode 100644 index 000000000..b81314b82 --- /dev/null +++ b/.changeset/contract-first-rest-routes.md @@ -0,0 +1,7 @@ +--- +"@croco/protocols-rest": patch +"@croco/protocols-core": patch +"create-croco-app": patch +--- + +REST route contracts can now drive controller decorators directly through contract-aware HTTP method, parameter, body, and response helpers. Contract graphs preserve route contract identity/source locations and report drift when controller bindings or response metadata diverge from the contract. The SPA split starter template now uses contract-first REST routes for its generated OpenAPI/RPC contract path. diff --git a/packages/cli/src/tests/contractsCheck.spec.ts b/packages/cli/src/tests/contractsCheck.spec.ts index 3abe53d29..303f9259f 100644 --- a/packages/cli/src/tests/contractsCheck.spec.ts +++ b/packages/cli/src/tests/contractsCheck.spec.ts @@ -128,6 +128,7 @@ function createGraph(diagnostics: ContractDiagnostic[] = []): ContractGraph { inputSchema: null, inputSchemas: { body: null, path: null, query: null, headers: null }, outputSchema: null, + routeContract: null, domain: null, }, ], diff --git a/packages/cli/src/tests/contractsDiff.spec.ts b/packages/cli/src/tests/contractsDiff.spec.ts index ff8141f37..16b37e8f4 100644 --- a/packages/cli/src/tests/contractsDiff.spec.ts +++ b/packages/cli/src/tests/contractsDiff.spec.ts @@ -161,6 +161,7 @@ function createGraph( inputSchema: null, inputSchemas: { body: null, path: null, query: null, headers: null }, outputSchema: null, + routeContract: null, domain: null, }; }); diff --git a/packages/cli/src/tests/projectMap.spec.ts b/packages/cli/src/tests/projectMap.spec.ts index be70cc1ed..482bd4c33 100644 --- a/packages/cli/src/tests/projectMap.spec.ts +++ b/packages/cli/src/tests/projectMap.spec.ts @@ -1,15 +1,13 @@ +import { describe, expect, it } from "vitest"; import type { PolicyTable, RuntimeCapabilityName } from "@croco/framework-context"; import type { FrameworkManifest } from "@croco/framework-routes"; import type { ContractDiagnostic, ContractGraphSnapshot } from "@croco/protocols-core"; -import { describe, expect, it } from "vitest"; import { createProjectMapManifest, runProjectMap, stringifyProjectMapManifest, - type ProjectMapDirent, - type ProjectMapIo, - type ProjectMapPackage, } from "../commands/projectMap.js"; +import type { ProjectMapDirent, ProjectMapIo, ProjectMapPackage } from "../commands/projectMap.js"; describe("projectMap", () => { it("writes a deterministic Project Map manifest snapshot", async () => { @@ -418,7 +416,9 @@ function createContractSnapshot(diagnostics: ContractDiagnostic[] = []): Contrac path: "/users", controllerPath: "/users", domain: "users", + routeContract: null, access: { guards: [], roles: [] }, + entitlements: [], params: [], request: { body: null, path: null, query: null, headers: null }, response: null, diff --git a/packages/create-croco-app/src/tests/templates-build.spec.ts b/packages/create-croco-app/src/tests/templates-build.spec.ts index 7e84bc021..e30b21808 100644 --- a/packages/create-croco-app/src/tests/templates-build.spec.ts +++ b/packages/create-croco-app/src/tests/templates-build.spec.ts @@ -221,12 +221,22 @@ function checkSpaBeSplitStructure() { checkFileContains( "spa-be-split", ["apps", "api-server", "src", "controllers", "UserController.ts"], - /@ResponseSchema/, + /@Get\(getUserRoute\)/, ); checkFileContains( "spa-be-split", ["apps", "api-server", "src", "controllers", "UserController.ts"], - /@Body\(createUserInputSchema\)/, + /@Body\(createUserRoute\)/, + ); + checkFileContains( + "spa-be-split", + ["apps", "api-server", "src", "controllers", "UserController.ts"], + /Promise>/, + ); + checkFileContains( + "spa-be-split", + ["apps", "api-server", "src", "controllers", "userSchemas.ts"], + /defineRouteContract/, ); checkFileExists("spa-be-split", "pnpm-workspace.yaml"); checkFileContains("spa-be-split", ["README.md.hbs"], /운영형 앱 스타터/); diff --git a/packages/create-croco-app/templates/spa-be-split/README.md.hbs b/packages/create-croco-app/templates/spa-be-split/README.md.hbs index 6369bf326..36f3806ab 100644 --- a/packages/create-croco-app/templates/spa-be-split/README.md.hbs +++ b/packages/create-croco-app/templates/spa-be-split/README.md.hbs @@ -54,7 +54,7 @@ Artifact 책임은 다음과 같습니다. `contract:openapi`는 `apps/api-server/src/{controllers/**/*.ts,users.ts,problems.ts}`에서 REST 컨트롤러 메타데이터를 읽어 `openapi.json`을 생성합니다. `contract:client`는 같은 컨트롤러 계약에서 React Query hook을 포함한 fetch 클라이언트를 `libs/shared/provider-rpc/src`에 생성합니다. `codegen`은 기존 사용자를 위한 `contract:client` 별칭입니다. -`contract:client`는 생성 직후 `{{scope}}/provider-rpc`를 typecheck합니다. 예를 들어 `UserController`의 `@Get("/:id")`, `@Param("id", userIdSchema)`, `@Body(createUserInputSchema)`, `@ResponseSchema(userSchema)` 계약은 다음처럼 `userClient` 타입으로 이어집니다. +`contract:client`는 생성 직후 `{{scope}}/provider-rpc`를 typecheck합니다. 예를 들어 `UserController`의 `@Get(getUserRoute)`, `@Param(getUserRoute, "id")`, `@Post(createUserRoute)`, `@Body(createUserRoute)` 계약은 다음처럼 `userClient` 타입으로 이어집니다. ```ts import { userClient, RpcClientProblemError } from "{{scope}}/provider-rpc"; @@ -77,7 +77,7 @@ function readProblemCode(error: unknown): string | null { } ``` -성공 응답 타입은 서버의 `@ResponseSchema`에서 생성되고, RFC 7807 Problem 응답은 성공 값으로 섞이지 않고 `RpcClientProblemError`로 보존됩니다. `rpc-codegen`이 JSON-safe TypeScript 타입으로 표현할 수 없는 Zod schema를 만나면 `unknown` fallback으로 숨기지 않고 생성 단계에서 실패합니다. +성공 응답 타입은 서버의 `RouteContract.response`에서 생성되고, RFC 7807 Problem 응답은 성공 값으로 섞이지 않고 `RpcClientProblemError`로 보존됩니다. `rpc-codegen`이 JSON-safe TypeScript 타입으로 표현할 수 없는 Zod schema를 만나면 `unknown` fallback으로 숨기지 않고 생성 단계에서 실패합니다. 도메인별 생성 타입과 React Query hook은 package root의 namespace export(예: `userRpc.useGetById`)로도 사용할 수 있습니다. ## 구조 diff --git a/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/controllers/UserController.ts b/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/controllers/UserController.ts index 43709716d..18df52602 100644 --- a/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/controllers/UserController.ts +++ b/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/controllers/UserController.ts @@ -6,51 +6,52 @@ import { Param, Post, Put, - ResponseSchema, + type RouteBody, + type RouteParam, + type RouteResponse, } from "@croco/protocols-rest"; -import { z } from "zod"; import { - createUserInputSchema, - deletedResponseSchema, - type CreateUserInput, - type User, - userIdSchema, - userSchema, + createUserRoute, + deleteUserRoute, + getUserRoute, + listUsersRoute, + updateUserRoute, } from "./userSchemas"; import { getUserService } from "../users"; @Controller("/users") export class UserController { - @Get() - @ResponseSchema(z.array(userSchema)) - async list(): Promise> { - return await getUserService().list(); + @Get(listUsersRoute) + async list(): Promise> { + return [...(await getUserService().list())]; } - @Get("/:id") - @ResponseSchema(userSchema) - async getById(@Param("id", userIdSchema) id: string): Promise { + @Get(getUserRoute) + async getById( + @Param(getUserRoute, "id") id: RouteParam, + ): Promise> { return await getUserService().getById(id); } - @Post() - @ResponseSchema(userSchema) - async create(@Body(createUserInputSchema) input: CreateUserInput): Promise { + @Post(createUserRoute) + async create( + @Body(createUserRoute) input: RouteBody, + ): Promise> { return await getUserService().create(input); } - @Put("/:id") - @ResponseSchema(userSchema) + @Put(updateUserRoute) async update( - @Param("id", userIdSchema) id: string, - @Body(createUserInputSchema) input: CreateUserInput, - ): Promise { + @Param(updateUserRoute, "id") id: RouteParam, + @Body(updateUserRoute) input: RouteBody, + ): Promise> { return await getUserService().update(id, input); } - @Delete("/:id") - @ResponseSchema(deletedResponseSchema) - async delete(@Param("id", userIdSchema) id: string): Promise<{ deleted: boolean }> { + @Delete(deleteUserRoute) + async delete( + @Param(deleteUserRoute, "id") id: RouteParam, + ): Promise> { return await getUserService().delete(id); } } diff --git a/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/controllers/userSchemas.ts b/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/controllers/userSchemas.ts index 0bbc55884..87356ee38 100644 --- a/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/controllers/userSchemas.ts +++ b/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/controllers/userSchemas.ts @@ -1,3 +1,4 @@ +import { defineRouteContract, HttpMethod } from "@croco/protocols-rest"; import { z } from "zod"; export const userIdSchema = z.string(); @@ -14,5 +15,50 @@ export const deletedResponseSchema = z.object({ deleted: z.boolean(), }); +export const listUsersRoute = defineRouteContract({ + id: "users.list", + method: HttpMethod.GET, + path: "/users", + operationId: "listUsers", + response: z.array(userSchema), +}); + +export const getUserRoute = defineRouteContract({ + id: "users.get", + method: HttpMethod.GET, + path: "/users/:id", + operationId: "getUserById", + params: z.object({ id: userIdSchema }), + response: userSchema, +}); + +export const createUserRoute = defineRouteContract({ + id: "users.create", + method: HttpMethod.POST, + path: "/users", + operationId: "createUser", + body: createUserInputSchema, + response: userSchema, +}); + +export const updateUserRoute = defineRouteContract({ + id: "users.update", + method: HttpMethod.PUT, + path: "/users/:id", + operationId: "updateUser", + params: z.object({ id: userIdSchema }), + body: createUserInputSchema, + response: userSchema, +}); + +export const deleteUserRoute = defineRouteContract({ + id: "users.delete", + method: HttpMethod.DELETE, + path: "/users/:id", + operationId: "deleteUser", + params: z.object({ id: userIdSchema }), + response: deletedResponseSchema, +}); + export type User = z.infer; export type CreateUserInput = z.infer; diff --git a/packages/docs/scripts/sanitize-typedoc-index.mjs b/packages/docs/scripts/sanitize-typedoc-index.mjs index 12525e486..623cc477f 100644 --- a/packages/docs/scripts/sanitize-typedoc-index.mjs +++ b/packages/docs/scripts/sanitize-typedoc-index.mjs @@ -2,6 +2,8 @@ import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; const apiIndexPath = fileURLToPath(new URL("../src/content/docs/api/README.md", import.meta.url)); +const apiDocsPath = (relativePath) => + fileURLToPath(new URL(`../src/content/docs/api/${relativePath}`, import.meta.url)); const policyExecutionPlanPath = fileURLToPath( new URL( "../src/content/docs/api/framework-context/src/functions/getPolicyExecutionPlan.md", @@ -12,10 +14,161 @@ const replacement = "API modules are available from the **API Reference** sideba const policyExecutionPlanLink = "[`PolicyExecutionPlan`](/api/framework-context/src/type-aliases/policyexecutionplan/)"; const policyExecutionPlanResult = `${policyExecutionPlanLink} \\| \`undefined\``; +const routeContractSpecLink = + "[`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)"; +const routePathParamNameLink = + "[`RoutePathParamName`](/api/protocols-rest/src/type-aliases/routepathparamname/)"; +const routePathParamsLink = + "[`RoutePathParams`](/api/protocols-rest/src/type-aliases/routepathparams/)"; +const routeQueryLink = "[`RouteQuery`](/api/protocols-rest/src/type-aliases/routequery/)"; +const paramsNameConstraint = `${routePathParamNameLink}\\<\`TContract\`\\[\`"path"\`\\]\\> & keyof ${routePathParamsLink}\\<\`TContract\`\\> & \`string\``; +const queryNameConstraint = `keyof ${routeQueryLink}\\<\`TContract\`\\> & \`string\``; +const routeContractDocs = [ + { + path: apiDocsPath("protocols-rest/src/functions/Body.md"), + replacements: [ + [ + "## Call Signature\n\n> **Body**\\<`TContract`\\>(`contract`): `ParameterDecorator`", + "## Contract Overload\n\n> **Body**\\<`TContract`\\>(`contract`): `ParameterDecorator`", + ], + [ + "`TContract` *extends* [`RouteContractWithBody`](/api/protocols-rest/src/type-aliases/routecontractwithbody/)", + `\`TContract\` *extends* ${routeContractSpecLink} & \`{ readonly body: z.ZodType }\``, + ], + [ + "## Call Signature\n\n> **Body**(`schema?`): `ParameterDecorator`", + "## Schema Overload\n\n> **Body**(`schema?`): `ParameterDecorator`", + ], + ], + }, + { + path: apiDocsPath("protocols-rest/src/functions/Param.md"), + replacements: [ + [ + "## Call Signature\n\n> **Param**\\<`TContract`, `Name`\\>(`contract`, `name`): `ParameterDecorator`", + "## Contract Overload\n\n> **Param**\\<`TContract`, `Name`\\>(`contract`, `name`): `ParameterDecorator`", + ], + [ + "`TContract` *extends* [`RouteContractWithParams`](/api/protocols-rest/src/type-aliases/routecontractwithparams/)", + `\`TContract\` *extends* ${routeContractSpecLink} & \`{ readonly params: AnyZodObject }\``, + ], + ["`Name` *extends* `string`", `\`Name\` *extends* ${paramsNameConstraint}`], + [ + "## Call Signature\n\n> **Param**(`name`, `schema?`): `ParameterDecorator`", + "## Schema Overload\n\n> **Param**(`name`, `schema?`): `ParameterDecorator`", + ], + ], + }, + { + path: apiDocsPath("protocols-rest/src/functions/Query.md"), + replacements: [ + [ + "## Call Signature\n\n> **Query**\\<`TContract`, `Name`\\>(`contract`, `name`): `ParameterDecorator`", + "## Contract Overload\n\n> **Query**\\<`TContract`, `Name`\\>(`contract`, `name`): `ParameterDecorator`", + ], + [ + "`TContract` *extends* [`RouteContractWithQuery`](/api/protocols-rest/src/type-aliases/routecontractwithquery/)", + `\`TContract\` *extends* ${routeContractSpecLink} & \`{ readonly query: AnyZodObject }\``, + ], + ["`Name` *extends* `string`", `\`Name\` *extends* ${queryNameConstraint}`], + [ + "## Call Signature\n\n> **Query**(`name`, `schema?`): `ParameterDecorator`", + "## Schema Overload\n\n> **Query**(`name`, `schema?`): `ParameterDecorator`", + ], + ], + }, + { + path: apiDocsPath("protocols-rest/src/functions/ResponseSchema.md"), + replacements: [ + [ + "## Call Signature\n\n> **ResponseSchema**\\<`TContract`\\>(`contract`): `MethodDecorator`\n", + "## Contract Overload\n\n> **ResponseSchema**\\<`TContract`\\>(`contract`): `MethodDecorator`\n\n응답 스키마를 메서드에 바인딩합니다.\n", + ], + [ + "`TContract` *extends* [`RouteContractWithResponse`](/api/protocols-rest/src/type-aliases/routecontractwithresponse/)", + `\`TContract\` *extends* ${routeContractSpecLink} & \`{ readonly response: z.ZodType }\``, + ], + [ + "## Call Signature\n\n> **ResponseSchema**(`schema`): `MethodDecorator`\n", + "## Schema Overload\n\n> **ResponseSchema**(`schema`): `MethodDecorator`\n\n응답 스키마를 메서드에 바인딩합니다.\n", + ], + ], + }, + { + path: apiDocsPath("protocols-rest/src/functions/routeParamSchema.md"), + replacements: [ + [ + "`TContract` *extends* [`RouteContractWithParams`](/api/protocols-rest/src/type-aliases/routecontractwithparams/)", + `\`TContract\` *extends* ${routeContractSpecLink} & \`{ readonly params: AnyZodObject }\``, + ], + ["`Name` *extends* `string`", `\`Name\` *extends* ${paramsNameConstraint}`], + ], + }, + { + path: apiDocsPath("protocols-rest/src/functions/routeQueryParamSchema.md"), + replacements: [ + [ + "`TContract` *extends* [`RouteContractWithQuery`](/api/protocols-rest/src/type-aliases/routecontractwithquery/)", + `\`TContract\` *extends* ${routeContractSpecLink} & \`{ readonly query: AnyZodObject }\``, + ], + ["`Name` *extends* `string`", `\`Name\` *extends* ${queryNameConstraint}`], + ], + }, + { + path: apiDocsPath("protocols-rest/src/functions/isRouteContractSpec.md"), + replacements: [ + [ + "> **isRouteContractSpec**(`value`): `value is AnyRouteContractSpec`\n", + "> **isRouteContractSpec**(`value`): `value is RouteContractSpec`\n\nRoute contract decorator overloads use this guard to distinguish contract objects from direct schema arguments at runtime.\n", + ], + [ + "## Returns\n\n`value is AnyRouteContractSpec`\n", + "## Returns\n\n`value is RouteContractSpec`\n\n## Example\n\n```ts\nif (isRouteContractSpec(value)) {\n value.method;\n value.path;\n}\n```\n", + ], + ], + }, + { + path: apiDocsPath("protocols-rest/src/type-aliases/RouteContractWithBody.md"), + replacements: [ + [ + `> **RouteContractWithBody** = ${routeContractSpecLink} & \`object\``, + `> **RouteContractWithBody** = ${routeContractSpecLink} & \`{ readonly body: z.ZodType }\``, + ], + ], + }, + { + path: apiDocsPath("protocols-rest/src/type-aliases/RouteContractWithParams.md"), + replacements: [ + [ + `> **RouteContractWithParams** = ${routeContractSpecLink} & \`object\``, + `> **RouteContractWithParams** = ${routeContractSpecLink} & \`{ readonly params: AnyZodObject }\``, + ], + ], + }, + { + path: apiDocsPath("protocols-rest/src/type-aliases/RouteContractWithQuery.md"), + replacements: [ + [ + `> **RouteContractWithQuery** = ${routeContractSpecLink} & \`object\``, + `> **RouteContractWithQuery** = ${routeContractSpecLink} & \`{ readonly query: AnyZodObject }\``, + ], + ], + }, + { + path: apiDocsPath("protocols-rest/src/type-aliases/RouteContractWithResponse.md"), + replacements: [ + [ + `> **RouteContractWithResponse** = ${routeContractSpecLink} & \`object\``, + `> **RouteContractWithResponse** = ${routeContractSpecLink} & \`{ readonly response: z.ZodType }\``, + ], + ], + }, +]; export async function sanitizeTypeDocIndex() { await sanitizeApiIndex(); await sanitizePolicyExecutionPlan(); + await sanitizeRestRouteContractDocs(); } async function sanitizeApiIndex() { @@ -50,6 +203,24 @@ async function sanitizePolicyExecutionPlan() { } } +async function sanitizeRestRouteContractDocs() { + await Promise.all( + routeContractDocs.map(({ path, replacements }) => sanitizeMarkdown(path, replacements)), + ); +} + +async function sanitizeMarkdown(path, replacements) { + const content = await readFile(path, "utf8"); + const sanitized = replacements.reduce( + (current, [search, replacement]) => current.replaceAll(search, replacement), + content, + ); + + if (sanitized !== content) { + await writeFile(path, sanitized.endsWith("\n") ? sanitized : `${sanitized}\n`); + } +} + if (import.meta.url === `file://${process.argv[1]}`) { await sanitizeTypeDocIndex(); } 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 d0a6088a0..30b3a0b93 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 @@ -64,3 +64,9 @@ title: "RouteIR" ### problemResponses? > `optional` **problemResponses**: readonly [`ProblemResponseIR`](/api/protocols-core/src/type-aliases/problemresponseir/)\<`string`, [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/), `number`\>[] + +*** + +### routeContract + +> **routeContract**: [`RouteContractIR`](/api/protocols-core/src/type-aliases/routecontractir/) \| `null` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractDiagnostic.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractDiagnostic.md index c590e23a4..0b225fbc0 100644 --- a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractDiagnostic.md +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractDiagnostic.md @@ -15,6 +15,12 @@ title: "ContractDiagnostic" *** +### contractId? + +> `readonly` `optional` **contractId**: `string` + +*** + ### controllerName? > `readonly` `optional` **controllerName**: `string` @@ -51,6 +57,12 @@ title: "ContractDiagnostic" *** +### sourceLocation? + +> `readonly` `optional` **sourceLocation**: [`ContractDiagnosticSourceLocation`](/api/protocols-core/src/type-aliases/contractdiagnosticsourcelocation/) + +*** + ### target > `readonly` **target**: [`ContractDiagnosticTarget`](/api/protocols-core/src/type-aliases/contractdiagnostictarget/) diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractDiagnosticSourceLocation.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractDiagnosticSourceLocation.md new file mode 100644 index 000000000..aace14fe3 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractDiagnosticSourceLocation.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "ContractDiagnosticSourceLocation" +--- + +> **ContractDiagnosticSourceLocation** = `object` + +## Properties + +### column? + +> `readonly` `optional` **column**: `number` + +*** + +### line? + +> `readonly` `optional` **line**: `number` + +*** + +### path + +> `readonly` **path**: `string` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractEntitlementRequirement.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractEntitlementRequirement.md new file mode 100644 index 000000000..0f91b4ee7 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractEntitlementRequirement.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "ContractEntitlementRequirement" +--- + +> **ContractEntitlementRequirement** = `object` + +## Properties + +### description? + +> `readonly` `optional` **description**: `string` + +*** + +### feature + +> `readonly` **feature**: `string` + +*** + +### resource? + +> `readonly` `optional` **resource**: [`ContractEntitlementResourceRequirement`](/api/protocols-core/src/type-aliases/contractentitlementresourcerequirement/) diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractEntitlementResourceRequirement.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractEntitlementResourceRequirement.md new file mode 100644 index 000000000..1c038b8d5 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractEntitlementResourceRequirement.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "ContractEntitlementResourceRequirement" +--- + +> **ContractEntitlementResourceRequirement** = `object` + +## Properties + +### id? + +> `readonly` `optional` **id**: `string` + +*** + +### idParam? + +> `readonly` `optional` **idParam**: `string` + +*** + +### type + +> `readonly` **type**: `string` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphConsumerRouteField.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphConsumerRouteField.md index cc4b10791..e77842513 100644 --- a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphConsumerRouteField.md +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphConsumerRouteField.md @@ -5,4 +5,4 @@ prev: false title: "ContractGraphConsumerRouteField" --- -> **ContractGraphConsumerRouteField** = `"routeId"` \| `"operationId"` \| `"httpMethod"` \| `"path"` \| `"request.body"` \| `"request.path"` \| `"request.query"` \| `"request.headers"` \| `"response"` \| `"problems"` \| `"access.guards"` \| `"access.roles"` +> **ContractGraphConsumerRouteField** = `"routeId"` \| `"operationId"` \| `"httpMethod"` \| `"path"` \| `"request.body"` \| `"request.path"` \| `"request.query"` \| `"request.headers"` \| `"response"` \| `"problems"` \| `"entitlements"` \| `"access.guards"` \| `"access.roles"` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphRoute.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphRoute.md index 08c7cc05b..ad362e769 100644 --- a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphRoute.md +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphRoute.md @@ -17,6 +17,10 @@ title: "ContractGraphRoute" > `readonly` **controllerPath**: `string` +### entitlements + +> `readonly` **entitlements**: readonly [`ContractEntitlementRequirement`](/api/protocols-core/src/type-aliases/contractentitlementrequirement/)[] + ### operationId > `readonly` **operationId**: `string` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotEntitlementRequirement.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotEntitlementRequirement.md new file mode 100644 index 000000000..326c07888 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotEntitlementRequirement.md @@ -0,0 +1,8 @@ +--- +editUrl: false +next: false +prev: false +title: "ContractGraphSnapshotEntitlementRequirement" +--- + +> **ContractGraphSnapshotEntitlementRequirement** = [`ContractEntitlementRequirement`](/api/protocols-core/src/type-aliases/contractentitlementrequirement/) diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotRoute.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotRoute.md index f283e1e34..3eb3856c0 100644 --- a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotRoute.md +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotRoute.md @@ -33,6 +33,12 @@ title: "ContractGraphSnapshotRoute" *** +### entitlements + +> `readonly` **entitlements**: readonly [`ContractGraphSnapshotEntitlementRequirement`](/api/protocols-core/src/type-aliases/contractgraphsnapshotentitlementrequirement/)[] + +*** + ### httpMethod > `readonly` **httpMethod**: `string` @@ -97,6 +103,12 @@ title: "ContractGraphSnapshotRoute" *** +### routeContract + +> `readonly` **routeContract**: [`ContractGraphSnapshotRouteContract`](/api/protocols-core/src/type-aliases/contractgraphsnapshotroutecontract/) \| `null` + +*** + ### routeId > `readonly` **routeId**: `string` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotRouteContract.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotRouteContract.md new file mode 100644 index 000000000..1d12b770b --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/ContractGraphSnapshotRouteContract.md @@ -0,0 +1,50 @@ +--- +editUrl: false +next: false +prev: false +title: "ContractGraphSnapshotRouteContract" +--- + +> **ContractGraphSnapshotRouteContract** = `object` + +## Properties + +### id + +> `readonly` **id**: `string` \| `null` + +*** + +### method + +> `readonly` **method**: `string` + +*** + +### operationId? + +> `readonly` `optional` **operationId**: `string` + +*** + +### path + +> `readonly` **path**: `string` + +*** + +### sourceLocation? + +> `readonly` `optional` **sourceLocation**: `object` + +#### column? + +> `readonly` `optional` **column**: `number` + +#### line? + +> `readonly` `optional` **line**: `number` + +#### path + +> `readonly` **path**: `string` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/EntitlementRequirementMetadata.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/EntitlementRequirementMetadata.md new file mode 100644 index 000000000..8544b378a --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/EntitlementRequirementMetadata.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "EntitlementRequirementMetadata" +--- + +> **EntitlementRequirementMetadata** = `object` + +## Properties + +### description? + +> `readonly` `optional` **description**: `string` + +*** + +### feature + +> `readonly` **feature**: `string` + +*** + +### resource? + +> `readonly` `optional` **resource**: [`EntitlementResourceRequirementMetadata`](/api/protocols-core/src/type-aliases/entitlementresourcerequirementmetadata/) diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/EntitlementResourceRequirementMetadata.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/EntitlementResourceRequirementMetadata.md new file mode 100644 index 000000000..0380b1575 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/EntitlementResourceRequirementMetadata.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "EntitlementResourceRequirementMetadata" +--- + +> **EntitlementResourceRequirementMetadata** = `object` + +## Properties + +### id? + +> `readonly` `optional` **id**: `string` + +*** + +### idParam? + +> `readonly` `optional` **idParam**: `string` + +*** + +### type + +> `readonly` **type**: `string` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/RouteContractIR.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/RouteContractIR.md new file mode 100644 index 000000000..3509c093a --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/RouteContractIR.md @@ -0,0 +1,56 @@ +--- +editUrl: false +next: false +prev: false +title: "RouteContractIR" +--- + +> **RouteContractIR** = `object` + +## Properties + +### id + +> `readonly` **id**: `string` \| `null` + +*** + +### inputSchemas + +> `readonly` **inputSchemas**: `RouteInputSchemas` + +*** + +### method + +> `readonly` **method**: `string` + +*** + +### operationId? + +> `readonly` `optional` **operationId**: `string` + +*** + +### outputSchema + +> `readonly` **outputSchema**: `z.ZodType` \| `null` + +*** + +### path + +> `readonly` **path**: `string` + +*** + +### problemResponses + +> `readonly` **problemResponses**: readonly [`ProblemResponseIR`](/api/protocols-core/src/type-aliases/problemresponseir/)[] + +*** + +### sourceLocation? + +> `readonly` `optional` **sourceLocation**: [`RouteContractSourceLocation`](/api/protocols-core/src/type-aliases/routecontractsourcelocation/) diff --git a/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/RouteContractSourceLocation.md b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/RouteContractSourceLocation.md new file mode 100644 index 000000000..d53bfb224 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-core/src/type-aliases/RouteContractSourceLocation.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "RouteContractSourceLocation" +--- + +> **RouteContractSourceLocation** = `object` + +## Properties + +### column? + +> `readonly` `optional` **column**: `number` + +*** + +### line? + +> `readonly` `optional` **line**: `number` + +*** + +### path + +> `readonly` **path**: `string` diff --git a/packages/docs/src/content/docs/api/protocols-core/src/variables/DEFAULT_CONTRACT_GRAPH_CONSUMERS.md b/packages/docs/src/content/docs/api/protocols-core/src/variables/DEFAULT_CONTRACT_GRAPH_CONSUMERS.md index 432cb2700..382291bba 100644 --- a/packages/docs/src/content/docs/api/protocols-core/src/variables/DEFAULT_CONTRACT_GRAPH_CONSUMERS.md +++ b/packages/docs/src/content/docs/api/protocols-core/src/variables/DEFAULT_CONTRACT_GRAPH_CONSUMERS.md @@ -5,4 +5,4 @@ prev: false title: "DEFAULT_CONTRACT_GRAPH_CONSUMERS" --- -> `const` **DEFAULT\_CONTRACT\_GRAPH\_CONSUMERS**: readonly \[\{ `generatedArtifact`: `"admin resource config files"`; `id`: `"admin-generated"`; `label`: `"Admin resource config"`; `requiredRouteFields`: readonly \[`"routeId"`, `"operationId"`, `"httpMethod"`, `"path"`, `"request.body"`, `"request.path"`, `"request.query"`, `"request.headers"`, `"response"`, `"problems"`, `"access.guards"`, `"access.roles"`\]; `unsupportedRouteFields`: readonly \[\]; \}, \{ `generatedArtifact`: `"openapi.json"`; `id`: `"openapi"`; `label`: `"OpenAPI specification"`; `requiredRouteFields`: readonly \[`"routeId"`, `"operationId"`, `"httpMethod"`, `"path"`, `"request.body"`, `"request.path"`, `"request.query"`, `"request.headers"`, `"response"`, `"problems"`\]; `unsupportedRouteFields`: readonly \[`"access.guards"`, `"access.roles"`\]; \}, \{ `generatedArtifact`: `"provider-rpc client files"`; `id`: `"rpc-client"`; `label`: `"RPC fetch client"`; `requiredRouteFields`: readonly \[`"routeId"`, `"operationId"`, `"httpMethod"`, `"path"`, `"request.body"`, `"request.path"`, `"request.query"`, `"request.headers"`, `"response"`, `"problems"`\]; `unsupportedRouteFields`: readonly \[`"access.guards"`, `"access.roles"`\]; \}\] +> `const` **DEFAULT\_CONTRACT\_GRAPH\_CONSUMERS**: readonly \[\{ `generatedArtifact`: `"admin resource config files"`; `id`: `"admin-generated"`; `label`: `"Admin resource config"`; `requiredRouteFields`: readonly \[`"routeId"`, `"operationId"`, `"httpMethod"`, `"path"`, `"request.body"`, `"request.path"`, `"request.query"`, `"request.headers"`, `"response"`, `"problems"`, `"access.guards"`, `"access.roles"`\]; `unsupportedRouteFields`: readonly \[\]; \}, \{ `generatedArtifact`: `"openapi.json"`; `id`: `"openapi"`; `label`: `"OpenAPI specification"`; `requiredRouteFields`: readonly \[`"routeId"`, `"operationId"`, `"httpMethod"`, `"path"`, `"request.body"`, `"request.path"`, `"request.query"`, `"request.headers"`, `"response"`, `"problems"`, `"entitlements"`\]; `unsupportedRouteFields`: readonly \[`"access.guards"`, `"access.roles"`\]; \}, \{ `generatedArtifact`: `"provider-rpc client files"`; `id`: `"rpc-client"`; `label`: `"RPC fetch client"`; `requiredRouteFields`: readonly \[`"routeId"`, `"operationId"`, `"httpMethod"`, `"path"`, `"request.body"`, `"request.path"`, `"request.query"`, `"request.headers"`, `"response"`, `"problems"`\]; `unsupportedRouteFields`: readonly \[`"access.guards"`, `"access.roles"`, `"entitlements"`\]; \}\] diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/Body.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/Body.md index 6c3e36ac4..bac602066 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/Body.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/Body.md @@ -5,16 +5,40 @@ prev: false title: "Body" --- +## Contract Overload + +> **Body**\<`TContract`\>(`contract`): `ParameterDecorator` + +요청 본문 전체를 메서드 인자에 바인딩합니다. + +### Type Parameters + +#### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly body: z.ZodType }` + +### Parameters + +#### contract + +`TContract` + +### Returns + +`ParameterDecorator` + +## Schema Overload + > **Body**(`schema?`): `ParameterDecorator` 요청 본문 전체를 메서드 인자에 바인딩합니다. -## Parameters +### Parameters -### schema? +#### schema? `ZodType`\<`any`, `ZodTypeDef`, `any`\> -## Returns +### Returns `ParameterDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/Param.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/Param.md index 9c0619145..c8c7ddaab 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/Param.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/Param.md @@ -5,20 +5,52 @@ prev: false title: "Param" --- +## Contract Overload + +> **Param**\<`TContract`, `Name`\>(`contract`, `name`): `ParameterDecorator` + +경로 파라미터를 메서드 인자에 바인딩합니다. + +### Type Parameters + +#### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly params: AnyZodObject }` + +#### Name + +`Name` *extends* [`RoutePathParamName`](/api/protocols-rest/src/type-aliases/routepathparamname/)\<`TContract`\[`"path"`\]\> & keyof [`RoutePathParams`](/api/protocols-rest/src/type-aliases/routepathparams/)\<`TContract`\> & `string` + +### Parameters + +#### contract + +`TContract` + +#### name + +`Name` + +### Returns + +`ParameterDecorator` + +## Schema Overload + > **Param**(`name`, `schema?`): `ParameterDecorator` 경로 파라미터를 메서드 인자에 바인딩합니다. -## Parameters +### Parameters -### name +#### name `string` -### schema? +#### schema? `ZodType`\<`any`, `ZodTypeDef`, `any`\> -## Returns +### Returns `ParameterDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/Query.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/Query.md index eb9e9a78e..76a968ec1 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/Query.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/Query.md @@ -5,20 +5,52 @@ prev: false title: "Query" --- +## Contract Overload + +> **Query**\<`TContract`, `Name`\>(`contract`, `name`): `ParameterDecorator` + +쿼리스트링 값을 메서드 인자에 바인딩합니다. + +### Type Parameters + +#### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly query: AnyZodObject }` + +#### Name + +`Name` *extends* keyof [`RouteQuery`](/api/protocols-rest/src/type-aliases/routequery/)\<`TContract`\> & `string` + +### Parameters + +#### contract + +`TContract` + +#### name + +`Name` + +### Returns + +`ParameterDecorator` + +## Schema Overload + > **Query**(`name`, `schema?`): `ParameterDecorator` 쿼리스트링 값을 메서드 인자에 바인딩합니다. -## Parameters +### Parameters -### name +#### name `string` -### schema? +#### schema? `ZodType`\<`any`, `ZodTypeDef`, `any`\> -## Returns +### Returns `ParameterDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/ResponseSchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/ResponseSchema.md index eb2570fe7..e8483e77a 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/ResponseSchema.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/ResponseSchema.md @@ -5,14 +5,40 @@ prev: false title: "ResponseSchema" --- +## Contract Overload + +> **ResponseSchema**\<`TContract`\>(`contract`): `MethodDecorator` + +응답 스키마를 메서드에 바인딩합니다. + +### Type Parameters + +#### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly response: z.ZodType }` + +### Parameters + +#### contract + +`TContract` + +### Returns + +`MethodDecorator` + +## Schema Overload + > **ResponseSchema**(`schema`): `MethodDecorator` -## Parameters +응답 스키마를 메서드에 바인딩합니다. + +### Parameters -### schema +#### schema `ZodType` -## Returns +### Returns `MethodDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/defineRouteContract.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/defineRouteContract.md index c32e17928..a25e8292e 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/defineRouteContract.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/defineRouteContract.md @@ -11,13 +11,13 @@ title: "defineRouteContract" ### TContract -`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly `RouteContractProblem`[] \| `undefined`\> +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly [`RouteContractProblem`](/api/protocols-rest/src/type-aliases/routecontractproblem/)[] \| `undefined`\> ## Parameters ### contract -`TContract` & `ValidateRouteContractPathParams`\<`TContract`\> +`TContract` & `ValidateRouteContractPathParams`\<`NoInfer`\<`TContract`\>\> ## Returns diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/isRouteContractSpec.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/isRouteContractSpec.md new file mode 100644 index 000000000..287144c4c --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/isRouteContractSpec.md @@ -0,0 +1,29 @@ +--- +editUrl: false +next: false +prev: false +title: "isRouteContractSpec" +--- + +> **isRouteContractSpec**(`value`): `value is RouteContractSpec` + +Route contract decorator overloads use this guard to distinguish contract objects from direct schema arguments at runtime. + +## Parameters + +### value + +`unknown` + +## Returns + +`value is RouteContractSpec` + +## Example + +```ts +if (isRouteContractSpec(value)) { + value.method; + value.path; +} +``` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeBodySchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeBodySchema.md index a7c4f5e08..ef71ba1be 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeBodySchema.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeBodySchema.md @@ -11,7 +11,7 @@ title: "routeBodySchema" ### TContract -`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly `RouteContractProblem`[] \| `undefined`\> & `object` +`TContract` *extends* [`RouteContractWithBody`](/api/protocols-rest/src/type-aliases/routecontractwithbody/) ## Parameters diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParam.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParam.md index 0da7d4656..614694ff9 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParam.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParam.md @@ -11,7 +11,7 @@ title: "routeParam" ### TContract -`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly `RouteContractProblem`[] \| `undefined`\> +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly [`RouteContractProblem`](/api/protocols-rest/src/type-aliases/routecontractproblem/)[] \| `undefined`\> ### Name diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParamSchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParamSchema.md new file mode 100644 index 000000000..1f1f73472 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParamSchema.md @@ -0,0 +1,32 @@ +--- +editUrl: false +next: false +prev: false +title: "routeParamSchema" +--- + +> **routeParamSchema**\<`TContract`, `Name`\>(`contract`, `name`): `ZodType`\<[`RoutePathParams`](/api/protocols-rest/src/type-aliases/routepathparams/)\<`TContract`\>\[`Name`\]\> + +## Type Parameters + +### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly params: AnyZodObject }` + +### Name + +`Name` *extends* [`RoutePathParamName`](/api/protocols-rest/src/type-aliases/routepathparamname/)\<`TContract`\[`"path"`\]\> & keyof [`RoutePathParams`](/api/protocols-rest/src/type-aliases/routepathparams/)\<`TContract`\> & `string` + +## Parameters + +### contract + +`TContract` + +### name + +`Name` + +## Returns + +`ZodType`\<[`RoutePathParams`](/api/protocols-rest/src/type-aliases/routepathparams/)\<`TContract`\>\[`Name`\]\> diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routePathParamsSchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routePathParamsSchema.md index 36fe4655c..0e6990864 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routePathParamsSchema.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routePathParamsSchema.md @@ -11,7 +11,7 @@ title: "routePathParamsSchema" ### TContract -`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly `RouteContractProblem`[] \| `undefined`\> & `object` +`TContract` *extends* [`RouteContractWithParams`](/api/protocols-rest/src/type-aliases/routecontractwithparams/) ## Parameters diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParam.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParam.md index f5945355c..681e57fb2 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParam.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParam.md @@ -11,7 +11,7 @@ title: "routeQueryParam" ### TContract -`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly `RouteContractProblem`[] \| `undefined`\> +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly [`RouteContractProblem`](/api/protocols-rest/src/type-aliases/routecontractproblem/)[] \| `undefined`\> ### Name diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParamSchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParamSchema.md new file mode 100644 index 000000000..734881387 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParamSchema.md @@ -0,0 +1,32 @@ +--- +editUrl: false +next: false +prev: false +title: "routeQueryParamSchema" +--- + +> **routeQueryParamSchema**\<`TContract`, `Name`\>(`contract`, `name`): `ZodType`\<[`RouteQuery`](/api/protocols-rest/src/type-aliases/routequery/)\<`TContract`\>\[`Name`\]\> + +## Type Parameters + +### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly query: AnyZodObject }` + +### Name + +`Name` *extends* keyof [`RouteQuery`](/api/protocols-rest/src/type-aliases/routequery/)\<`TContract`\> & `string` + +## Parameters + +### contract + +`TContract` + +### name + +`Name` + +## Returns + +`ZodType`\<[`RouteQuery`](/api/protocols-rest/src/type-aliases/routequery/)\<`TContract`\>\[`Name`\]\> diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQuerySchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQuerySchema.md index 06e82f395..5fcd6266c 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQuerySchema.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQuerySchema.md @@ -11,7 +11,7 @@ title: "routeQuerySchema" ### TContract -`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly `RouteContractProblem`[] \| `undefined`\> & `object` +`TContract` *extends* [`RouteContractWithQuery`](/api/protocols-rest/src/type-aliases/routecontractwithquery/) ## Parameters diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeResponseSchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeResponseSchema.md index 44af80f92..d172647f2 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeResponseSchema.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeResponseSchema.md @@ -11,7 +11,7 @@ title: "routeResponseSchema" ### TContract -`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly `RouteContractProblem`[] \| `undefined`\> & `object` +`TContract` *extends* [`RouteContractWithResponse`](/api/protocols-rest/src/type-aliases/routecontractwithresponse/) ## Parameters diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/interfaces/RouteMetadata.md b/packages/docs/src/content/docs/api/protocols-rest/src/interfaces/RouteMetadata.md index d66e8cd7a..b4522259c 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/interfaces/RouteMetadata.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/interfaces/RouteMetadata.md @@ -7,6 +7,12 @@ title: "RouteMetadata" ## Properties +### contract? + +> `optional` **contract**: [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) + +*** + ### method > **method**: [`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/) diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/AnyRouteContractSpec.md b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/AnyRouteContractSpec.md new file mode 100644 index 000000000..7ef2a7d56 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/AnyRouteContractSpec.md @@ -0,0 +1,8 @@ +--- +editUrl: false +next: false +prev: false +title: "AnyRouteContractSpec" +--- + +> **AnyRouteContractSpec** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractProblem.md b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractProblem.md new file mode 100644 index 000000000..65365bcb5 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractProblem.md @@ -0,0 +1,8 @@ +--- +editUrl: false +next: false +prev: false +title: "RouteContractProblem" +--- + +> **RouteContractProblem** = [`ProblemConstructor`](/api/protocols-rest/src/type-aliases/problemconstructor/) \| [`RouteProblemDeclaration`](/api/protocols-rest/src/type-aliases/routeproblemdeclaration/) diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractSourceLocation.md b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractSourceLocation.md new file mode 100644 index 000000000..d53bfb224 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractSourceLocation.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "RouteContractSourceLocation" +--- + +> **RouteContractSourceLocation** = `object` + +## Properties + +### column? + +> `readonly` `optional` **column**: `number` + +*** + +### line? + +> `readonly` `optional` **line**: `number` + +*** + +### path + +> `readonly` **path**: `string` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractSpec.md b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractSpec.md index 5f59a3b08..bf6757fd7 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractSpec.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractSpec.md @@ -35,7 +35,7 @@ title: "RouteContractSpec" ### Problems -`Problems` *extends* readonly `RouteContractProblem`[] \| `undefined` = readonly `RouteContractProblem`[] \| `undefined` +`Problems` *extends* readonly [`RouteContractProblem`](/api/protocols-rest/src/type-aliases/routecontractproblem/)[] \| `undefined` = readonly [`RouteContractProblem`](/api/protocols-rest/src/type-aliases/routecontractproblem/)[] \| `undefined` ## Properties @@ -45,6 +45,12 @@ title: "RouteContractSpec" *** +### id? + +> `readonly` `optional` **id**: `string` + +*** + ### method > `readonly` **method**: `Method` @@ -84,3 +90,9 @@ title: "RouteContractSpec" ### response? > `readonly` `optional` **response**: `Response` + +*** + +### sourceLocation? + +> `readonly` `optional` **sourceLocation**: [`RouteContractSourceLocation`](/api/protocols-rest/src/type-aliases/routecontractsourcelocation/) diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithBody.md b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithBody.md new file mode 100644 index 000000000..8f91381db --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithBody.md @@ -0,0 +1,14 @@ +--- +editUrl: false +next: false +prev: false +title: "RouteContractWithBody" +--- + +> **RouteContractWithBody** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly body: z.ZodType }` + +## Type Declaration + +### body + +> `readonly` **body**: `z.ZodType` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithParams.md b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithParams.md new file mode 100644 index 000000000..cdff7a618 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithParams.md @@ -0,0 +1,14 @@ +--- +editUrl: false +next: false +prev: false +title: "RouteContractWithParams" +--- + +> **RouteContractWithParams** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly params: AnyZodObject }` + +## Type Declaration + +### params + +> `readonly` **params**: `AnyZodObject` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithQuery.md b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithQuery.md new file mode 100644 index 000000000..7335225c6 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithQuery.md @@ -0,0 +1,14 @@ +--- +editUrl: false +next: false +prev: false +title: "RouteContractWithQuery" +--- + +> **RouteContractWithQuery** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly query: AnyZodObject }` + +## Type Declaration + +### query + +> `readonly` **query**: `AnyZodObject` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithResponse.md b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithResponse.md new file mode 100644 index 000000000..dc670a404 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractWithResponse.md @@ -0,0 +1,14 @@ +--- +editUrl: false +next: false +prev: false +title: "RouteContractWithResponse" +--- + +> **RouteContractWithResponse** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly response: z.ZodType }` + +## Type Declaration + +### response + +> `readonly` **response**: `z.ZodType` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/variables/All.md b/packages/docs/src/content/docs/api/protocols-rest/src/variables/All.md index b99a60d08..61ad63eb4 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/variables/All.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/variables/All.md @@ -5,16 +5,6 @@ prev: false title: "All" --- -> `const` **All**: (`path`) => `MethodDecorator` +> `const` **All**: `HttpMethodDecoratorFactory`\<[`ALL`](/api/protocols-rest/src/enumerations/httpmethod/#all)\> 메서드를 모든 HTTP 메서드에 응답하는 라우트로 등록합니다. - -## Parameters - -### path? - -`string` = `""` - -## Returns - -`MethodDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Delete.md b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Delete.md index 8342dd517..41c060795 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Delete.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Delete.md @@ -5,16 +5,6 @@ prev: false title: "Delete" --- -> `const` **Delete**: (`path`) => `MethodDecorator` +> `const` **Delete**: `HttpMethodDecoratorFactory`\<[`DELETE`](/api/protocols-rest/src/enumerations/httpmethod/#delete)\> 메서드를 DELETE 라우트로 등록합니다. - -## Parameters - -### path? - -`string` = `""` - -## Returns - -`MethodDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Get.md b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Get.md index 0579cee2b..d3d63fb0d 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Get.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Get.md @@ -5,16 +5,6 @@ prev: false title: "Get" --- -> `const` **Get**: (`path`) => `MethodDecorator` +> `const` **Get**: `HttpMethodDecoratorFactory`\<[`GET`](/api/protocols-rest/src/enumerations/httpmethod/#get)\> 메서드를 GET 라우트로 등록합니다. - -## Parameters - -### path? - -`string` = `""` - -## Returns - -`MethodDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Head.md b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Head.md index 2b4cd29b7..e67054e30 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Head.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Head.md @@ -5,16 +5,6 @@ prev: false title: "Head" --- -> `const` **Head**: (`path`) => `MethodDecorator` +> `const` **Head**: `HttpMethodDecoratorFactory`\<[`HEAD`](/api/protocols-rest/src/enumerations/httpmethod/#head)\> 메서드를 HEAD 라우트로 등록합니다. - -## Parameters - -### path? - -`string` = `""` - -## Returns - -`MethodDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Options.md b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Options.md index 0a6c183f4..d48befd75 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Options.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Options.md @@ -5,16 +5,6 @@ prev: false title: "Options" --- -> `const` **Options**: (`path`) => `MethodDecorator` +> `const` **Options**: `HttpMethodDecoratorFactory`\<[`OPTIONS`](/api/protocols-rest/src/enumerations/httpmethod/#options)\> 메서드를 OPTIONS 라우트로 등록합니다. - -## Parameters - -### path? - -`string` = `""` - -## Returns - -`MethodDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Patch.md b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Patch.md index d5b90f2dd..77ca23b6c 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Patch.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Patch.md @@ -5,16 +5,6 @@ prev: false title: "Patch" --- -> `const` **Patch**: (`path`) => `MethodDecorator` +> `const` **Patch**: `HttpMethodDecoratorFactory`\<[`PATCH`](/api/protocols-rest/src/enumerations/httpmethod/#patch)\> 메서드를 PATCH 라우트로 등록합니다. - -## Parameters - -### path? - -`string` = `""` - -## Returns - -`MethodDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Post.md b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Post.md index 3829267b0..187de2b09 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Post.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Post.md @@ -5,16 +5,6 @@ prev: false title: "Post" --- -> `const` **Post**: (`path`) => `MethodDecorator` +> `const` **Post**: `HttpMethodDecoratorFactory`\<[`POST`](/api/protocols-rest/src/enumerations/httpmethod/#post)\> 메서드를 POST 라우트로 등록합니다. - -## Parameters - -### path? - -`string` = `""` - -## Returns - -`MethodDecorator` diff --git a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Put.md b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Put.md index a3ef9db5a..5c9ec3866 100644 --- a/packages/docs/src/content/docs/api/protocols-rest/src/variables/Put.md +++ b/packages/docs/src/content/docs/api/protocols-rest/src/variables/Put.md @@ -5,16 +5,6 @@ prev: false title: "Put" --- -> `const` **Put**: (`path`) => `MethodDecorator` +> `const` **Put**: `HttpMethodDecoratorFactory`\<[`PUT`](/api/protocols-rest/src/enumerations/httpmethod/#put)\> 메서드를 PUT 라우트로 등록합니다. - -## Parameters - -### path? - -`string` = `""` - -## Returns - -`MethodDecorator` diff --git a/packages/docs/src/content/docs/en/guides/schema-source-of-truth.mdx b/packages/docs/src/content/docs/en/guides/schema-source-of-truth.mdx index a0d3843d9..097e5ba46 100644 --- a/packages/docs/src/content/docs/en/guides/schema-source-of-truth.mdx +++ b/packages/docs/src/content/docs/en/guides/schema-source-of-truth.mdx @@ -4,15 +4,16 @@ description: Derive DTO types, runtime validation, OpenAPI, and RPC clients from --- Croco route contracts should start from one schema object. The same object feeds request validation, -response validation metadata, contract graph snapshots, OpenAPI emission, and generated RPC client types. +response contract metadata, contract graph snapshots, OpenAPI emission, and generated RPC client types. ## Decision -Use `defineRouteSchema()` for route-level request and response schemas: +Use `defineRouteContract()` for REST controller routes that need one typed source of truth for method, +path, request schemas, and response schema: -- DTO types are inferred with `InferRouteSchemaRequest` and `InferRouteSchemaResponse`. -- REST decorators consume the same schema references through `@Body(...)`, `@Param(...)`, - `@Query(...)`, `@Header(...)`, and `@ResponseSchema(...)`. +- DTO types are inferred with `RouteParam`, `RouteBody`, `RouteQueryParam`, and `RouteMethodReturn`. +- REST decorators consume the same contract through `@Get(contract)`, `@Post(contract)`, + `@Param(contract, "id")`, `@Query(contract, "name")`, and `@Body(contract)`. - `buildContractGraph()` preserves those schema references in `inputSchemas` and `outputSchema`. - `@croco/openapi-spec` and `@croco/rpc-codegen` derive artifacts from the contract graph instead of reading parallel DTO interfaces or hand-written OpenAPI metadata. @@ -21,36 +22,38 @@ Use `defineRouteSchema()` for route-level request and response schemas: import { Body, Controller, + defineRouteContract, + HttpMethod, Post, - ResponseSchema, - defineRouteSchema, - type InferRouteSchemaRequest, - type InferRouteSchemaResponse, + type RouteBody, + type RouteMethodReturn, } from "@croco/protocols-rest"; import { z } from "zod"; -const createUserRoute = defineRouteSchema({ - request: { - body: z.object({ - name: z.string().min(1), - email: z.string().email(), - }), - }, - response: z.object({ - id: z.string().uuid(), - name: z.string(), - email: z.string().email(), - }), +const createUserInput = z.object({ + name: z.string().min(1), + email: z.string().email(), +}); +const userSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + email: z.string().email(), }); -type CreateUserBody = InferRouteSchemaRequest["body"]; -type CreateUserResponse = InferRouteSchemaResponse; +const createUserRoute = defineRouteContract({ + id: "users.create", + method: HttpMethod.POST, + path: "/users", + body: createUserInput, + response: userSchema, +}); @Controller("/users") class UsersController { - @Post("/") - @ResponseSchema(createUserRoute.response) - createUser(@Body(createUserRoute.request.body) body: CreateUserBody): CreateUserResponse { + @Post(createUserRoute) + createUser( + @Body(createUserRoute) body: RouteBody, + ): RouteMethodReturn { return { id: "4ea573de-cfb9-4696-bc48-216f19f44300", ...body }; } } @@ -60,6 +63,35 @@ The controller has no separate `CreateUserDto` interface to keep synchronized. I changes, the method parameter type, runtime validation, OpenAPI request body, and generated RPC request type move together. +## Migration + +Move existing loose decorators one route at a time. Replace the parallel path/param/body/response values +with a single route contract, then point the decorators at that contract: + +```typescript no-check +const getUserRoute = defineRouteContract({ + id: "users.get", + method: HttpMethod.GET, + path: "/users/:id", + params: z.object({ id: userIdSchema }), + response: userSchema, +}); + +@Controller("/users") +class UsersController { + @Get(getUserRoute) + getUser( + @Param(getUserRoute, "id") id: RouteParam, + ): RouteMethodReturn { + return getUser(id); + } +} +``` + +If the contract path declares `:id` but the params schema uses `userId`, `defineRouteContract()` fails +typecheck. If the controller binds `@Body(otherSchema)` or `@ResponseSchema(otherSchema)` against a +contract route, `buildContractGraph()` emits a contract diagnostic before OpenAPI or RPC generation. + ## Considered Models | Model | Strength | Tradeoff | Croco stance | @@ -91,7 +123,7 @@ fails before publishing artifacts. ## Dependency Rationale This model does not add a new external schema dependency. Croco already uses Zod for REST validation and -contract artifact generation. `defineRouteSchema()` is a small contract helper over existing schema +contract artifact generation. `defineRouteContract()` is a small contract helper over existing schema objects, and its public type inference uses Zod's structural output type so package-local Zod instances do not force a duplicate DTO interface. diff --git a/packages/framework-routes/src/__tests__/compiler.spec.ts b/packages/framework-routes/src/__tests__/compiler.spec.ts index ed4d54038..fe2d6fb47 100644 --- a/packages/framework-routes/src/__tests__/compiler.spec.ts +++ b/packages/framework-routes/src/__tests__/compiler.spec.ts @@ -117,6 +117,7 @@ describe("compiler", () => { httpMethod: "GET", path: "/api/hello", controllerPath: "/api", + routeContract: null, params: [], inputSchema: null, inputSchemas: { body: null, path: null, query: null, headers: null }, @@ -133,6 +134,7 @@ describe("compiler", () => { httpMethod: "POST", path: "/api/users", controllerPath: "/api", + routeContract: null, params: [], inputSchema: null, inputSchemas: { body: null, path: null, query: null, headers: null }, diff --git a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts index 53095c6ea..073dfd129 100644 --- a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts +++ b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts @@ -12,9 +12,11 @@ import { All, Body, Controller, + defineRouteContract, defineRouteSchema, Get, Header, + HttpMethod, type InferRouteSchemaRequest, type InferRouteSchemaResponse, Param, @@ -22,6 +24,8 @@ import { ProblemResponse, Query, RequestValidationProblem, + type RouteBody, + type RouteMethodReturn, ResponseSchema, } from "@croco/protocols-rest"; import { Container } from "typedi"; @@ -394,6 +398,71 @@ describe("emitOpenAPI", () => { }); }); + it("should emit request and response contracts from a typed route contract", () => { + const createUserSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + }); + const userSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + email: z.string().email(), + }); + const createUserContract = defineRouteContract({ + id: "users.create", + method: HttpMethod.POST, + path: "/users", + operationId: "createUser", + body: createUserSchema, + response: userSchema, + }); + + @Controller("/users") + class UsersController { + @Post(createUserContract) + createUser( + @Body(createUserContract) body: RouteBody, + ): RouteMethodReturn { + return { id: "4ea573de-cfb9-4696-bc48-216f19f44300", ...body }; + } + } + + const spec = emitOpenAPI([UsersController]); + const createUser = spec.paths?.["/users"]?.post; + + expect(createUser?.operationId).toBe("createUser"); + expect(createUser?.requestBody).toMatchObject({ + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + email: { type: "string", format: "email" }, + }, + required: ["name", "email"], + }, + }, + }, + }); + expect(createUser?.responses?.[200]).toMatchObject({ + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + email: { type: "string", format: "email" }, + }, + required: ["id", "name", "email"], + }, + }, + }, + }); + }); + it("should document Problem Details responses by default", () => { @Controller("/orders") class OrdersController { diff --git a/packages/protocols-core/src/index.ts b/packages/protocols-core/src/index.ts index 23d84880c..32414194e 100644 --- a/packages/protocols-core/src/index.ts +++ b/packages/protocols-core/src/index.ts @@ -3,6 +3,7 @@ export type { ContractAccessMetadata, ContractDiagnostic, ContractDiagnosticSeverity, + ContractDiagnosticSourceLocation, ContractDiagnosticTarget, ContractEntitlementRequirement, ContractEntitlementResourceRequirement, @@ -61,6 +62,7 @@ export type { ContractGraphSnapshotEntitlementRequirement, ContractGraphSnapshotParam, ContractGraphSnapshotProblemResponse, + ContractGraphSnapshotRouteContract, ContractGraphSnapshotRoute, ContractGraphSnapshotVersion, ContractSchemaFieldSnapshot, @@ -102,7 +104,13 @@ export { isControllerConstructor, } from "./libs/controllerDiscovery"; export { extractRouteIR } from "./libs/extractRouteIR"; -export type { ParamIR, ProblemResponseIR, RouteIR } from "./libs/RouteIR"; +export type { + ParamIR, + ProblemResponseIR, + RouteContractIR, + RouteContractSourceLocation, + RouteIR, +} from "./libs/RouteIR"; export type { Constructor, EntitlementRequirementMetadata, diff --git a/packages/protocols-core/src/libs/ContractGraph.ts b/packages/protocols-core/src/libs/ContractGraph.ts index 398abef82..d5dfc8a85 100644 --- a/packages/protocols-core/src/libs/ContractGraph.ts +++ b/packages/protocols-core/src/libs/ContractGraph.ts @@ -3,7 +3,11 @@ import { Problem, ProblemCategory } from "@croco/problems-core"; import type { z } from "zod"; import { extractRouteIR } from "./extractRouteIR"; import type { RouteIR } from "./RouteIR"; -import { describeZodSchema, getSchemaDescriptorDiagnostics } from "./SchemaDescriptor"; +import { + describeZodSchema, + getSchemaDescriptorDiagnostics, + getZodObjectShape, +} from "./SchemaDescriptor"; import { type Constructor, type ControllerMetadata, @@ -25,9 +29,17 @@ export type ContractDiagnostic = { readonly target: ContractDiagnosticTarget; readonly message: string; readonly routeId?: string; + readonly contractId?: string; readonly controllerName?: string; readonly methodName?: string; readonly path?: string; + readonly sourceLocation?: ContractDiagnosticSourceLocation; +}; + +export type ContractDiagnosticSourceLocation = { + readonly path: string; + readonly line?: number; + readonly column?: number; }; export type ContractGraphController = { @@ -80,7 +92,7 @@ type RouteSchemaDiagnosticLocation = "body" | "path" | "query" | "headers" | "re type RouteSchemaDiagnosticEntry = { readonly schema: z.ZodType; - readonly location: RouteSchemaDiagnosticLocation; + readonly location: RouteSchemaDiagnosticLocation | string; }; export type ContractGraphRoute = RouteIR & { @@ -219,7 +231,7 @@ function toContractGraphRoute( return { ...route, routeId, - operationId: routeId.replace(/[^A-Za-z0-9_]+/g, "_"), + operationId: route.routeContract?.operationId ?? routeId.replace(/[^A-Za-z0-9_]+/g, "_"), controllerPath, access: { guards: [ @@ -274,6 +286,7 @@ function validateRoute( diagnostics.push(...validatePathParams(route)); diagnostics.push(...validateNamedParams(route)); diagnostics.push(...validateBodyParams(route)); + diagnostics.push(...validateRouteContract(route)); diagnostics.push(...validateRouteSchemas(route)); diagnostics.push(...validateProblemResponses(route)); diagnostics.push(...validateStrictProblemResponses(route, options)); @@ -281,6 +294,212 @@ function validateRoute( return diagnostics; } +function validateRouteContract(route: ContractGraphRoute): ContractDiagnostic[] { + const contract = route.routeContract; + + if (!contract) { + return []; + } + + return [ + ...validateContractMethod(route), + ...validateContractControllerPath(route), + ...validateContractNamedParams(route, "path", contract.inputSchemas.path), + ...validateContractNamedParams(route, "query", contract.inputSchemas.query), + ...validateContractBody(route), + ...validateContractResponse(route), + ]; +} + +function validateContractMethod(route: ContractGraphRoute): ContractDiagnostic[] { + const contract = route.routeContract; + + if (!contract || contract.method.toUpperCase() === route.httpMethod.toUpperCase()) { + return []; + } + + return [ + createRouteDiagnostic( + route, + "contract-route-method-mismatch", + "error", + `Route contract declares ${contract.method.toUpperCase()} but the route decorator registered ${route.httpMethod.toUpperCase()}. Use the HTTP method decorator that matches the contract.`, + ), + ]; +} + +function validateContractControllerPath(route: ContractGraphRoute): ContractDiagnostic[] { + const contract = route.routeContract; + + if (!contract || route.controllerPath === "" || contract.path === route.controllerPath) { + return []; + } + + if (contract.path.startsWith(`${route.controllerPath}/`)) { + return []; + } + + return [ + createRouteDiagnostic( + route, + "contract-route-controller-path-mismatch", + "error", + `Route contract path '${contract.path}' is outside controller path '${route.controllerPath}'. Contract-first routes use the contract path as the generated/runtime path, so the contract path must include the controller prefix or the controller should use '/'.`, + ), + ]; +} + +function validateContractNamedParams( + route: ContractGraphRoute, + kind: "path" | "query", + contractSchema: z.ZodType | null, +): ContractDiagnostic[] { + const diagnostics: ContractDiagnostic[] = []; + const contractShape = getNamedSchemaShape(contractSchema); + const contractNames = new Set(Object.keys(contractShape)); + const params = route.params.filter((param) => param.kind === kind); + const pathParamNames = kind === "path" ? new Set(getContractPathParamNames(route.path)) : null; + + if (kind === "path" && pathParamNames && pathParamNames.size > 0 && contractNames.size === 0) { + diagnostics.push( + createRouteDiagnostic( + route, + "contract-route-missing-path-param-schema", + "error", + `Route contract path '${route.path}' declares path parameters but the contract has no params schema.`, + ), + ); + } + + for (const name of contractNames) { + const param = params.find((candidate) => candidate.name === name); + + if (!param) { + diagnostics.push( + createRouteDiagnostic( + route, + kind === "path" + ? "contract-route-missing-path-param-binding" + : "contract-route-missing-query-param-binding", + "error", + `Route contract declares ${kind} parameter '${name}' but the controller method does not bind it with @${kind === "path" ? "Param" : "Query"}(contract, "${name}").`, + ), + ); + continue; + } + + if (param.schema !== contractShape[name]) { + diagnostics.push( + createRouteDiagnostic( + route, + kind === "path" + ? "contract-route-path-param-schema-mismatch" + : "contract-route-query-param-schema-mismatch", + "error", + `Controller @${kind === "path" ? "Param" : "Query"}("${name}") schema does not match the route contract schema. Use @${kind === "path" ? "Param" : "Query"}(contract, "${name}") or route ${kind} schema helpers so the contract remains the source of truth.`, + ), + ); + } + } + + for (const param of params) { + if (!contractNames.has(param.name)) { + diagnostics.push( + createRouteDiagnostic( + route, + kind === "path" + ? "contract-route-uncontracted-path-param" + : "contract-route-uncontracted-query-param", + "error", + `Controller binds ${kind} parameter '${param.name}' but the route contract does not declare it.`, + ), + ); + } + } + + return diagnostics; +} + +function validateContractBody(route: ContractGraphRoute): ContractDiagnostic[] { + const contract = route.routeContract; + + if (!contract) { + return []; + } + + const bodyParams = route.params.filter((param) => param.kind === "body"); + const contractBody = contract.inputSchemas.body; + + if (!contractBody) { + if (bodyParams.length === 0) { + return []; + } + + return [ + createRouteDiagnostic( + route, + "contract-route-uncontracted-body-param", + "error", + "Controller binds @Body() but the route contract does not declare a body schema.", + ), + ]; + } + + if (bodyParams.length === 0) { + return [ + createRouteDiagnostic( + route, + "contract-route-missing-body-binding", + "error", + "Route contract declares a body schema but the controller method does not bind it with @Body(contract).", + ), + ]; + } + + return bodyParams + .filter((param) => param.schema !== contractBody) + .map((param) => + createRouteDiagnostic( + route, + "contract-route-body-schema-mismatch", + "error", + `Controller @Body() schema does not match the route contract body schema at parameter '${param.name}'. Use @Body(contract) so request validation and generated contracts share the same schema.`, + ), + ); +} + +function validateContractResponse(route: ContractGraphRoute): ContractDiagnostic[] { + const contract = route.routeContract; + + if (!contract) { + return []; + } + + if (route.outputSchema === contract.outputSchema) { + return []; + } + + if (!contract.outputSchema) { + return [ + createRouteDiagnostic( + route, + "contract-route-uncontracted-response-schema", + "error", + "Controller declares @ResponseSchema() but the route contract does not declare a response schema.", + ), + ]; + } + + return [ + createRouteDiagnostic( + route, + "contract-route-response-schema-mismatch", + "error", + "Controller @ResponseSchema() metadata does not match the route contract response schema. Use @ResponseSchema(contract) or omit @ResponseSchema when @Get(contract) is the source of truth.", + ), + ]; +} + function validateProblemResponses(route: ContractGraphRoute): ContractDiagnostic[] { const diagnostics: ContractDiagnostic[] = []; const seenCodes = new Set(); @@ -311,7 +530,10 @@ function validateProblemResponses(route: ContractGraphRoute): ContractDiagnostic function validateRouteContractProblemResponses(route: ContractGraphRoute): ContractDiagnostic[] { const diagnostics: ContractDiagnostic[] = []; const problemResponses = route.problemResponses ?? []; - const contractResponses = getRouteContractProblemResponses(problemResponses); + const contractResponses = + route.routeContract && route.routeContract.problemResponses.length > 0 + ? route.routeContract.problemResponses + : getRouteContractProblemResponses(problemResponses); if (contractResponses.length === 0) { return diagnostics; @@ -369,7 +591,11 @@ function validateStrictProblemResponses( route: ContractGraphRoute, options: BuildContractGraphOptions, ): ContractDiagnostic[] { - if (!options.strictProblemResponses || (route.problemResponses?.length ?? 0) > 0) { + if ( + !options.strictProblemResponses || + (route.problemResponses?.length ?? 0) > 0 || + (route.routeContract?.problemResponses.length ?? 0) > 0 + ) { return []; } @@ -610,6 +836,19 @@ function validateUniqueOperationIds(routes: readonly ContractGraphRoute[]): Cont return diagnostics; } +function getNamedSchemaShape(schema: z.ZodType | null): Record { + const shape = schema ? getZodObjectShape(schema) : {}; + const result: Record = {}; + + for (const [name, value] of Object.entries(shape)) { + if (isZodType(value)) { + result[name] = value; + } + } + + return result; +} + function getRouteSchemaEntries(route: ContractGraphRoute): RouteSchemaDiagnosticEntry[] { const candidates: readonly { readonly schema: z.ZodType | null; @@ -633,11 +872,72 @@ function getRouteSchemaEntries(route: ContractGraphRoute): RouteSchemaDiagnostic entries.push({ schema: candidate.schema, location: candidate.location }); } + for (const param of route.params) { + if ( + !param.schema || + seen.has(param.schema) || + isParamSchemaCoveredByRouteSchema(route, param) + ) { + continue; + } + + seen.add(param.schema); + entries.push({ + schema: param.schema, + location: formatParamSchemaLocation(param.kind, param.name), + }); + } + return entries; } +function isParamSchemaCoveredByRouteSchema( + route: ContractGraphRoute, + param: ContractGraphRoute["params"][number], +): boolean { + switch (param.kind) { + case "body": + return route.inputSchemas.body === param.schema; + case "path": + case "query": + case "header": { + const schema = + param.kind === "path" + ? route.inputSchemas.path + : param.kind === "query" + ? route.inputSchemas.query + : route.inputSchemas.headers; + + return getNamedSchemaShape(schema)[param.name] === param.schema; + } + case "ctx": + return false; + } +} + +function formatParamSchemaLocation( + kind: ContractGraphRoute["params"][number]["kind"], + name: string, +): string { + if (kind === "ctx") { + return "ctx"; + } + + return name.length > 0 ? `${kind}.${name}` : kind; +} + +function isZodType(value: unknown): value is z.ZodType { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { readonly safeParse?: unknown }; + + return typeof candidate.safeParse === "function"; +} + function formatSchemaLocation( - location: RouteSchemaDiagnosticLocation, + location: RouteSchemaDiagnosticLocation | string, schemaPath: readonly string[], ): string { return schemaPath.length > 0 ? `${location}.${schemaPath.join(".")}` : location; @@ -655,9 +955,13 @@ function createRouteDiagnostic( target: getDiagnosticTarget(code), message, routeId: route.routeId, + ...(route.routeContract?.id ? { contractId: route.routeContract.id } : {}), controllerName: route.controllerName, methodName: route.methodName, path: route.path, + ...(route.routeContract?.sourceLocation + ? { sourceLocation: route.routeContract.sourceLocation } + : {}), }; } diff --git a/packages/protocols-core/src/libs/ContractGraphSnapshot.ts b/packages/protocols-core/src/libs/ContractGraphSnapshot.ts index f07a4ef83..88f7ae146 100644 --- a/packages/protocols-core/src/libs/ContractGraphSnapshot.ts +++ b/packages/protocols-core/src/libs/ContractGraphSnapshot.ts @@ -44,6 +44,18 @@ export type ContractGraphSnapshotProblemResponse = { readonly type?: string; }; +export type ContractGraphSnapshotRouteContract = { + readonly id: string | null; + readonly method: string; + readonly path: string; + readonly operationId?: string; + readonly sourceLocation?: { + readonly path: string; + readonly line?: number; + readonly column?: number; + }; +}; + export type ContractGraphSnapshotEntitlementRequirement = ContractEntitlementRequirement; export type ContractGraphSnapshotRoute = { @@ -55,6 +67,7 @@ export type ContractGraphSnapshotRoute = { readonly path: string; readonly controllerPath: string; readonly domain: string | null; + readonly routeContract: ContractGraphSnapshotRouteContract | null; readonly access: ContractAccessMetadata; readonly entitlements: readonly ContractGraphSnapshotEntitlementRequirement[]; readonly params: readonly ContractGraphSnapshotParam[]; @@ -139,6 +152,19 @@ function toSnapshotRoute(route: ContractGraphRoute): ContractGraphSnapshotRoute path: route.path, controllerPath: route.controllerPath, domain: route.domain, + routeContract: route.routeContract + ? { + id: route.routeContract.id, + method: route.routeContract.method, + path: route.routeContract.path, + ...(route.routeContract.operationId + ? { operationId: route.routeContract.operationId } + : {}), + ...(route.routeContract.sourceLocation + ? { sourceLocation: route.routeContract.sourceLocation } + : {}), + } + : null, access: { guards: sortGuards(route.access.guards), roles: [...route.access.roles].sort(compareStrings), diff --git a/packages/protocols-core/src/libs/RouteIR.ts b/packages/protocols-core/src/libs/RouteIR.ts index a1a67ec0f..ecf97c41c 100644 --- a/packages/protocols-core/src/libs/RouteIR.ts +++ b/packages/protocols-core/src/libs/RouteIR.ts @@ -6,6 +6,7 @@ export interface RouteIR { methodName: string; httpMethod: string; path: string; + routeContract: RouteContractIR | null; params: ParamIR[]; inputSchema: z.ZodType | null; inputSchemas: RouteInputSchemas; @@ -14,6 +15,23 @@ export interface RouteIR { domain: string | null; } +export type RouteContractIR = { + readonly id: string | null; + readonly method: string; + readonly path: string; + readonly operationId?: string; + readonly sourceLocation?: RouteContractSourceLocation; + readonly inputSchemas: RouteInputSchemas; + readonly outputSchema: z.ZodType | null; + readonly problemResponses: readonly ProblemResponseIR[]; +}; + +export type RouteContractSourceLocation = { + readonly path: string; + readonly line?: number; + readonly column?: number; +}; + export type RouteInputSchemas = { body: z.ZodType | null; path: z.ZodType | null; diff --git a/packages/protocols-core/src/libs/extractRouteIR.ts b/packages/protocols-core/src/libs/extractRouteIR.ts index 6931212d8..8f79ade57 100644 --- a/packages/protocols-core/src/libs/extractRouteIR.ts +++ b/packages/protocols-core/src/libs/extractRouteIR.ts @@ -1,7 +1,13 @@ import "reflect-metadata"; import { ProblemCategoryMapper } from "@croco/problems-core"; import type { z } from "zod"; -import type { ParamIR, ProblemResponseIR, RouteInputSchemas, RouteIR } from "./RouteIR"; +import type { + ParamIR, + ProblemResponseIR, + RouteContractIR, + RouteInputSchemas, + RouteIR, +} from "./RouteIR"; import { buildHeaderSchema, buildPathSchema, buildQuerySchema } from "./schemaBuilder"; import { type Constructor, @@ -13,6 +19,7 @@ import { REST_CONTROLLER_KEY, REST_PARAMS_KEY, REST_ROUTES_KEY, + type RouteContractMetadata, type RouteMetadata, } from "./sharedTypes"; @@ -35,11 +42,16 @@ export function extractRouteIR(controllerCtor: Constructor): RouteIR[] { return routesMeta.map((routeMeta) => { const params = extractParams(paramsMap?.get(routeMeta.methodName) ?? []); - const inputSchemas = extractInputSchemas(params); - const outputSchema = + const routeContract = extractRouteContract(routeMeta.contract); + const decoratorInputSchemas = extractInputSchemas(params); + const inputSchemas = routeContract + ? mergeContractInputSchemas(routeContract.inputSchemas, decoratorInputSchemas) + : decoratorInputSchemas; + const decoratorOutputSchema = (Reflect.getMetadata(RESPONSE_SCHEMA_KEY, controllerCtor, routeMeta.methodName) as | z.ZodType | undefined) ?? null; + const outputSchema = decoratorOutputSchema ?? routeContract?.outputSchema ?? null; const problemResponses = extractProblemResponses( Reflect.getMetadata(PROBLEM_RESPONSES_KEY, controllerCtor, routeMeta.methodName), ); @@ -48,7 +60,8 @@ export function extractRouteIR(controllerCtor: Constructor): RouteIR[] { controllerName: controllerCtor.name, methodName: String(routeMeta.methodName), httpMethod: routeMeta.method, - path: joinPaths(controllerMeta.path, routeMeta.path), + path: routeContract?.path ?? joinPaths(controllerMeta.path, routeMeta.path), + routeContract, params, inputSchema: inputSchemas.body, inputSchemas, @@ -59,6 +72,42 @@ export function extractRouteIR(controllerCtor: Constructor): RouteIR[] { }); } +function extractRouteContract(contract: RouteContractMetadata | undefined): RouteContractIR | null { + if (!contract) { + return null; + } + + const path = normalizeFullPath(contract.path); + + return { + id: contract.id ?? null, + method: contract.method, + path, + ...(contract.operationId ? { operationId: contract.operationId } : {}), + ...(contract.sourceLocation ? { sourceLocation: contract.sourceLocation } : {}), + inputSchemas: { + body: contract.body ?? null, + path: contract.params ?? null, + query: contract.query ?? null, + headers: null, + }, + outputSchema: contract.response ?? null, + problemResponses: extractContractProblemResponses(contract.problems), + }; +} + +function mergeContractInputSchemas( + contractInputSchemas: RouteInputSchemas, + decoratorInputSchemas: RouteInputSchemas, +): RouteInputSchemas { + return { + body: contractInputSchemas.body, + path: contractInputSchemas.path, + query: contractInputSchemas.query, + headers: decoratorInputSchemas.headers, + }; +} + function extractProblemResponses(value: unknown): ProblemResponseIR[] { if (!Array.isArray(value)) { return []; @@ -70,6 +119,17 @@ function extractProblemResponses(value: unknown): ProblemResponseIR[] { .sort(compareProblemResponses); } +function extractContractProblemResponses(value: unknown): ProblemResponseIR[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter(isProblemResponseMetadata) + .map(toContractProblemResponseIR) + .sort(compareProblemResponses); +} + function toProblemResponseIR(response: ProblemResponseMetadata): ProblemResponseIR { const routeContractProblems = response.routeContractProblems ?.filter(isProblemResponseMetadata) @@ -173,3 +233,9 @@ function joinPaths(base: string, path: string): string { return result.length > 1 && result.endsWith("/") ? result.slice(0, -1) : result || "/"; } + +function normalizeFullPath(path: string): string { + const result = (path.startsWith("/") ? path : `/${path}`).replace(/\/+/g, "/"); + + return result.length > 1 && result.endsWith("/") ? result.slice(0, -1) : result || "/"; +} diff --git a/packages/protocols-core/src/libs/sharedTypes.ts b/packages/protocols-core/src/libs/sharedTypes.ts index ea95c5a05..1a9378640 100644 --- a/packages/protocols-core/src/libs/sharedTypes.ts +++ b/packages/protocols-core/src/libs/sharedTypes.ts @@ -1,4 +1,5 @@ import type { ProblemCategory } from "@croco/problems-core"; +import type { z } from "zod"; /** * @croco/protocols-rest와 동일한 Symbol.for() 키를 사용합니다. @@ -35,15 +36,38 @@ export interface RouteMetadata { path: string; methodName: string | symbol; statusCode?: number; + contract?: RouteContractMetadata; } -export type ProblemResponseMetadata = { +export type RouteContractSourceLocation = { + readonly path: string; + readonly line?: number; + readonly column?: number; +}; + +export type RouteContractProblemMetadata = { readonly code: string; readonly category: ProblemCategory; readonly status?: number; readonly description?: string; readonly type?: string; - readonly routeContractProblems?: readonly ProblemResponseMetadata[]; +}; + +export type RouteContractMetadata = { + readonly id?: string; + readonly method: string; + readonly path: string; + readonly operationId?: string; + readonly sourceLocation?: RouteContractSourceLocation; + readonly params?: z.AnyZodObject; + readonly query?: z.AnyZodObject; + readonly body?: z.ZodType; + readonly response?: z.ZodType; + readonly problems?: readonly RouteContractProblemMetadata[]; +}; + +export type ProblemResponseMetadata = RouteContractProblemMetadata & { + readonly routeContractProblems?: readonly RouteContractProblemMetadata[]; }; export type EntitlementResourceRequirementMetadata = { diff --git a/packages/protocols-core/src/tests/ContractGraph.spec.ts b/packages/protocols-core/src/tests/ContractGraph.spec.ts index 42fc9b4d4..009047f33 100644 --- a/packages/protocols-core/src/tests/ContractGraph.spec.ts +++ b/packages/protocols-core/src/tests/ContractGraph.spec.ts @@ -19,6 +19,7 @@ import { createContractGraphSnapshot, stringifyContractGraphSnapshot, } from "../libs/ContractGraphSnapshot"; +import { REST_ROUTES_KEY, type RouteMetadata } from "../libs/sharedTypes"; import { defineRouteSchema, type InferRouteSchemaRequest, @@ -164,6 +165,64 @@ describe("buildContractGraph", () => { expect(() => assertContractGraphHasNoErrors(graph)).not.toThrow(); }); + it("should preserve route contract identity and reject body or response decorator drift", () => { + const bodySchema = z.object({ name: z.string() }); + const otherBodySchema = z.object({ displayName: z.string() }); + const responseSchema = z.object({ id: z.string(), name: z.string() }); + const otherResponseSchema = z.object({ id: z.string(), displayName: z.string() }); + + @Controller("/users") + class UsersController { + @Post("/") + @ResponseSchema(otherResponseSchema) + createUser(@Body(otherBodySchema) _body: z.infer): void {} + } + + attachRouteContract(UsersController, "createUser", { + id: "users.create", + method: "POST", + path: "/users", + operationId: "createUser", + sourceLocation: { path: "src/controllers/UserController.ts", line: 20 }, + body: bodySchema, + response: responseSchema, + }); + + const graph = buildContractGraph([UsersController]); + + expect(graph.routes[0]).toMatchObject({ + routeContract: { + id: "users.create", + method: "POST", + path: "/users", + operationId: "createUser", + sourceLocation: { path: "src/controllers/UserController.ts", line: 20 }, + }, + operationId: "createUser", + }); + expect(createContractGraphSnapshot(graph).routes[0]?.routeContract).toEqual({ + id: "users.create", + method: "POST", + path: "/users", + operationId: "createUser", + sourceLocation: { path: "src/controllers/UserController.ts", line: 20 }, + }); + expect(graph.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "contract-route-body-schema-mismatch", + contractId: "users.create", + sourceLocation: { path: "src/controllers/UserController.ts", line: 20 }, + }), + expect.objectContaining({ + code: "contract-route-response-schema-mismatch", + contractId: "users.create", + sourceLocation: { path: "src/controllers/UserController.ts", line: 20 }, + }), + ]), + ); + }); + it("should expose auth and access metadata references when present", () => { const AuthGuard = class SharedAccessGuard {}; const AuditGuard = class SharedAccessGuard {}; @@ -364,6 +423,37 @@ describe("buildContractGraph", () => { expect(() => assertContractGraphHasNoErrors(graph)).toThrow(ContractGraphDiagnosticError); }); + it("should reject JSON-unsafe decorator parameter schemas shadowed by route contracts", () => { + @Controller("/profiles") + class ProfilesController { + @Get("/:id") + getProfile(@Param("id", z.date()) _id: Date): void {} + } + + attachRouteContract(ProfilesController, "getProfile", { + method: "GET", + path: "/profiles/:id", + params: z.object({ id: z.string() }), + }); + + const graph = buildContractGraph([ProfilesController]); + + expect(graph.diagnostics).toEqual([ + expect.objectContaining({ + code: "contract-route-path-param-schema-mismatch", + severity: "error", + routeId: "ProfilesController.getProfile", + }), + expect.objectContaining({ + code: CONTRACT_SCHEMA_JSON_UNSAFE_DIAGNOSTIC_CODE, + severity: "error", + routeId: "ProfilesController.getProfile", + message: expect.stringContaining("path.id"), + }), + ]); + expect(() => assertContractGraphHasNoErrors(graph)).toThrow(ContractGraphDiagnosticError); + }); + it("should reject routes with more than one request body parameter", () => { @Controller("/users") class UsersController { @@ -863,6 +953,80 @@ describe("buildContractGraph", () => { expect(() => assertContractGraphHasNoErrors(graph)).toThrow(ContractGraphDiagnosticError); }); + it("should reject manual Problem responses that drift from route contract problems", () => { + const userIdSchema = z.string(); + + @Controller("/users") + class UsersController { + @Get("/:id") + @ProblemResponse({ + code: "USER_FORBIDDEN", + category: ProblemCategory.Forbidden, + }) + getUser(@Param("id", userIdSchema) _id: string): void {} + } + + attachRouteContract(UsersController, "getUser", { + method: "GET", + path: "/users/:id", + params: z.object({ id: userIdSchema }), + problems: [ + { + code: "USER_NOT_FOUND", + category: ProblemCategory.NotFound, + status: 404, + }, + ], + }); + + const graph = buildContractGraph([UsersController], { strictProblemResponses: true }); + + expect(graph.diagnostics).toEqual([ + expect.objectContaining({ + code: "contract-route-missing-problem-response", + routeId: "UsersController.getUser", + }), + expect.objectContaining({ + code: "contract-route-problem-response-not-in-contract", + routeId: "UsersController.getUser", + }), + ]); + expect(() => assertContractGraphHasNoErrors(graph)).toThrow(ContractGraphDiagnosticError); + }); + + it("should reject missing route metadata for route contract problems", () => { + const userIdSchema = z.string(); + + @Controller("/users") + class UsersController { + @Get("/:id") + getUser(@Param("id", userIdSchema) _id: string): void {} + } + + attachRouteContract(UsersController, "getUser", { + method: "GET", + path: "/users/:id", + params: z.object({ id: userIdSchema }), + problems: [ + { + code: "USER_NOT_FOUND", + category: ProblemCategory.NotFound, + status: 404, + }, + ], + }); + + const graph = buildContractGraph([UsersController], { strictProblemResponses: true }); + + expect(graph.diagnostics).toEqual([ + expect.objectContaining({ + code: "contract-route-missing-problem-response", + routeId: "UsersController.getUser", + }), + ]); + expect(() => assertContractGraphHasNoErrors(graph)).toThrow(ContractGraphDiagnosticError); + }); + it("should reject Problem responses that are not declared by the route contract", () => { const routeContractProblems = [ { @@ -1481,3 +1645,17 @@ describe("buildContractGraph", () => { ]); }); }); + +function attachRouteContract( + controller: Function, + methodName: string, + contract: NonNullable, +): void { + const routes = Reflect.getMetadata(REST_ROUTES_KEY, controller) as RouteMetadata[]; + + Reflect.defineMetadata( + REST_ROUTES_KEY, + routes.map((route) => (route.methodName === methodName ? { ...route, contract } : route)), + controller, + ); +} diff --git a/packages/protocols-core/src/tests/extractRouteIR.spec.ts b/packages/protocols-core/src/tests/extractRouteIR.spec.ts index d013ddaf6..38afc3c7f 100644 --- a/packages/protocols-core/src/tests/extractRouteIR.spec.ts +++ b/packages/protocols-core/src/tests/extractRouteIR.spec.ts @@ -3,6 +3,7 @@ import { ProblemCategory } from "@croco/problems-core"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import { extractRouteIR } from "../libs/extractRouteIR"; +import { REST_ROUTES_KEY, type RouteMetadata } from "../libs/sharedTypes"; import { Body, Controller, @@ -101,8 +102,8 @@ describe("extractRouteIR", () => { expect(routes).toHaveLength(1); expect(routes[0]?.inputSchemas.body).toBeNull(); expect(routes[0]?.inputSchemas.path).toBeTruthy(); - expect((routes[0]?.inputSchemas.path as z.ZodObject).shape).toHaveProperty("id"); - expect((routes[0]?.inputSchemas.path as z.ZodObject).shape.id).toBeInstanceOf(z.ZodString); + expect((routes[0]?.inputSchemas.path as z.AnyZodObject).shape).toHaveProperty("id"); + expect((routes[0]?.inputSchemas.path as z.AnyZodObject).shape.id).toBeInstanceOf(z.ZodString); expect(routes[0]?.inputSchemas.query).toBeNull(); expect(routes[0]?.inputSchemas.headers).toBeNull(); expect(routes[0]?.params).toEqual([{ kind: "path", name: "id", schema: null }]); @@ -143,9 +144,9 @@ describe("extractRouteIR", () => { expect(routes).toHaveLength(1); expect(routes[0]?.inputSchemas.body).toBe(updateItemSchema); expect(routes[0]?.inputSchemas.path).toBeTruthy(); - expect((routes[0]?.inputSchemas.path as z.ZodObject).shape.id).toBeInstanceOf(z.ZodString); + expect((routes[0]?.inputSchemas.path as z.AnyZodObject).shape.id).toBeInstanceOf(z.ZodString); expect(routes[0]?.inputSchemas.query).toBeTruthy(); - expect((routes[0]?.inputSchemas.query as z.ZodObject).shape.filter).toBeInstanceOf( + expect((routes[0]?.inputSchemas.query as z.AnyZodObject).shape.filter).toBeInstanceOf( z.ZodString, ); expect(routes[0]?.inputSchema).toBe(updateItemSchema); @@ -165,9 +166,9 @@ describe("extractRouteIR", () => { expect(routes[0]?.inputSchemas.path).toBeNull(); expect(routes[0]?.inputSchemas.query).toBeNull(); expect(routes[0]?.inputSchemas.headers).toBeTruthy(); - expect( - (routes[0]?.inputSchemas.headers as z.ZodObject).shape["x-tenant-id"], - ).toBeInstanceOf(z.ZodString); + expect((routes[0]?.inputSchemas.headers as z.AnyZodObject).shape["x-tenant-id"]).toBeInstanceOf( + z.ZodString, + ); expect(routes[0]?.params).toEqual([{ kind: "header", name: "x-tenant-id", schema: null }]); }); @@ -187,6 +188,92 @@ describe("extractRouteIR", () => { expect(routes[0]?.outputSchema).toBe(userSchema); }); + it("should extract path, input, and output schemas from route contract metadata", () => { + const userIdSchema = z.string().uuid(); + const includePostsSchema = z.boolean().optional(); + const tenantIdSchema = z.string().uuid(); + const paramsSchema = z.object({ id: userIdSchema }); + const querySchema = z.object({ includePosts: includePostsSchema }); + const userSchema = z.object({ id: z.string(), name: z.string() }); + + @Controller("/users") + class UsersController { + @Get("/:id") + getUser( + @Param("id", userIdSchema) _id: string, + @Query("includePosts", includePostsSchema) _includePosts: boolean | undefined, + @Header("x-tenant-id", tenantIdSchema) _tenantId: string, + ): void {} + } + + attachRouteContract(UsersController, "getUser", { + id: "users.get", + method: "GET", + path: "/users/:id", + operationId: "getUser", + sourceLocation: { path: "src/controllers/UserController.ts", line: 12 }, + params: paramsSchema, + query: querySchema, + response: userSchema, + }); + + const routes = extractRouteIR(UsersController); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ + path: "/users/:id", + routeContract: { + id: "users.get", + method: "GET", + path: "/users/:id", + operationId: "getUser", + sourceLocation: { path: "src/controllers/UserController.ts", line: 12 }, + }, + }); + expect(routes[0]?.inputSchemas.path).toBe(paramsSchema); + expect(routes[0]?.inputSchemas.query).toBe(querySchema); + expect((routes[0]?.inputSchemas.headers as z.AnyZodObject).shape["x-tenant-id"]).toBe( + tenantIdSchema, + ); + expect(routes[0]?.outputSchema).toBe(userSchema); + expect(routes[0]?.params).toEqual([ + { kind: "path", name: "id", schema: userIdSchema }, + { kind: "query", name: "includePosts", schema: includePostsSchema }, + { kind: "header", name: "x-tenant-id", schema: tenantIdSchema }, + ]); + }); + + it("should extract route contract Problem responses from contract metadata", () => { + @Controller("/users") + class UsersController { + @Get("/:id") + getUser(@Param("id") _id: string): void {} + } + + attachRouteContract(UsersController, "getUser", { + method: "GET", + path: "/users/:id", + problems: [ + { + code: "USER_NOT_FOUND", + category: ProblemCategory.NotFound, + description: "The requested user does not exist.", + }, + ], + }); + + const routes = extractRouteIR(UsersController); + + expect(routes[0]?.routeContract?.problemResponses).toEqual([ + { + code: "USER_NOT_FOUND", + category: ProblemCategory.NotFound, + description: "The requested user does not exist.", + status: 404, + }, + ]); + }); + it("should set outputSchema to null when response schema metadata is missing", () => { @Controller("/users") class UsersController { @@ -232,3 +319,17 @@ describe("extractRouteIR", () => { expect(extractRouteIR(PlainClass)).toEqual([]); }); }); + +function attachRouteContract( + controller: Function, + methodName: string, + contract: NonNullable, +): void { + const routes = Reflect.getMetadata(REST_ROUTES_KEY, controller) as RouteMetadata[]; + + Reflect.defineMetadata( + REST_ROUTES_KEY, + routes.map((route) => (route.methodName === methodName ? { ...route, contract } : route)), + controller, + ); +} diff --git a/packages/protocols-rest/README.md b/packages/protocols-rest/README.md index 612127a59..ec9b64279 100644 --- a/packages/protocols-rest/README.md +++ b/packages/protocols-rest/README.md @@ -111,10 +111,6 @@ import { HttpMethod, Param, Post, - ResponseSchema, - routeBodySchema, - routeParam, - routeResponseSchema, type RouteBody, type RouteMethodReturn, type RouteParam, @@ -140,25 +136,23 @@ const createUser = defineRouteContract({ @Controller("/users") class UserController { - @Get(getUser.path) - @ResponseSchema(routeResponseSchema(getUser)) + @Get(getUser) find( - @Param(routeParam(getUser, "id")) id: RouteParam, + @Param(getUser, "id") id: RouteParam, ): RouteMethodReturn { return { id, name: "Ada" }; } - @Post(createUser.path) - @ResponseSchema(routeResponseSchema(createUser)) + @Post(createUser) create( - @Body(routeBodySchema(createUser)) body: RouteBody, + @Body(createUser) body: RouteBody, ): RouteMethodReturn { return { id: "user-1", name: body.name }; } } ``` -`defineRouteContract`는 path params, query, body, response, Problem union을 TypeScript 계약으로 연결합니다. `routeParam(getUser, "userId")`처럼 path에 없는 이름이나 response schema와 맞지 않는 반환 타입은 typecheck 단계에서 실패합니다. 런타임 값 검증은 기존처럼 Zod schema와 pipe가 담당합니다. +`defineRouteContract`는 path params, query, body, response, Problem union을 TypeScript 계약으로 연결합니다. `@Get(createUser)`처럼 HTTP 메서드가 맞지 않거나 `@Param(getUser, "userId")`처럼 path에 없는 이름, response schema와 맞지 않는 반환 타입은 typecheck 단계에서 실패합니다. `RouteContract.path`는 `/users/:id` 같은 최종 경로이며, `@Controller("/users")`는 컨트롤러 그룹/런타임 prefix로 유지됩니다. 런타임 값 검증은 기존처럼 Zod schema와 pipe가 담당합니다. ## API 레퍼런스 @@ -169,5 +163,5 @@ class UserController { - 메타데이터 조회: `getControllerMeta`, `getRouteMeta`, `getParamsMeta`, `getGuards`, `getPipes`, `getInterceptors`, `getFilters`, `isController` - 검증 유틸리티: `createValidator`, `validateRequest`, `validateResponse`, `createValidationPipe` - 검증 Problem: `ValidationProblem`, `RequestValidationProblem`, `ResponseValidationProblem` -- 스키마 계약: `defineRouteSchema`, `InferRouteSchemaRequest`, `InferRouteSchemaResponse` -- 타입: `ExecutionContext`, `PipeTransform`, `ExceptionFilter`, `CallHandler`, `RouteSchema`, `TypedRouteConfig`, `RouteContractSpec`, `RouteMethodReturn` +- 스키마 계약: `defineRouteSchema`, `InferRouteSchemaRequest`, `InferRouteSchemaResponse`, `defineRouteContract`, `routeParam`, `routeParamSchema`, `routeQueryParam`, `routeQueryParamSchema`, `routePathParamsSchema`, `routeQuerySchema`, `routeBodySchema`, `routeResponseSchema` +- 타입: `ExecutionContext`, `PipeTransform`, `ExceptionFilter`, `CallHandler`, `RouteSchema`, `TypedRouteConfig`, `RouteContractSpec`, `RouteContractSourceLocation`, `RouteBody`, `RouteResponse`, `RouteMethodReturn`, `RouteParam`, `RouteQueryParam` diff --git a/packages/protocols-rest/src/libs/decorators/Controller.ts b/packages/protocols-rest/src/libs/decorators/Controller.ts index a466e2ba1..cce2a87ca 100644 --- a/packages/protocols-rest/src/libs/decorators/Controller.ts +++ b/packages/protocols-rest/src/libs/decorators/Controller.ts @@ -1,6 +1,6 @@ import "reflect-metadata"; -import { REST_CONTROLLER_KEY } from "../constants"; -import type { ControllerMetadata } from "../types"; +import { REST_CONTROLLER_KEY, REST_ROUTES_KEY } from "../constants"; +import type { ControllerMetadata, RouteMetadata } from "../types"; /** * 클래스를 REST 컨트롤러로 등록하고 기본 경로를 저장합니다. @@ -13,5 +13,45 @@ export function Controller(path: string = ""): ClassDecorator { target, }; Reflect.defineMetadata(REST_CONTROLLER_KEY, metadata, target); + normalizeContractRoutePaths(target, metadata.path); }; } + +function normalizeContractRoutePaths(target: Function, controllerPath: string): void { + const routes = Reflect.getOwnMetadata(REST_ROUTES_KEY, target) as RouteMetadata[] | undefined; + + if (!routes?.some((route) => route.contract)) { + return; + } + + Reflect.defineMetadata( + REST_ROUTES_KEY, + routes.map((route) => + route.contract + ? { + ...route, + path: toControllerRelativePath(controllerPath, route.contract.path), + } + : route, + ), + target, + ); +} + +function toControllerRelativePath(controllerPath: string, routePath: string): string { + const normalizedRoutePath = routePath.startsWith("/") ? routePath : `/${routePath}`; + + if (controllerPath === "") { + return normalizedRoutePath === "/" ? "" : normalizedRoutePath; + } + + if (normalizedRoutePath === controllerPath) { + return ""; + } + + if (normalizedRoutePath.startsWith(`${controllerPath}/`)) { + return normalizedRoutePath.slice(controllerPath.length); + } + + return normalizedRoutePath; +} diff --git a/packages/protocols-rest/src/libs/decorators/HttpMethod.ts b/packages/protocols-rest/src/libs/decorators/HttpMethod.ts index 1b30bb5df..02dba6a2c 100644 --- a/packages/protocols-rest/src/libs/decorators/HttpMethod.ts +++ b/packages/protocols-rest/src/libs/decorators/HttpMethod.ts @@ -1,10 +1,22 @@ import "reflect-metadata"; import { HttpMethod as HttpMethodEnum, REST_ROUTES_KEY } from "../constants"; import type { RouteMetadata } from "../types"; +import type { RouteContractSpec } from "../types/RouteContract"; -function createMethodDecorator(method: HttpMethodEnum) { - return (path: string = ""): MethodDecorator => { +type RouteContractForMethod = RouteContractSpec; + +type HttpMethodDecoratorFactory = { + (path?: string): MethodDecorator; + >(contract: TContract): MethodDecorator; +}; + +function createMethodDecorator( + method: Method, +): HttpMethodDecoratorFactory { + return ((pathOrContract: string | RouteContractForMethod = ""): MethodDecorator => { return (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const contract = typeof pathOrContract === "string" ? undefined : pathOrContract; + const path = typeof pathOrContract === "string" ? pathOrContract : pathOrContract.path; const normalizedPath = path.startsWith("/") ? path : `/${path}`; const existingRoutes: RouteMetadata[] = @@ -16,6 +28,7 @@ function createMethodDecorator(method: HttpMethodEnum) { method, path: normalizedPath === "/" ? "" : normalizedPath, methodName: propertyKey, + ...(contract ? { contract } : {}), }; Reflect.defineMetadata( @@ -26,7 +39,7 @@ function createMethodDecorator(method: HttpMethodEnum) { return descriptor; }; - }; + }) as HttpMethodDecoratorFactory; } /** diff --git a/packages/protocols-rest/src/libs/decorators/Params.ts b/packages/protocols-rest/src/libs/decorators/Params.ts index 97907d7d0..ec97f5593 100644 --- a/packages/protocols-rest/src/libs/decorators/Params.ts +++ b/packages/protocols-rest/src/libs/decorators/Params.ts @@ -2,8 +2,21 @@ import "reflect-metadata"; import type { z } from "zod"; import { ParamType, REST_PARAMS_KEY } from "../constants"; import type { ParamMetadata } from "../types"; +import { + hasRouteBodyContract, + hasRouteParamsContract, + hasRouteQueryContract, + type RouteContractWithBody, + type RouteContractWithParams, + type RouteContractWithQuery, + type RoutePathParamName, + type RoutePathParams, + type RouteQuery, +} from "../types/RouteContract"; import { ValidationPipe } from "../validators/ValidationPipe"; +type AnyZodObject = z.AnyZodObject; + function createParamDecorator(type: ParamType) { return (name?: string, schema?: z.ZodType): ParameterDecorator => { return (target: object, propertyKey: string | symbol | undefined, parameterIndex: number) => { @@ -35,14 +48,44 @@ function createParamDecorator(type: ParamType) { /** * 경로 파라미터를 메서드 인자에 바인딩합니다. */ -export const Param = (name: string, schema?: z.ZodType) => - createParamDecorator(ParamType.PARAM)(name, schema); +export function Param< + TContract extends RouteContractWithParams, + Name extends RoutePathParamName & keyof RoutePathParams & string, +>(contract: TContract, name: Name): ParameterDecorator; +export function Param(name: string, schema?: z.ZodType): ParameterDecorator; +export function Param( + nameOrContract: string | RouteContractWithParams, + schemaOrName?: z.ZodType | string, +): ParameterDecorator { + if (hasRouteParamsContract(nameOrContract)) { + const name = schemaOrName as keyof RoutePathParams & string; + + return createParamDecorator(ParamType.PARAM)(name, getObjectShape(nameOrContract.params)[name]); + } + + return createParamDecorator(ParamType.PARAM)(nameOrContract, schemaOrName as z.ZodType); +} /** * 쿼리스트링 값을 메서드 인자에 바인딩합니다. */ -export const Query = (name: string, schema?: z.ZodType) => - createParamDecorator(ParamType.QUERY)(name, schema); +export function Query< + TContract extends RouteContractWithQuery, + Name extends keyof RouteQuery & string, +>(contract: TContract, name: Name): ParameterDecorator; +export function Query(name: string, schema?: z.ZodType): ParameterDecorator; +export function Query( + nameOrContract: string | RouteContractWithQuery, + schemaOrName?: z.ZodType | string, +): ParameterDecorator { + if (hasRouteQueryContract(nameOrContract)) { + const name = schemaOrName as keyof RouteQuery & string; + + return createParamDecorator(ParamType.QUERY)(name, getObjectShape(nameOrContract.query)[name]); + } + + return createParamDecorator(ParamType.QUERY)(nameOrContract, schemaOrName as z.ZodType); +} /** * 요청 헤더 값을 메서드 인자에 바인딩합니다. @@ -53,8 +96,15 @@ export const Header = (name: string, schema?: z.ZodType) => /** * 요청 본문 전체를 메서드 인자에 바인딩합니다. */ -export const Body = (schema?: z.ZodType): ParameterDecorator => - createParamDecorator(ParamType.BODY)(undefined, schema); +export function Body( + contract: TContract, +): ParameterDecorator; +export function Body(schema?: z.ZodType): ParameterDecorator; +export function Body(schemaOrContract?: z.ZodType | RouteContractWithBody) { + const schema = hasRouteBodyContract(schemaOrContract) ? schemaOrContract.body : schemaOrContract; + + return createParamDecorator(ParamType.BODY)(undefined, schema); +} /** * 추상화된 HTTP 컨텍스트를 메서드 인자에 바인딩합니다. @@ -65,3 +115,7 @@ export const Ctx = (): ParameterDecorator => createParamDecorator(ParamType.CTX) * 전송 계층의 원본 요청 객체를 메서드 인자에 바인딩합니다. */ export const Raw = (): ParameterDecorator => createParamDecorator(ParamType.RAW)(); + +function getObjectShape(schema: AnyZodObject): z.ZodRawShape { + return schema.shape; +} diff --git a/packages/protocols-rest/src/libs/decorators/ResponseSchema.ts b/packages/protocols-rest/src/libs/decorators/ResponseSchema.ts index 714ffd9ff..b5a26c71f 100644 --- a/packages/protocols-rest/src/libs/decorators/ResponseSchema.ts +++ b/packages/protocols-rest/src/libs/decorators/ResponseSchema.ts @@ -1,8 +1,19 @@ import "reflect-metadata"; import type { z } from "zod"; import { RESPONSE_SCHEMA_KEY } from "../constants"; +import { hasRouteResponseContract, type RouteContractWithResponse } from "../types/RouteContract"; + +export function ResponseSchema( + contract: TContract, +): MethodDecorator; +export function ResponseSchema(schema: z.ZodType): MethodDecorator; +export function ResponseSchema( + schemaOrContract: z.ZodType | RouteContractWithResponse, +): MethodDecorator { + const schema = hasRouteResponseContract(schemaOrContract) + ? schemaOrContract.response + : schemaOrContract; -export function ResponseSchema(schema: z.ZodType): MethodDecorator { return (target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { Reflect.defineMetadata(RESPONSE_SCHEMA_KEY, schema, target.constructor, propertyKey); diff --git a/packages/protocols-rest/src/libs/internal/routeContractProblemMetadata.ts b/packages/protocols-rest/src/libs/internal/routeContractProblemMetadata.ts index 66f4f81cf..5db7645ac 100644 --- a/packages/protocols-rest/src/libs/internal/routeContractProblemMetadata.ts +++ b/packages/protocols-rest/src/libs/internal/routeContractProblemMetadata.ts @@ -1,10 +1,18 @@ -import type { ProblemResponseMetadata } from "../types"; +import type { ProblemCategory } from "@croco/problems-core"; export const ROUTE_CONTRACT_PROBLEMS_KEY = Symbol.for("croco:rest:routeContractProblems"); +type RouteContractProblemMetadata = { + readonly code: string; + readonly category: ProblemCategory; + readonly status: number; + readonly description?: string; + readonly type?: string; +}; + export function attachRouteContractProblems( response: TResponse, - problems: readonly ProblemResponseMetadata[], + problems: readonly RouteContractProblemMetadata[], ): TResponse { Object.defineProperty(response, ROUTE_CONTRACT_PROBLEMS_KEY, { enumerable: false, @@ -16,13 +24,13 @@ export function attachRouteContractProblems( export function getRouteContractProblems( response: object, -): readonly ProblemResponseMetadata[] | undefined { +): readonly RouteContractProblemMetadata[] | undefined { const value = Reflect.get(response, ROUTE_CONTRACT_PROBLEMS_KEY); - return Array.isArray(value) ? value.filter(isProblemResponseMetadata) : undefined; + return Array.isArray(value) ? value.filter(isRouteContractProblemMetadata) : undefined; } -function isProblemResponseMetadata(value: unknown): value is ProblemResponseMetadata { +function isRouteContractProblemMetadata(value: unknown): value is RouteContractProblemMetadata { return ( typeof value === "object" && value !== null && diff --git a/packages/protocols-rest/src/libs/types.ts b/packages/protocols-rest/src/libs/types.ts index dd30a9539..9d923d8e7 100644 --- a/packages/protocols-rest/src/libs/types.ts +++ b/packages/protocols-rest/src/libs/types.ts @@ -4,6 +4,7 @@ import type { HttpMethod, ParamType } from "./constants"; import type { ExceptionFilter } from "./interfaces/ExceptionFilter"; import type { Interceptor } from "./interfaces/Interceptor"; import type { PipeTransform } from "./interfaces/PipeTransform"; +import type { RouteContractSpec } from "./types/RouteContract"; export interface ControllerMetadata { path: string; @@ -15,6 +16,7 @@ export interface RouteMetadata { path: string; methodName: string | symbol; statusCode?: number; + contract?: RouteContractSpec; } export type ProblemResponseMetadata< diff --git a/packages/protocols-rest/src/libs/types/RouteContract.ts b/packages/protocols-rest/src/libs/types/RouteContract.ts index 46e45c021..4662e777d 100644 --- a/packages/protocols-rest/src/libs/types/RouteContract.ts +++ b/packages/protocols-rest/src/libs/types/RouteContract.ts @@ -1,11 +1,14 @@ -import { ProblemCategoryMapper, type Problem, type ProblemCategory } from "@croco/problems-core"; +import { type Problem, type ProblemCategory, ProblemCategoryMapper } from "@croco/problems-core"; import type { z } from "zod"; -import type { HttpMethod } from "../constants"; +import { HttpMethod } from "../constants"; import { attachRouteContractProblems } from "../internal/routeContractProblemMetadata"; -import type { ProblemResponseMetadata, ProblemResponseOptions } from "../types"; -type AnyZodObject = z.ZodObject; +type AnyZodObject = z.AnyZodObject; type EmptyObject = Record; +declare const noRouteParamsSchema: unique symbol; +type NoRouteParamsSchema = { + readonly [noRouteParamsSchema]: never; +}; type RouteContractTypeError = { readonly __routeContractError__: Message; }; @@ -54,7 +57,7 @@ export type RouteProblemDeclaration< readonly type?: string; }; -type RouteContractProblem = ProblemConstructor | RouteProblemDeclaration; +export type RouteContractProblem = ProblemConstructor | RouteProblemDeclaration; type RouteProblemCode = TProblem["code"] extends infer Code extends string ? Code : string; @@ -72,9 +75,11 @@ export type RouteContractSpec< | readonly RouteContractProblem[] | undefined, > = { + readonly id?: string; readonly method: Method; readonly path: Path; readonly operationId?: string; + readonly sourceLocation?: RouteContractSourceLocation; readonly params?: Params; readonly query?: Query; readonly body?: Body; @@ -82,6 +87,30 @@ export type RouteContractSpec< readonly problems?: Problems; }; +export type AnyRouteContractSpec = RouteContractSpec; + +export type RouteContractWithParams = RouteContractSpec & { + readonly params: AnyZodObject; +}; + +export type RouteContractWithQuery = RouteContractSpec & { + readonly query: AnyZodObject; +}; + +export type RouteContractWithBody = RouteContractSpec & { + readonly body: z.ZodType; +}; + +export type RouteContractWithResponse = RouteContractSpec & { + readonly response: z.ZodType; +}; + +export type RouteContractSourceLocation = { + readonly path: string; + readonly line?: number; + readonly column?: number; +}; + export type RoutePathParamName = string extends Path ? string : Path extends `${string}:${infer Token}/${infer Rest}` @@ -151,11 +180,59 @@ export type RouteQueryParam< > = RouteQuery[Name]; export function defineRouteContract( - contract: TContract & ValidateRouteContractPathParams, + contract: TContract & ValidateRouteContractPathParams>, ): TContract { return contract; } +export function isRouteContractSpec(value: unknown): value is AnyRouteContractSpec { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { + readonly body?: unknown; + readonly id?: unknown; + readonly method?: unknown; + readonly operationId?: unknown; + readonly params?: unknown; + readonly path?: unknown; + readonly problems?: unknown; + readonly query?: unknown; + readonly response?: unknown; + readonly sourceLocation?: unknown; + }; + + return ( + isHttpMethod(candidate.method) && + typeof candidate.path === "string" && + isOptionalString(candidate.id) && + isOptionalString(candidate.operationId) && + isOptionalRouteContractSourceLocation(candidate.sourceLocation) && + isOptionalZodObject(candidate.params) && + isOptionalZodObject(candidate.query) && + isOptionalZodType(candidate.body) && + isOptionalZodType(candidate.response) && + (candidate.problems === undefined || Array.isArray(candidate.problems)) + ); +} + +export function hasRouteParamsContract(value: unknown): value is RouteContractWithParams { + return isRouteContractSpec(value) && isZodObject(value.params); +} + +export function hasRouteQueryContract(value: unknown): value is RouteContractWithQuery { + return isRouteContractSpec(value) && isZodObject(value.query); +} + +export function hasRouteBodyContract(value: unknown): value is RouteContractWithBody { + return isRouteContractSpec(value) && isZodType(value.body); +} + +export function hasRouteResponseContract(value: unknown): value is RouteContractWithResponse { + return isRouteContractSpec(value) && isZodType(value.response); +} + export function defineRouteProblem< const TProblem extends Problem, const Code extends RouteProblemCode, @@ -198,6 +275,13 @@ export function routeParam< return name; } +export function routeParamSchema< + TContract extends RouteContractWithParams, + Name extends RoutePathParamName & keyof RoutePathParams & string, +>(contract: TContract, name: Name): z.ZodType[Name]> { + return getObjectShape(contract.params)[name] as z.ZodType[Name]>; +} + export function routeQueryParam< TContract extends RouteContractSpec, Name extends keyof RouteQuery & string, @@ -205,25 +289,32 @@ export function routeQueryParam< return name; } -export function routePathParamsSchema< - TContract extends RouteContractSpec & { params: AnyZodObject }, ->(contract: TContract): TContract["params"] { +export function routeQueryParamSchema< + TContract extends RouteContractWithQuery, + Name extends keyof RouteQuery & string, +>(contract: TContract, name: Name): z.ZodType[Name]> { + return getObjectShape(contract.query)[name] as z.ZodType[Name]>; +} + +export function routePathParamsSchema( + contract: TContract, +): TContract["params"] { return contract.params; } -export function routeQuerySchema( +export function routeQuerySchema( contract: TContract, ): TContract["query"] { return contract.query; } -export function routeBodySchema( +export function routeBodySchema( contract: TContract, ): TContract["body"] { return contract.body; } -export function routeResponseSchema( +export function routeResponseSchema( contract: TContract, ): TContract["response"] { return contract.response; @@ -234,13 +325,16 @@ type ValidateRouteContractPathParams = ? ContractPathParamError> : unknown; -type ContractParamsSchema = TContract extends { - readonly params: infer Params extends AnyZodObject; -} - ? Params - : undefined; +type ContractParamsSchema = "params" extends keyof TContract + ? TContract["params"] extends AnyZodObject + ? TContract["params"] + : NoRouteParamsSchema + : NoRouteParamsSchema; -type ContractPathParamError = +type ContractPathParamError< + Path extends string, + Params extends AnyZodObject | NoRouteParamsSchema, +> = MissingPathParamNames extends infer Missing extends string ? ExtraPathParamNames extends infer Extra extends string ? [Missing] extends [never] @@ -251,23 +345,83 @@ type ContractPathParamError = Exclude< - RoutePathParamName, - ZodObjectKey ->; +type MissingPathParamNames< + Path extends string, + Params extends AnyZodObject | NoRouteParamsSchema, +> = Exclude, ZodObjectKey>; -type ExtraPathParamNames = Exclude< - ZodObjectKey, - RoutePathParamName ->; +type ExtraPathParamNames< + Path extends string, + Params extends AnyZodObject | NoRouteParamsSchema, +> = Exclude, RoutePathParamName>; -type ZodObjectKey = +type ZodObjectKey = Schema extends z.ZodObject ? Extract : never; type NormalizePathParamToken = Token extends `...${infer Name}` ? Name : Token; +function getObjectShape(schema: AnyZodObject): z.ZodRawShape { + return schema.shape; +} + +function isHttpMethod(value: unknown): value is HttpMethod { + return typeof value === "string" && HTTP_METHODS.has(value); +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === "string"; +} + +function isOptionalRouteContractSourceLocation( + value: unknown, +): value is RouteContractSourceLocation | undefined { + if (value === undefined) { + return true; + } + + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { + readonly column?: unknown; + readonly line?: unknown; + readonly path?: unknown; + }; + + return ( + typeof candidate.path === "string" && + (candidate.line === undefined || typeof candidate.line === "number") && + (candidate.column === undefined || typeof candidate.column === "number") + ); +} + +function isOptionalZodObject(value: unknown): value is AnyZodObject | undefined { + return value === undefined || isZodObject(value); +} + +function isOptionalZodType(value: unknown): value is z.ZodType | undefined { + return value === undefined || isZodType(value); +} + +function isZodObject(value: unknown): value is AnyZodObject { + return isZodType(value) && "shape" in value; +} + +function isZodType(value: unknown): value is z.ZodType { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { readonly safeParse?: unknown }; + + return typeof candidate.safeParse === "function"; +} + +const HTTP_METHODS = new Set(Object.values(HttpMethod)); + type RouteProblemResponses = { readonly [Index in keyof TProblems]: RouteProblemResponseFor; }; @@ -277,10 +431,35 @@ type RouteProblemResponseFor = TProble readonly category: infer Category extends ProblemCategory; readonly status: infer Status extends number; } - ? ProblemResponseOptions & ProblemResponseMetadata + ? RouteProblemResponseOptions & + RouteProblemResponseMetadata : never; -function toProblemResponseMetadata(problem: RouteProblemDeclaration): ProblemResponseMetadata { +type RouteProblemResponseMetadata< + Code extends string = string, + Category extends ProblemCategory = ProblemCategory, + Status extends number = number, +> = { + readonly code: Code; + readonly category: Category; + readonly status: Status; + readonly description?: string; + readonly type?: string; +}; + +type RouteProblemResponseOptions< + Code extends string = string, + Category extends ProblemCategory = ProblemCategory, + Status extends number = number, +> = { + readonly code: Code; + readonly category: Category; + readonly status?: Status; + readonly description?: string; + readonly type?: string; +}; + +function toProblemResponseMetadata(problem: RouteProblemDeclaration): RouteProblemResponseMetadata { return { code: problem.code, category: problem.category, diff --git a/packages/protocols-rest/src/libs/types/index.ts b/packages/protocols-rest/src/libs/types/index.ts index 0cfe163d7..626c97104 100644 --- a/packages/protocols-rest/src/libs/types/index.ts +++ b/packages/protocols-rest/src/libs/types/index.ts @@ -5,16 +5,21 @@ export * from "../types"; export { defineRouteProblem, defineRouteContract, + isRouteContractSpec, routeBodySchema, routeParam, + routeParamSchema, routePathParamsSchema, routeProblemResponses, routeQueryParam, + routeQueryParamSchema, routeQuerySchema, routeResponseSchema, } from "./RouteContract"; export type { + AnyRouteContractSpec, ProblemConstructor, + RouteContractProblem, RouteProblemDeclaration, RouteProblemStatus, RouteBody, @@ -22,6 +27,11 @@ export type { RouteContractRequest, RouteContractResult, RouteContractSpec, + RouteContractSourceLocation, + RouteContractWithBody, + RouteContractWithParams, + RouteContractWithQuery, + RouteContractWithResponse, RouteMethodReturn, RouteParam, RoutePathParamName, diff --git a/packages/protocols-rest/src/tests/RouteContractTypes.spec.ts b/packages/protocols-rest/src/tests/RouteContractTypes.spec.ts index 55bc1af07..4643b1ab9 100644 --- a/packages/protocols-rest/src/tests/RouteContractTypes.spec.ts +++ b/packages/protocols-rest/src/tests/RouteContractTypes.spec.ts @@ -3,8 +3,8 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; import { Body, - defineRouteProblem, defineRouteContract, + defineRouteProblem, Get, HttpMethod, Param, @@ -12,12 +12,6 @@ import { ProblemResponse, Query, ResponseSchema, - routeBodySchema, - routeParam, - routeProblemResponses, - routeQueryParam, - routeQuerySchema, - routeResponseSchema, type RouteBody, type RouteContractHandler, type RouteMethodReturn, @@ -27,6 +21,12 @@ import { type RouteQuery, type RouteQueryParam, type RouteResponse, + routeParam, + routeParamSchema, + routeProblemResponses, + routeQueryParam, + routeQuerySchema, + routeResponseSchema, } from "../index"; const userSchema = z.object({ @@ -59,8 +59,11 @@ class UserForbiddenProblem extends Problem { describe("route contract types", () => { const getUserContract = defineRouteContract({ + id: "users.get", method: HttpMethod.GET, path: "/users/:id", + operationId: "getUser", + sourceLocation: { path: "src/controllers/UserController.ts", line: 12 }, params: z.object({ id: z.string() }), query: userQuerySchema, response: userSchema, @@ -68,12 +71,20 @@ describe("route contract types", () => { }); const createUserContract = defineRouteContract({ + id: "users.create", method: HttpMethod.POST, path: "/users", body: createUserSchema, response: userSchema, }); + const listUsersContract = defineRouteContract({ + id: "users.list", + method: HttpMethod.GET, + path: "/users", + response: z.array(userSchema), + }); + const updateUserContract = defineRouteContract({ method: HttpMethod.POST, path: "/users/:id", @@ -89,20 +100,19 @@ describe("route contract types", () => { it("connects route schemas to controller decorator migration helpers", () => { class UsersController { - @Get(getUserContract.path) - @ResponseSchema(routeResponseSchema(getUserContract)) + @Get(getUserContract) getUser( - @Param(routeParam(getUserContract, "id")) id: RouteParam, - @Query(routeQueryParam(getUserContract, "includePosts")) + @Param(getUserContract, "id") id: RouteParam, + @Query(getUserContract, "includePosts") includePosts: RouteQueryParam, ): RouteMethodReturn { return { id, name: includePosts ? "Ada Lovelace" : "Ada" }; } - @Post(createUserContract.path) - @ResponseSchema(routeResponseSchema(createUserContract)) + @Post(createUserContract) + @ResponseSchema(createUserContract) createUser( - @Body(routeBodySchema(createUserContract)) body: RouteBody, + @Body(createUserContract) body: RouteBody, ): RouteMethodReturn { return { id: "user_1", name: body.name }; } @@ -112,9 +122,10 @@ describe("route contract types", () => { id: "user_1", name: "Ada Lovelace", }); + expect(routeParamSchema(getUserContract, "id")).toBe(getUserContract.params.shape.id); + expect(routeQueryParam(getUserContract, "includePosts")).toBe("includePosts"); expect(routeQuerySchema(getUserContract)).toBe(userQuerySchema); expect(routeResponseSchema(getUserContract)).toBe(userSchema); - expect(routeBodySchema(createUserContract)).toBe(createUserSchema); }); it("infers request, response, and Problem types from route contracts", async () => { @@ -126,6 +137,12 @@ describe("route contract types", () => { expectTypeOf>().toEqualTypeOf<{ name: string; }>(); + expectTypeOf>().toEqualTypeOf< + { + id: string; + name: string; + }[] + >(); expectTypeOf>().toEqualTypeOf<{ id: string; name: string; @@ -160,6 +177,16 @@ defineRouteContract({ params: z.object({ id: z.string() }), }); +const unionParamsSchema = + Math.random() > 0.5 ? z.object({ id: z.string() }) : z.object({ userId: z.string() }); + +// @ts-expect-error union params schemas still expose extra path params on paramless routes. +defineRouteContract({ + method: HttpMethod.GET, + path: "/users", + params: unionParamsSchema, +}); + const responseContract = defineRouteContract({ method: HttpMethod.GET, path: "/users/:id", @@ -167,6 +194,13 @@ const responseContract = defineRouteContract({ response: userSchema, }); +const postContractForNegativeTest = defineRouteContract({ + method: HttpMethod.POST, + path: "/users", + body: createUserSchema, + response: userSchema, +}); + // @ts-expect-error routeParam only accepts names declared by the route path and params schema. routeParam(responseContract, "userId"); @@ -178,6 +212,22 @@ const invalidResponseHandler: RouteContractHandler = () void invalidResponseHandler; +class InvalidMethodController { + // @ts-expect-error @Get cannot consume a POST route contract. + @Get(postContractForNegativeTest) + invalidMethod(): void {} +} + +class InvalidBodyController { + invalidBody( + // @ts-expect-error @Body(contract) requires a route contract with a body schema. + @Body(responseContract) _body: unknown, + ): void {} +} + +void InvalidMethodController; +void InvalidBodyController; + defineRouteProblem(UserForbiddenProblem, { // @ts-expect-error typed Problem helpers preserve the subclass literal code. code: "USER_NOT_FOUND", diff --git a/packages/protocols-rest/src/tests/decorators/Route.spec.ts b/packages/protocols-rest/src/tests/decorators/Route.spec.ts index de244a502..d95f9610a 100644 --- a/packages/protocols-rest/src/tests/decorators/Route.spec.ts +++ b/packages/protocols-rest/src/tests/decorators/Route.spec.ts @@ -1,7 +1,13 @@ import "reflect-metadata"; import { Problem, ProblemCategory } from "@croco/problems-core"; import { describe, expect, it } from "vitest"; -import { HttpMethod, PROBLEM_RESPONSES_KEY, REST_ROUTES_KEY } from "../../libs/constants"; +import { z } from "zod"; +import { + HttpMethod, + PROBLEM_RESPONSES_KEY, + RESPONSE_SCHEMA_KEY, + REST_ROUTES_KEY, +} from "../../libs/constants"; import { Controller } from "../../libs/decorators/Controller"; import { All, @@ -14,12 +20,13 @@ import { Put, } from "../../libs/decorators/HttpMethod"; import { ProblemResponse, ProblemResponses } from "../../libs/decorators/ProblemResponse"; +import { ResponseSchema } from "../../libs/decorators/ResponseSchema"; +import type { ProblemResponseMetadata, RouteMetadata } from "../../libs/types"; import { defineRouteContract, defineRouteProblem, routeProblemResponses, } from "../../libs/types/RouteContract"; -import type { ProblemResponseMetadata, RouteMetadata } from "../../libs/types"; class UserNotFoundProblem extends Problem { readonly code = "USER_NOT_FOUND"; @@ -67,6 +74,36 @@ describe("Route decorators", () => { const routes = Reflect.getMetadata(REST_ROUTES_KEY, RootController) as RouteMetadata[]; expect(routes[0].path).toBe(""); }); + + it("should register a typed route contract while keeping route metadata controller-relative", () => { + const userSchema = z.object({ id: z.string(), name: z.string() }); + const getUserContract = defineRouteContract({ + id: "users.get", + method: HttpMethod.GET, + path: "/users/:id", + operationId: "getUser", + sourceLocation: { path: "src/controllers/UserController.ts", line: 10 }, + params: z.object({ id: z.string() }), + response: userSchema, + }); + + @Controller("/users") + class UserController { + @Get(getUserContract) + @ResponseSchema(getUserContract) + getUser() {} + } + + const routes = Reflect.getMetadata(REST_ROUTES_KEY, UserController) as RouteMetadata[]; + + expect(routes[0]).toMatchObject({ + method: HttpMethod.GET, + path: "/:id", + methodName: "getUser", + contract: getUserContract, + }); + expect(Reflect.getMetadata(RESPONSE_SCHEMA_KEY, UserController, "getUser")).toBe(userSchema); + }); }); describe("@Post decorator", () => { diff --git a/packages/protocols-trpc/src/tests/createTrpcRouter.spec.ts b/packages/protocols-trpc/src/tests/createTrpcRouter.spec.ts index 0334b32dd..3f26b00a7 100644 --- a/packages/protocols-trpc/src/tests/createTrpcRouter.spec.ts +++ b/packages/protocols-trpc/src/tests/createTrpcRouter.spec.ts @@ -102,6 +102,7 @@ describe("createTrpcRouter", () => { methodName: "createUser", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: createUserSchema }], inputSchema: createUserSchema, inputSchemas: { body: createUserSchema, path: null, query: null, headers: null }, @@ -175,6 +176,7 @@ function extractTestRouteIR(controllerCtor: Function): RouteIR[] { methodName: String(routeMeta.methodName), httpMethod: routeMeta.method, path: `${controllerMeta.path}${routeMeta.path}`, + routeContract: null, params: [], inputSchema: null, inputSchemas: { body: null, path: null, query: null, headers: null }, diff --git a/packages/rpc-codegen/src/tests/codegen.spec.ts b/packages/rpc-codegen/src/tests/codegen.spec.ts index ba233087f..892664cc0 100644 --- a/packages/rpc-codegen/src/tests/codegen.spec.ts +++ b/packages/rpc-codegen/src/tests/codegen.spec.ts @@ -179,6 +179,7 @@ describe("generateClientFiles", () => { methodName: "list", httpMethod: "GET", path: "/users", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -237,6 +238,7 @@ describe("generateClientFiles", () => { httpMethod: "GET", path: "/users/:id", controllerPath: "/users", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -280,6 +282,7 @@ describe("generateClientFiles", () => { methodName: "createUser", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: BODY_INPUT_SCHEMAS, @@ -334,6 +337,7 @@ describe("generateClientFiles", () => { httpMethod: "GET", path: "/users/:id", controllerPath: "/users", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -351,6 +355,7 @@ describe("generateClientFiles", () => { httpMethod: "POST", path: "/users", controllerPath: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: BODY_INPUT_SCHEMAS, @@ -368,6 +373,7 @@ describe("generateClientFiles", () => { httpMethod: "DELETE", path: "/users/:id", controllerPath: "/users", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -399,6 +405,7 @@ describe("generateClientFiles", () => { methodName: "handleHook", httpMethod: "ALL", path: "/hooks/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -426,6 +433,7 @@ describe("generateClientFiles", () => { methodName: "createUser", httpMethod: "POST", path: "/users", + routeContract: null, params: [ { kind: "body", name: "", schema: bodySchema }, { kind: "body", name: "", schema: auditSchema }, @@ -450,6 +458,7 @@ describe("generateClientFiles", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -471,6 +480,7 @@ describe("generateClientFiles", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -492,6 +502,7 @@ describe("generateClientFiles", () => { methodName: "listUsers", httpMethod: "GET", path: "/users", + routeContract: null, params: [], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -513,6 +524,7 @@ describe("generateClientFiles", () => { methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: BODY_INPUT_SCHEMAS, @@ -530,6 +542,50 @@ describe("generateClientFiles", () => { ); }); + it("should generate clients from contract-first route IR", () => { + const createUserSchema = z.object({ name: z.string() }) as unknown as RouteIR["inputSchema"]; + const userSchema = z.object({ id: z.string(), name: z.string() }) as unknown as + | RouteIR["outputSchema"] + | NonNullable["outputSchema"]; + const inputSchemas: RouteIR["inputSchemas"] = { + body: createUserSchema, + path: null, + query: null, + headers: null, + }; + const routes: RouteIR[] = [ + { + controllerName: "UserController", + methodName: "create", + httpMethod: "POST", + path: "/users", + routeContract: { + id: "users.create", + method: "POST", + path: "/users", + operationId: "createUser", + inputSchemas, + outputSchema: userSchema, + problemResponses: [], + }, + params: [{ kind: "body", name: "", schema: createUserSchema }], + inputSchema: createUserSchema, + inputSchemas, + outputSchema: userSchema, + domain: null, + }, + ]; + + const files = generateClientFiles(routes, TEMP_DIR); + const content = fs.readFileSync(files[0], "utf-8"); + + expect(content).toContain("export type CreateInput = { name: string; };"); + expect(content).toContain("export type CreateOutput = { id: string; name: string; };"); + expect(content).toContain( + "create: (input: CreateInput): Promise => fetch('/users', { method: 'POST', body: JSON.stringify(input), headers: { 'Content-Type': 'application/json' } }).then((response) => handleJsonResponse(response)),", + ); + }); + it("should generate one file per controller domain", () => { const routes: RouteIR[] = [ { @@ -537,6 +593,7 @@ describe("generateClientFiles", () => { methodName: "list", httpMethod: "GET", path: "/users", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -548,6 +605,7 @@ describe("generateClientFiles", () => { methodName: "list", httpMethod: "GET", path: "/orders", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -578,6 +636,7 @@ describe("generateClientFiles", () => { methodName: "get", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -589,6 +648,7 @@ describe("generateClientFiles", () => { methodName: "getResult", httpMethod: "GET", path: "/users/:id/result", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -611,6 +671,7 @@ describe("generateClientFiles", () => { methodName: "get", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -624,6 +685,7 @@ describe("generateClientFiles", () => { methodName: "get", httpMethod: "GET", path: "/orders/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -648,6 +710,7 @@ describe("generateClientFiles", () => { methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: BODY_INPUT_SCHEMAS, @@ -680,6 +743,7 @@ describe("generateClientFiles", () => { methodName: "list", httpMethod: "GET", path: "/users", + routeContract: null, params: [{ kind: "query", name: "page", schema: null }], inputSchema: null, inputSchemas: QUERY_INPUT_SCHEMAS, @@ -691,6 +755,7 @@ describe("generateClientFiles", () => { methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: BODY_INPUT_SCHEMAS, @@ -738,6 +803,7 @@ void createInvalidationRouteId; methodName: "list", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [ { kind: "path", name: "id", schema: null }, { kind: "query", name: "page", schema: null }, @@ -812,6 +878,7 @@ void createInvalidationRouteId; methodName: "list", httpMethod: "GET", path: "/users", + routeContract: null, params: [{ kind: "query", name: "page", schema: null }], inputSchema: null, inputSchemas: QUERY_INPUT_SCHEMAS, @@ -833,6 +900,7 @@ void createInvalidationRouteId; methodName: "get", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -854,6 +922,7 @@ void createInvalidationRouteId; methodName: "get", httpMethod: "GET", path: "/users", + routeContract: null, params: [ { kind: "header", name: "authorization", schema: null }, { kind: "header", name: "x-tenant-id", schema: null }, @@ -880,6 +949,7 @@ void createInvalidationRouteId; methodName: "update", httpMethod: "PATCH", path: "/users/:id", + routeContract: null, params: [ { kind: "path", name: "id", schema: null }, { kind: "query", name: "filter", schema: null }, @@ -936,6 +1006,7 @@ void createInvalidationRouteId; methodName: "createUser", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: bodySchema }], inputSchema: bodySchema, inputSchemas: { @@ -968,6 +1039,7 @@ void createInvalidationRouteId; methodName: "get", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -994,6 +1066,7 @@ void createInvalidationRouteId; methodName: "get", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -1110,6 +1183,7 @@ void handleMissingProblemBranch; methodName: "list", httpMethod: "GET", path: "/users", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -1132,6 +1206,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/status", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -1160,6 +1235,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/status", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -1188,6 +1264,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/status", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -1211,6 +1288,7 @@ void handleMissingProblemBranch; methodName: "list", httpMethod: "GET", path: "/alpha", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -1222,6 +1300,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/zeta", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -1248,6 +1327,7 @@ void handleMissingProblemBranch; methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: { @@ -1274,6 +1354,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -1300,6 +1381,7 @@ void handleMissingProblemBranch; methodName: "compare", httpMethod: "GET", path: "/pairs/:id/:id2", + routeContract: null, params: [ { kind: "path", name: "id", schema: null }, { kind: "path", name: "id2", schema: null }, @@ -1335,6 +1417,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/users/:user-id", + routeContract: null, params: [{ kind: "path", name: "user-id", schema: null }], inputSchema: null, inputSchemas: { @@ -1365,6 +1448,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/assets/:...id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: PATH_INPUT_SCHEMAS, @@ -1388,6 +1472,7 @@ void handleMissingProblemBranch; methodName: "list", httpMethod: "GET", path: "/users", + routeContract: null, params: [{ kind: "query", name: "page", schema: null }], inputSchema: null, inputSchemas: QUERY_INPUT_SCHEMAS, @@ -1422,6 +1507,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/users", + routeContract: null, params: [ { kind: "header", name: "authorization", schema: null }, { kind: "header", name: "x-tenant-id", schema: null }, @@ -1452,6 +1538,7 @@ void handleMissingProblemBranch; methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [ { kind: "body", name: "", schema: null }, { kind: "header", name: "x-request-id", schema: null }, @@ -1483,6 +1570,7 @@ void handleMissingProblemBranch; methodName: "get", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [ { kind: "path", name: "id", schema: null }, { kind: "query", name: "page", schema: null }, @@ -1508,6 +1596,7 @@ void handleMissingProblemBranch; methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [ { kind: "body", name: "", schema: null }, { kind: "header", name: "x-request-id", schema: null }, @@ -1631,6 +1720,7 @@ void createResultHook; methodName: "list", httpMethod: "GET", path: "/users", + routeContract: null, params: [ { kind: "query", name: "page", schema: null }, { kind: "query", name: "active", schema: null }, @@ -1677,6 +1767,7 @@ void resultBranch; methodName: "get", httpMethod: "GET", path: "/users", + routeContract: null, params: [ { kind: "header", name: "authorization", schema: null }, { kind: "header", name: "x-tenant-id", schema: null }, @@ -1710,6 +1801,7 @@ void result; methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: { @@ -1821,6 +1913,7 @@ void submitCreateForm; methodName: "update", httpMethod: "PUT", path: "/users/:id", + routeContract: null, params: [ { kind: "path", name: "id", schema: null }, { kind: "body", name: "", schema: null }, @@ -1865,6 +1958,7 @@ void updateResult; methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: { @@ -1901,6 +1995,7 @@ void updateResult; methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: { @@ -1946,6 +2041,7 @@ void updateResult; methodName: "create", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: { @@ -1979,6 +2075,7 @@ void updateResult; methodName: "update", httpMethod: "PATCH", path: "/users/:id", + routeContract: null, params: [ { kind: "path", name: "id", schema: null }, { kind: "query", name: "filter", schema: null }, diff --git a/packages/rpc-codegen/src/tests/round-trip.spec.ts b/packages/rpc-codegen/src/tests/round-trip.spec.ts index b280a12d2..28bb27e11 100644 --- a/packages/rpc-codegen/src/tests/round-trip.spec.ts +++ b/packages/rpc-codegen/src/tests/round-trip.spec.ts @@ -68,6 +68,7 @@ describe("rpc-codegen round trip", () => { methodName: "listUsers", httpMethod: "GET", path: "/users", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -79,6 +80,7 @@ describe("rpc-codegen round trip", () => { methodName: "createUser", httpMethod: "POST", path: "/users", + routeContract: null, params: [{ kind: "body", name: "", schema: null }], inputSchema: null, inputSchemas: BODY_INPUT_SCHEMAS, @@ -90,6 +92,7 @@ describe("rpc-codegen round trip", () => { methodName: "listOrders", httpMethod: "GET", path: "/orders", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -164,6 +167,7 @@ describe("rpc-codegen round trip", () => { methodName: "health", httpMethod: "GET", path: "/health", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -175,6 +179,7 @@ describe("rpc-codegen round trip", () => { methodName: "clear", httpMethod: "POST", path: "/health/cache", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -186,6 +191,7 @@ describe("rpc-codegen round trip", () => { methodName: "fail", httpMethod: "GET", path: "/health/fail", + routeContract: null, params: [], inputSchema: null, inputSchemas: EMPTY_INPUT_SCHEMAS, @@ -235,6 +241,7 @@ describe("rpc-codegen round trip", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [ { kind: "path", name: "id", schema: null }, { kind: "query", name: "includePosts", schema: null }, @@ -278,6 +285,7 @@ describe("rpc-codegen round trip", () => { methodName: "getCurrentUser", httpMethod: "GET", path: "/me", + routeContract: null, params: [ { kind: "header", name: "authorization", schema: null }, { kind: "header", name: "x-request-id", schema: null }, @@ -314,6 +322,7 @@ describe("rpc-codegen round trip", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: { @@ -347,6 +356,7 @@ describe("rpc-codegen round trip", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: { @@ -396,6 +406,7 @@ describe("rpc-codegen round trip", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: { @@ -456,6 +467,7 @@ describe("rpc-codegen round trip", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: { @@ -515,6 +527,7 @@ describe("rpc-codegen round trip", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: { @@ -554,6 +567,7 @@ describe("rpc-codegen round trip", () => { methodName: "getUser", httpMethod: "GET", path: "/users/:id", + routeContract: null, params: [{ kind: "path", name: "id", schema: null }], inputSchema: null, inputSchemas: { diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 8e19aff40..195624008 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -11555,6 +11555,11 @@ "exportKind": "named", "source": "./libs/ContractGraph" }, + { + "name": "ContractDiagnosticSourceLocation", + "exportKind": "named", + "source": "./libs/ContractGraph" + }, { "name": "ContractDiagnosticTarget", "exportKind": "named", @@ -11675,6 +11680,11 @@ "exportKind": "named", "source": "./libs/ContractGraphSnapshot" }, + { + "name": "ContractGraphSnapshotRouteContract", + "exportKind": "named", + "source": "./libs/ContractGraphSnapshot" + }, { "name": "ContractGraphSnapshotVersion", "exportKind": "named", @@ -11785,6 +11795,16 @@ "exportKind": "named", "source": "./libs/RouteIR" }, + { + "name": "RouteContractIR", + "exportKind": "named", + "source": "./libs/RouteIR" + }, + { + "name": "RouteContractSourceLocation", + "exportKind": "named", + "source": "./libs/RouteIR" + }, { "name": "RouteIR", "exportKind": "named", @@ -12166,7 +12186,7 @@ "name": "Body", "exportKind": "named", "source": "./Params", - "declarationKind": "const" + "declarationKind": "function" }, { "name": "Controller", @@ -12293,6 +12313,12 @@ "source": "./MetadataReader", "declarationKind": "function" }, + { + "name": "isRouteContractSpec", + "exportKind": "named", + "source": "./RouteContract", + "declarationKind": "function" + }, { "name": "LoggingInterceptor", "exportKind": "named", @@ -12309,7 +12335,7 @@ "name": "Param", "exportKind": "named", "source": "./Params", - "declarationKind": "const" + "declarationKind": "function" }, { "name": "ParamType", @@ -12357,7 +12383,7 @@ "name": "Query", "exportKind": "named", "source": "./Params", - "declarationKind": "const" + "declarationKind": "function" }, { "name": "Raw", @@ -12461,6 +12487,12 @@ "source": "./RouteContract", "declarationKind": "function" }, + { + "name": "routeParamSchema", + "exportKind": "named", + "source": "./RouteContract", + "declarationKind": "function" + }, { "name": "routePathParamsSchema", "exportKind": "named", @@ -12479,6 +12511,12 @@ "source": "./RouteContract", "declarationKind": "function" }, + { + "name": "routeQueryParamSchema", + "exportKind": "named", + "source": "./RouteContract", + "declarationKind": "function" + }, { "name": "routeQuerySchema", "exportKind": "named", @@ -12619,6 +12657,11 @@ "source": "../types", "declarationKind": "interface" }, + { + "name": "AnyRouteContractSpec", + "exportKind": "named", + "source": "./RouteContract" + }, { "name": "ApiEndpoint", "exportKind": "named", @@ -12740,6 +12783,11 @@ "exportKind": "named", "source": "./RouteContract" }, + { + "name": "RouteContractProblem", + "exportKind": "named", + "source": "./RouteContract" + }, { "name": "RouteContractRequest", "exportKind": "named", @@ -12750,11 +12798,36 @@ "exportKind": "named", "source": "./RouteContract" }, + { + "name": "RouteContractSourceLocation", + "exportKind": "named", + "source": "./RouteContract" + }, { "name": "RouteContractSpec", "exportKind": "named", "source": "./RouteContract" }, + { + "name": "RouteContractWithBody", + "exportKind": "named", + "source": "./RouteContract" + }, + { + "name": "RouteContractWithParams", + "exportKind": "named", + "source": "./RouteContract" + }, + { + "name": "RouteContractWithQuery", + "exportKind": "named", + "source": "./RouteContract" + }, + { + "name": "RouteContractWithResponse", + "exportKind": "named", + "source": "./RouteContract" + }, { "name": "RouteHandler", "exportKind": "named",