From 9c810dabdcc6cc00e73fc2450d80b77baa8371b9 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 20 Jun 2026 00:44:14 +0900 Subject: [PATCH 1/2] fix: promote REST route contracts to controller decorators --- .changeset/contract-first-rest-routes.md | 7 + .../src/tests/templates-build.spec.ts | 14 +- .../templates/spa-be-split/README.md.hbs | 4 +- .../src/controllers/UserController.ts | 53 ++-- .../api-server/src/controllers/userSchemas.ts | 46 ++++ .../api/protocols-rest/src/functions/Body.md | 30 ++- .../api/protocols-rest/src/functions/Param.md | 40 ++- .../api/protocols-rest/src/functions/Query.md | 40 ++- .../src/functions/ResponseSchema.md | 28 ++- .../src/functions/defineRouteContract.md | 2 +- .../src/functions/isRouteContractSpec.md | 18 ++ .../src/functions/routeParamSchema.md | 32 +++ .../src/functions/routeQueryParamSchema.md | 32 +++ .../src/interfaces/RouteMetadata.md | 6 + .../RouteContractSourceLocation.md | 26 ++ .../src/type-aliases/RouteContractSpec.md | 12 + .../api/protocols-rest/src/variables/All.md | 12 +- .../protocols-rest/src/variables/Delete.md | 12 +- .../api/protocols-rest/src/variables/Get.md | 12 +- .../api/protocols-rest/src/variables/Head.md | 12 +- .../protocols-rest/src/variables/Options.md | 12 +- .../api/protocols-rest/src/variables/Patch.md | 12 +- .../api/protocols-rest/src/variables/Post.md | 12 +- .../api/protocols-rest/src/variables/Put.md | 12 +- .../docs/en/guides/schema-source-of-truth.mdx | 86 +++++-- .../src/tests/emitOpenAPI.spec.ts | 69 +++++ packages/protocols-core/src/index.ts | 10 +- .../protocols-core/src/libs/ContractGraph.ts | 235 +++++++++++++++++- .../src/libs/ContractGraphSnapshot.ts | 26 ++ packages/protocols-core/src/libs/RouteIR.ts | 17 ++ .../protocols-core/src/libs/extractRouteIR.ts | 62 ++++- .../protocols-core/src/libs/sharedTypes.ts | 20 ++ .../src/tests/ContractGraph.spec.ts | 73 ++++++ .../src/tests/extractRouteIR.spec.ts | 70 ++++++ packages/protocols-rest/README.md | 20 +- .../src/libs/decorators/Controller.ts | 44 +++- .../src/libs/decorators/HttpMethod.ts | 19 +- .../src/libs/decorators/Params.ts | 62 ++++- .../src/libs/decorators/ResponseSchema.ts | 13 +- packages/protocols-rest/src/libs/types.ts | 2 + .../src/libs/types/RouteContract.ts | 46 +++- .../protocols-rest/src/libs/types/index.ts | 4 + .../src/tests/RouteContractTypes.spec.ts | 72 ++++-- .../src/tests/decorators/Route.spec.ts | 40 ++- .../rpc-codegen/src/tests/codegen.spec.ts | 43 ++++ public-api-surface.snapshot.json | 49 +++- 46 files changed, 1355 insertions(+), 213 deletions(-) create mode 100644 .changeset/contract-first-rest-routes.md create mode 100644 packages/docs/src/content/docs/api/protocols-rest/src/functions/isRouteContractSpec.md create mode 100644 packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParamSchema.md create mode 100644 packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParamSchema.md create mode 100644 packages/docs/src/content/docs/api/protocols-rest/src/type-aliases/RouteContractSourceLocation.md 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/create-croco-app/src/tests/templates-build.spec.ts b/packages/create-croco-app/src/tests/templates-build.spec.ts index 810ffbddc..51f230215 100644 --- a/packages/create-croco-app/src/tests/templates-build.spec.ts +++ b/packages/create-croco-app/src/tests/templates-build.spec.ts @@ -208,12 +208,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 65401085a..60f84dd32 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 @@ -53,7 +53,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"; @@ -76,7 +76,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/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..065dc0033 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" --- +## Call Signature + +> **Body**\<`TContract`\>(`contract`): `ParameterDecorator` + +요청 본문 전체를 메서드 인자에 바인딩합니다. + +### Type Parameters + +#### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `object` + +### Parameters + +#### contract + +`TContract` + +### Returns + +`ParameterDecorator` + +## Call Signature + > **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..d841d75f9 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" --- +## Call Signature + +> **Param**\<`TContract`, `Name`\>(`contract`, `name`): `ParameterDecorator` + +경로 파라미터를 메서드 인자에 바인딩합니다. + +### Type Parameters + +#### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `object` + +#### Name + +`Name` *extends* `string` + +### Parameters + +#### contract + +`TContract` + +#### name + +`Name` + +### Returns + +`ParameterDecorator` + +## Call Signature + > **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..f8530de53 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" --- +## Call Signature + +> **Query**\<`TContract`, `Name`\>(`contract`, `name`): `ParameterDecorator` + +쿼리스트링 값을 메서드 인자에 바인딩합니다. + +### Type Parameters + +#### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `object` + +#### Name + +`Name` *extends* `string` + +### Parameters + +#### contract + +`TContract` + +#### name + +`Name` + +### Returns + +`ParameterDecorator` + +## Call Signature + > **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..7b766cc15 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,36 @@ prev: false title: "ResponseSchema" --- +## Call Signature + +> **ResponseSchema**\<`TContract`\>(`contract`): `MethodDecorator` + +### Type Parameters + +#### TContract + +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `object` + +### Parameters + +#### contract + +`TContract` + +### Returns + +`MethodDecorator` + +## Call Signature + > **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 5db3380d7..8061ee74f 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 @@ -17,7 +17,7 @@ title: "defineRouteContract" ### 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..d9e5f6973 --- /dev/null +++ b/packages/docs/src/content/docs/api/protocols-rest/src/functions/isRouteContractSpec.md @@ -0,0 +1,18 @@ +--- +editUrl: false +next: false +prev: false +title: "isRouteContractSpec" +--- + +> **isRouteContractSpec**(`value`): value is RouteContractSpec\ \| undefined, ZodType\ \| undefined, readonly ProblemConstructor\\[\] \| undefined\> + +## Parameters + +### value + +`unknown` + +## Returns + +value is RouteContractSpec\ \| undefined, ZodType\ \| undefined, readonly ProblemConstructor\\[\] \| undefined\> 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..28f59ddc3 --- /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/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly [`ProblemConstructor`](/api/protocols-rest/src/type-aliases/problemconstructor/)\<[`Problem`](/api/problems-core/src/classes/problem/)\>[] \| `undefined`\> & `object` + +### Name + +`Name` *extends* `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/routeQueryParamSchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeQueryParamSchema.md new file mode 100644 index 000000000..16ab57a12 --- /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/)\<[`HttpMethod`](/api/protocols-rest/src/enumerations/httpmethod/), `string`, `AnyZodObject` \| `undefined`, `AnyZodObject` \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, `ZodType`\<`any`, `ZodTypeDef`, `any`\> \| `undefined`, readonly [`ProblemConstructor`](/api/protocols-rest/src/type-aliases/problemconstructor/)\<[`Problem`](/api/problems-core/src/classes/problem/)\>[] \| `undefined`\> & `object` + +### Name + +`Name` *extends* `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/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/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 acd137427..75c90225c 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 @@ -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/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/openapi-spec/src/tests/emitOpenAPI.spec.ts b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts index ef4361728..55cbae18d 100644 --- a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts +++ b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts @@ -9,9 +9,11 @@ import { All, Body, Controller, + defineRouteContract, defineRouteSchema, Get, Header, + HttpMethod, type InferRouteSchemaRequest, type InferRouteSchemaResponse, Param, @@ -19,6 +21,8 @@ import { ProblemResponse, Query, RequestValidationProblem, + type RouteBody, + type RouteMethodReturn, ResponseSchema, } from "@croco/protocols-rest"; import { Container } from "typedi"; @@ -305,6 +309,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 b04821baf..86635d11a 100644 --- a/packages/protocols-core/src/index.ts +++ b/packages/protocols-core/src/index.ts @@ -2,6 +2,7 @@ export type { ContractAccessMetadata, ContractDiagnostic, ContractDiagnosticSeverity, + ContractDiagnosticSourceLocation, ContractDiagnosticTarget, ContractGraph, ContractGraphController, @@ -40,6 +41,7 @@ export type { ContractGraphSnapshotController, ContractGraphSnapshotParam, ContractGraphSnapshotProblemResponse, + ContractGraphSnapshotRouteContract, ContractGraphSnapshotRoute, ContractGraphSnapshotVersion, ContractSchemaFieldSnapshot, @@ -57,5 +59,11 @@ 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 } from "./libs/sharedTypes"; diff --git a/packages/protocols-core/src/libs/ContractGraph.ts b/packages/protocols-core/src/libs/ContractGraph.ts index 18d7af4bc..44a6357db 100644 --- a/packages/protocols-core/src/libs/ContractGraph.ts +++ b/packages/protocols-core/src/libs/ContractGraph.ts @@ -21,9 +21,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 = { @@ -184,7 +192,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: [ @@ -230,12 +238,219 @@ function validateRoute(route: ContractGraphRoute): ContractDiagnostic[] { diagnostics.push(...validatePathParams(route)); diagnostics.push(...validateNamedParams(route)); diagnostics.push(...validateBodyParams(route)); + diagnostics.push(...validateRouteContract(route)); diagnostics.push(...validateSchemaEffects(route)); diagnostics.push(...validateProblemResponses(route)); 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(); @@ -461,6 +676,20 @@ function getRouteSchemas(route: ContractGraphRoute): z.ZodType[] { return [...new Set(schemas)]; } +function getNamedSchemaShape(schema: z.ZodType | null): Record { + const definition = schema ? getZodDefinition(schema) : undefined; + const shape = definition ? getZodObjectShape(definition) : {}; + const result: Record = {}; + + for (const [name, value] of Object.entries(shape)) { + if (isZodType(value)) { + result[name] = value; + } + } + + return result; +} + function isZodEffects(schema: z.ZodType): boolean { return schema.constructor.name === "ZodEffects"; } @@ -545,9 +774,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 0e89680b2..066ea28ba 100644 --- a/packages/protocols-core/src/libs/ContractGraphSnapshot.ts +++ b/packages/protocols-core/src/libs/ContractGraphSnapshot.ts @@ -51,6 +51,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 ContractGraphSnapshotRoute = { readonly routeId: string; readonly operationId: string; @@ -60,6 +72,7 @@ export type ContractGraphSnapshotRoute = { readonly path: string; readonly controllerPath: string; readonly domain: string | null; + readonly routeContract: ContractGraphSnapshotRouteContract | null; readonly access: ContractAccessMetadata; readonly params: readonly ContractGraphSnapshotParam[]; readonly request: { @@ -145,6 +158,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 acb6ec4f4..98eba67c5 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,22 @@ 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; +}; + +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 a5d8b2a18..459ba73ad 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,41 @@ 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 ?? contract.operationId ?? 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, + }; +} + +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 []; @@ -148,3 +196,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 e69c3cd05..e55287b9f 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() 키를 사용합니다. @@ -33,8 +34,27 @@ export interface RouteMetadata { path: string; methodName: string | symbol; statusCode?: number; + contract?: RouteContractMetadata; } +export type RouteContractSourceLocation = { + readonly path: string; + readonly line?: number; + readonly column?: number; +}; + +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; +}; + export type ProblemResponseMetadata = { readonly code: string; readonly category: ProblemCategory; diff --git a/packages/protocols-core/src/tests/ContractGraph.spec.ts b/packages/protocols-core/src/tests/ContractGraph.spec.ts index 4ff4bcb2d..df5299648 100644 --- a/packages/protocols-core/src/tests/ContractGraph.spec.ts +++ b/packages/protocols-core/src/tests/ContractGraph.spec.ts @@ -14,6 +14,7 @@ import { createContractGraphSnapshot, stringifyContractGraphSnapshot, } from "../libs/ContractGraphSnapshot"; +import { REST_ROUTES_KEY, type RouteMetadata } from "../libs/sharedTypes"; import { defineRouteSchema, type InferRouteSchemaRequest, @@ -156,6 +157,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 {}; @@ -876,3 +935,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 b89b5faaf..a8fdfc5e2 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, @@ -187,6 +188,61 @@ 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.ZodObject).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 set outputSchema to null when response schema metadata is missing", () => { @Controller("/users") class UsersController { @@ -232,3 +288,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..3d7574377 100644 --- a/packages/protocols-rest/src/libs/decorators/Params.ts +++ b/packages/protocols-rest/src/libs/decorators/Params.ts @@ -2,8 +2,17 @@ import "reflect-metadata"; import type { z } from "zod"; import { ParamType, REST_PARAMS_KEY } from "../constants"; import type { ParamMetadata } from "../types"; +import { + isRouteContractSpec, + type RouteContractSpec, + 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 +44,44 @@ function createParamDecorator(type: ParamType) { /** * 경로 파라미터를 메서드 인자에 바인딩합니다. */ -export const Param = (name: string, schema?: z.ZodType) => - createParamDecorator(ParamType.PARAM)(name, schema); +export function Param< + TContract extends RouteContractSpec & { params: AnyZodObject }, + 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 | (RouteContractSpec & { params: AnyZodObject }), + schemaOrName?: z.ZodType | string, +): ParameterDecorator { + if (isRouteContractSpec(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 RouteContractSpec & { query: AnyZodObject }, + Name extends keyof RouteQuery & string, +>(contract: TContract, name: Name): ParameterDecorator; +export function Query(name: string, schema?: z.ZodType): ParameterDecorator; +export function Query( + nameOrContract: string | (RouteContractSpec & { query: AnyZodObject }), + schemaOrName?: z.ZodType | string, +): ParameterDecorator { + if (isRouteContractSpec(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 +92,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 | (RouteContractSpec & { body: z.ZodType })) { + const schema = isRouteContractSpec(schemaOrContract) ? schemaOrContract.body : schemaOrContract; + + return createParamDecorator(ParamType.BODY)(undefined, schema); +} /** * 추상화된 HTTP 컨텍스트를 메서드 인자에 바인딩합니다. @@ -65,3 +111,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..afbb2ed50 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 { isRouteContractSpec, type RouteContractSpec } from "../types/RouteContract"; + +export function ResponseSchema( + contract: TContract, +): MethodDecorator; +export function ResponseSchema(schema: z.ZodType): MethodDecorator; +export function ResponseSchema( + schemaOrContract: z.ZodType | (RouteContractSpec & { response: z.ZodType }), +): MethodDecorator { + const schema = isRouteContractSpec(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/types.ts b/packages/protocols-rest/src/libs/types.ts index 77799bde0..af7ca45cb 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 f61b385d3..8b3c2c5dd 100644 --- a/packages/protocols-rest/src/libs/types/RouteContract.ts +++ b/packages/protocols-rest/src/libs/types/RouteContract.ts @@ -2,7 +2,7 @@ import type { Problem } from "@croco/problems-core"; import type { z } from "zod"; import type { HttpMethod } from "../constants"; -type AnyZodObject = z.ZodObject; +type AnyZodObject = z.AnyZodObject; type EmptyObject = Record; type RouteContractTypeError = { readonly __routeContractError__: Message; @@ -24,9 +24,11 @@ export type RouteContractSpec< | readonly ProblemConstructor[] | 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; @@ -34,6 +36,12 @@ export type RouteContractSpec< readonly problems?: Problems; }; +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}` @@ -99,11 +107,21 @@ export type RouteQueryParam< > = RouteQuery[Name]; export function defineRouteContract( - contract: TContract & ValidateRouteContractPathParams, + contract: TContract & ValidateRouteContractPathParams>, ): TContract { return contract; } +export function isRouteContractSpec(value: unknown): value is RouteContractSpec { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { readonly method?: unknown; readonly path?: unknown }; + + return typeof candidate.method === "string" && typeof candidate.path === "string"; +} + export function routeParam< TContract extends RouteContractSpec, Name extends RoutePathParamName & keyof RoutePathParams & string, @@ -111,6 +129,13 @@ export function routeParam< return name; } +export function routeParamSchema< + TContract extends RouteContractSpec & { params: AnyZodObject }, + 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, @@ -118,6 +143,13 @@ export function routeQueryParam< return name; } +export function routeQueryParamSchema< + TContract extends RouteContractSpec & { query: AnyZodObject }, + Name extends keyof RouteQuery & string, +>(contract: TContract, name: Name): z.ZodType[Name]> { + return getObjectShape(contract.query)[name] as z.ZodType[Name]>; +} + export function routePathParamsSchema< TContract extends RouteContractSpec & { params: AnyZodObject }, >(contract: TContract): TContract["params"] { @@ -148,9 +180,11 @@ type ValidateRouteContractPathParams = : unknown; type ContractParamsSchema = TContract extends { - readonly params: infer Params extends AnyZodObject; + readonly params: unknown; } - ? Params + ? TContract["params"] extends AnyZodObject + ? TContract["params"] + : undefined : undefined; type ContractPathParamError = @@ -180,3 +214,7 @@ type ZodObjectKey = type NormalizePathParamToken = Token extends `...${infer Name}` ? Name : Token; + +function getObjectShape(schema: AnyZodObject): z.ZodRawShape { + return schema.shape; +} diff --git a/packages/protocols-rest/src/libs/types/index.ts b/packages/protocols-rest/src/libs/types/index.ts index 8672cbdbe..fb67cfd8f 100644 --- a/packages/protocols-rest/src/libs/types/index.ts +++ b/packages/protocols-rest/src/libs/types/index.ts @@ -4,10 +4,13 @@ export * from "../types"; export { defineRouteContract, + isRouteContractSpec, routeBodySchema, routeParam, + routeParamSchema, routePathParamsSchema, routeQueryParam, + routeQueryParamSchema, routeQuerySchema, routeResponseSchema, } from "./RouteContract"; @@ -18,6 +21,7 @@ export type { RouteContractRequest, RouteContractResult, RouteContractSpec, + RouteContractSourceLocation, RouteMethodReturn, RouteParam, RoutePathParamName, diff --git a/packages/protocols-rest/src/tests/RouteContractTypes.spec.ts b/packages/protocols-rest/src/tests/RouteContractTypes.spec.ts index 4341b0ced..a821f86ef 100644 --- a/packages/protocols-rest/src/tests/RouteContractTypes.spec.ts +++ b/packages/protocols-rest/src/tests/RouteContractTypes.spec.ts @@ -10,11 +10,8 @@ import { Post, Query, ResponseSchema, - routeBodySchema, routeParam, - routeQueryParam, - routeQuerySchema, - routeResponseSchema, + routeParamSchema, type RouteBody, type RouteContractHandler, type RouteMethodReturn, @@ -47,8 +44,11 @@ class UserNotFoundProblem 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, @@ -56,28 +56,35 @@ 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), + }); + 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 }; } @@ -87,9 +94,7 @@ describe("route contract types", () => { id: "user_1", name: "Ada Lovelace", }); - expect(routeQuerySchema(getUserContract)).toBe(userQuerySchema); - expect(routeResponseSchema(getUserContract)).toBe(userSchema); - expect(routeBodySchema(createUserContract)).toBe(createUserSchema); + expect(routeParamSchema(getUserContract, "id")).toBe(getUserContract.params.shape.id); }); it("infers request, response, and Problem types from route contracts", async () => { @@ -101,6 +106,12 @@ describe("route contract types", () => { expectTypeOf>().toEqualTypeOf<{ name: string; }>(); + expectTypeOf>().toEqualTypeOf< + { + id: string; + name: string; + }[] + >(); expectTypeOf>().toEqualTypeOf<{ id: string; name: string; @@ -133,6 +144,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", @@ -140,6 +161,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"); @@ -150,3 +178,19 @@ 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; diff --git a/packages/protocols-rest/src/tests/decorators/Route.spec.ts b/packages/protocols-rest/src/tests/decorators/Route.spec.ts index 0d0b90abb..e3dcf09ec 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 { 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,6 +20,8 @@ import { Put, } from "../../libs/decorators/HttpMethod"; import { ProblemResponse, ProblemResponses } from "../../libs/decorators/ProblemResponse"; +import { ResponseSchema } from "../../libs/decorators/ResponseSchema"; +import { defineRouteContract } from "../../libs/types/RouteContract"; import type { ProblemResponseMetadata, RouteMetadata } from "../../libs/types"; describe("Route decorators", () => { @@ -53,6 +61,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/rpc-codegen/src/tests/codegen.spec.ts b/packages/rpc-codegen/src/tests/codegen.spec.ts index 54ce6d8bc..edfb9f8a1 100644 --- a/packages/rpc-codegen/src/tests/codegen.spec.ts +++ b/packages/rpc-codegen/src/tests/codegen.spec.ts @@ -261,6 +261,49 @@ 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, + }, + 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[] = [ { diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index fde5637b6..9f07e03b6 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -8119,6 +8119,11 @@ "exportKind": "named", "source": "./libs/ContractGraph" }, + { + "name": "ContractDiagnosticSourceLocation", + "exportKind": "named", + "source": "./libs/ContractGraph" + }, { "name": "ContractDiagnosticTarget", "exportKind": "named", @@ -8179,6 +8184,11 @@ "exportKind": "named", "source": "./libs/ContractGraphSnapshot" }, + { + "name": "ContractGraphSnapshotRouteContract", + "exportKind": "named", + "source": "./libs/ContractGraphSnapshot" + }, { "name": "ContractGraphSnapshotVersion", "exportKind": "named", @@ -8244,6 +8254,16 @@ "exportKind": "named", "source": "./libs/RouteIR" }, + { + "name": "RouteContractIR", + "exportKind": "named", + "source": "./libs/RouteIR" + }, + { + "name": "RouteContractSourceLocation", + "exportKind": "named", + "source": "./libs/RouteIR" + }, { "name": "RouteIR", "exportKind": "named", @@ -8625,7 +8645,7 @@ "name": "Body", "exportKind": "named", "source": "./Params", - "declarationKind": "const" + "declarationKind": "function" }, { "name": "Controller", @@ -8746,6 +8766,12 @@ "source": "./MetadataReader", "declarationKind": "function" }, + { + "name": "isRouteContractSpec", + "exportKind": "named", + "source": "./RouteContract", + "declarationKind": "function" + }, { "name": "LoggingInterceptor", "exportKind": "named", @@ -8762,7 +8788,7 @@ "name": "Param", "exportKind": "named", "source": "./Params", - "declarationKind": "const" + "declarationKind": "function" }, { "name": "ParamType", @@ -8810,7 +8836,7 @@ "name": "Query", "exportKind": "named", "source": "./Params", - "declarationKind": "const" + "declarationKind": "function" }, { "name": "Raw", @@ -8914,6 +8940,12 @@ "source": "./RouteContract", "declarationKind": "function" }, + { + "name": "routeParamSchema", + "exportKind": "named", + "source": "./RouteContract", + "declarationKind": "function" + }, { "name": "routePathParamsSchema", "exportKind": "named", @@ -8926,6 +8958,12 @@ "source": "./RouteContract", "declarationKind": "function" }, + { + "name": "routeQueryParamSchema", + "exportKind": "named", + "source": "./RouteContract", + "declarationKind": "function" + }, { "name": "routeQuerySchema", "exportKind": "named", @@ -9197,6 +9235,11 @@ "exportKind": "named", "source": "./RouteContract" }, + { + "name": "RouteContractSourceLocation", + "exportKind": "named", + "source": "./RouteContract" + }, { "name": "RouteContractSpec", "exportKind": "named", From 90ff67e4e59a6e7808f5a03bad1e971a225241c7 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 21 Jun 2026 15:54:42 +0900 Subject: [PATCH 2/2] fix: align REST contract API docs --- packages/cli/src/tests/projectMap.spec.ts | 6 +- .../docs/scripts/sanitize-typedoc-index.mjs | 171 ++++++++++++++++++ .../api/protocols-rest/src/functions/Body.md | 6 +- .../api/protocols-rest/src/functions/Param.md | 8 +- .../api/protocols-rest/src/functions/Query.md | 8 +- .../src/functions/ResponseSchema.md | 10 +- .../src/functions/isRouteContractSpec.md | 15 +- .../src/functions/routeParamSchema.md | 4 +- .../src/functions/routeQueryParamSchema.md | 4 +- .../src/type-aliases/RouteContractWithBody.md | 2 +- .../type-aliases/RouteContractWithParams.md | 2 +- .../type-aliases/RouteContractWithQuery.md | 2 +- .../type-aliases/RouteContractWithResponse.md | 2 +- 13 files changed, 212 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/tests/projectMap.spec.ts b/packages/cli/src/tests/projectMap.spec.ts index 27ea18ef8..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 () => { 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-rest/src/functions/Body.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/Body.md index 5a0b7a8ed..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,7 +5,7 @@ prev: false title: "Body" --- -## Call Signature +## Contract Overload > **Body**\<`TContract`\>(`contract`): `ParameterDecorator` @@ -15,7 +15,7 @@ title: "Body" #### TContract -`TContract` *extends* [`RouteContractWithBody`](/api/protocols-rest/src/type-aliases/routecontractwithbody/) +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly body: z.ZodType }` ### Parameters @@ -27,7 +27,7 @@ title: "Body" `ParameterDecorator` -## Call Signature +## Schema Overload > **Body**(`schema?`): `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 7b1239865..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,7 +5,7 @@ prev: false title: "Param" --- -## Call Signature +## Contract Overload > **Param**\<`TContract`, `Name`\>(`contract`, `name`): `ParameterDecorator` @@ -15,11 +15,11 @@ title: "Param" #### TContract -`TContract` *extends* [`RouteContractWithParams`](/api/protocols-rest/src/type-aliases/routecontractwithparams/) +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly params: AnyZodObject }` #### Name -`Name` *extends* `string` +`Name` *extends* [`RoutePathParamName`](/api/protocols-rest/src/type-aliases/routepathparamname/)\<`TContract`\[`"path"`\]\> & keyof [`RoutePathParams`](/api/protocols-rest/src/type-aliases/routepathparams/)\<`TContract`\> & `string` ### Parameters @@ -35,7 +35,7 @@ title: "Param" `ParameterDecorator` -## Call Signature +## Schema Overload > **Param**(`name`, `schema?`): `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 846fe9cf4..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,7 +5,7 @@ prev: false title: "Query" --- -## Call Signature +## Contract Overload > **Query**\<`TContract`, `Name`\>(`contract`, `name`): `ParameterDecorator` @@ -15,11 +15,11 @@ title: "Query" #### TContract -`TContract` *extends* [`RouteContractWithQuery`](/api/protocols-rest/src/type-aliases/routecontractwithquery/) +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly query: AnyZodObject }` #### Name -`Name` *extends* `string` +`Name` *extends* keyof [`RouteQuery`](/api/protocols-rest/src/type-aliases/routequery/)\<`TContract`\> & `string` ### Parameters @@ -35,7 +35,7 @@ title: "Query" `ParameterDecorator` -## Call Signature +## Schema Overload > **Query**(`name`, `schema?`): `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 3edb31550..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,15 +5,17 @@ prev: false title: "ResponseSchema" --- -## Call Signature +## Contract Overload > **ResponseSchema**\<`TContract`\>(`contract`): `MethodDecorator` +응답 스키마를 메서드에 바인딩합니다. + ### Type Parameters #### TContract -`TContract` *extends* [`RouteContractWithResponse`](/api/protocols-rest/src/type-aliases/routecontractwithresponse/) +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly response: z.ZodType }` ### Parameters @@ -25,10 +27,12 @@ title: "ResponseSchema" `MethodDecorator` -## Call Signature +## Schema Overload > **ResponseSchema**(`schema`): `MethodDecorator` +응답 스키마를 메서드에 바인딩합니다. + ### Parameters #### schema 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 index 2870de38f..287144c4c 100644 --- 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 @@ -5,7 +5,9 @@ prev: false title: "isRouteContractSpec" --- -> **isRouteContractSpec**(`value`): `value is AnyRouteContractSpec` +> **isRouteContractSpec**(`value`): `value is RouteContractSpec` + +Route contract decorator overloads use this guard to distinguish contract objects from direct schema arguments at runtime. ## Parameters @@ -15,4 +17,13 @@ title: "isRouteContractSpec" ## Returns -`value is AnyRouteContractSpec` +`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/routeParamSchema.md b/packages/docs/src/content/docs/api/protocols-rest/src/functions/routeParamSchema.md index 6b0925e8f..1f1f73472 100644 --- 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 @@ -11,11 +11,11 @@ title: "routeParamSchema" ### TContract -`TContract` *extends* [`RouteContractWithParams`](/api/protocols-rest/src/type-aliases/routecontractwithparams/) +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly params: AnyZodObject }` ### Name -`Name` *extends* `string` +`Name` *extends* [`RoutePathParamName`](/api/protocols-rest/src/type-aliases/routepathparamname/)\<`TContract`\[`"path"`\]\> & keyof [`RoutePathParams`](/api/protocols-rest/src/type-aliases/routepathparams/)\<`TContract`\> & `string` ## Parameters 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 index b5f4e8dfe..734881387 100644 --- 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 @@ -11,11 +11,11 @@ title: "routeQueryParamSchema" ### TContract -`TContract` *extends* [`RouteContractWithQuery`](/api/protocols-rest/src/type-aliases/routecontractwithquery/) +`TContract` *extends* [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly query: AnyZodObject }` ### Name -`Name` *extends* `string` +`Name` *extends* keyof [`RouteQuery`](/api/protocols-rest/src/type-aliases/routequery/)\<`TContract`\> & `string` ## Parameters 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 index 6724593f7..8f91381db 100644 --- 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 @@ -5,7 +5,7 @@ prev: false title: "RouteContractWithBody" --- -> **RouteContractWithBody** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `object` +> **RouteContractWithBody** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly body: z.ZodType }` ## Type Declaration 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 index 49247b9fa..cdff7a618 100644 --- 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 @@ -5,7 +5,7 @@ prev: false title: "RouteContractWithParams" --- -> **RouteContractWithParams** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `object` +> **RouteContractWithParams** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly params: AnyZodObject }` ## Type Declaration 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 index fe6f943da..7335225c6 100644 --- 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 @@ -5,7 +5,7 @@ prev: false title: "RouteContractWithQuery" --- -> **RouteContractWithQuery** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `object` +> **RouteContractWithQuery** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly query: AnyZodObject }` ## Type Declaration 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 index db0024fa9..dc670a404 100644 --- 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 @@ -5,7 +5,7 @@ prev: false title: "RouteContractWithResponse" --- -> **RouteContractWithResponse** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `object` +> **RouteContractWithResponse** = [`RouteContractSpec`](/api/protocols-rest/src/type-aliases/routecontractspec/) & `{ readonly response: z.ZodType }` ## Type Declaration