Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/contract-first-rest-routes.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/cli/src/tests/contractsCheck.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ function createGraph(diagnostics: ContractDiagnostic[] = []): ContractGraph {
inputSchema: null,
inputSchemas: { body: null, path: null, query: null, headers: null },
outputSchema: null,
routeContract: null,
domain: null,
},
],
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/tests/contractsDiff.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ function createGraph(
inputSchema: null,
inputSchemas: { body: null, path: null, query: null, headers: null },
outputSchema: null,
routeContract: null,
domain: null,
};
});
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/tests/projectMap.spec.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -418,7 +416,9 @@ function createContractSnapshot(diagnostics: ContractDiagnostic[] = []): Contrac
path: "/users",
controllerPath: "/users",
domain: "users",
routeContract: null,
access: { guards: [], roles: [] },
entitlements: [],
params: [],
request: { body: null, path: null, query: null, headers: null },
response: null,
Expand Down
14 changes: 12 additions & 2 deletions packages/create-croco-app/src/tests/templates-build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,22 @@ function checkSpaBeSplitStructure() {
checkFileContains(
"spa-be-split",
["apps", "api-server", "src", "controllers", "UserController.ts"],
/@ResponseSchema/,
/@Get\(getUserRoute\)/,
);
checkFileContains(
"spa-be-split",
["apps", "api-server", "src", "controllers", "UserController.ts"],
/@Body\(createUserInputSchema\)/,
/@Body\(createUserRoute\)/,
);
checkFileContains(
"spa-be-split",
["apps", "api-server", "src", "controllers", "UserController.ts"],
/Promise<RouteResponse<typeof listUsersRoute>>/,
);
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"], /운영형 앱 스타터/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Artifact 책임은 다음과 같습니다.

`contract:openapi`는 `apps/api-server/src/{controllers/**/*.ts,users.ts,problems.ts}`에서 REST 컨트롤러 메타데이터를 읽어 `openapi.json`을 생성합니다. `contract:client`는 같은 컨트롤러 계약에서 React Query hook을 포함한 fetch 클라이언트를 `libs/shared/provider-rpc/src`에 생성합니다. `codegen`은 기존 사용자를 위한 `contract:client` 별칭입니다.

`contract:client`는 생성 직후 `{{scope}}/provider-rpc`를 typecheck합니다. 예를 들어 `UserController`의 `@Get("/:id")`, `@Param("id", userIdSchema)`, `@Body(createUserInputSchema)`, `@ResponseSchema(userSchema)` 계약은 다음처럼 `userClient` 타입으로 이어집니다.
`contract:client`는 생성 직후 `{{scope}}/provider-rpc`를 typecheck합니다. 예를 들어 `UserController`의 `@Get(getUserRoute)`, `@Param(getUserRoute, "id")`, `@Post(createUserRoute)`, `@Body(createUserRoute)` 계약은 다음처럼 `userClient` 타입으로 이어집니다.

```ts
import { userClient, RpcClientProblemError } from "{{scope}}/provider-rpc";
Expand All @@ -77,7 +77,7 @@ function readProblemCode(error: unknown): string | null {
}
```

성공 응답 타입은 서버의 `@ResponseSchema`에서 생성되고, RFC 7807 Problem 응답은 성공 값으로 섞이지 않고 `RpcClientProblemError`로 보존됩니다. `rpc-codegen`이 JSON-safe TypeScript 타입으로 표현할 수 없는 Zod schema를 만나면 `unknown` fallback으로 숨기지 않고 생성 단계에서 실패합니다.
성공 응답 타입은 서버의 `RouteContract.response`에서 생성되고, RFC 7807 Problem 응답은 성공 값으로 섞이지 않고 `RpcClientProblemError`로 보존됩니다. `rpc-codegen`이 JSON-safe TypeScript 타입으로 표현할 수 없는 Zod schema를 만나면 `unknown` fallback으로 숨기지 않고 생성 단계에서 실패합니다.
도메인별 생성 타입과 React Query hook은 package root의 namespace export(예: `userRpc.useGetById`)로도 사용할 수 있습니다.

## 구조
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyArray<User>> {
return await getUserService().list();
@Get(listUsersRoute)
async list(): Promise<RouteResponse<typeof listUsersRoute>> {
return [...(await getUserService().list())];
}

@Get("/:id")
@ResponseSchema(userSchema)
async getById(@Param("id", userIdSchema) id: string): Promise<User> {
@Get(getUserRoute)
async getById(
@Param(getUserRoute, "id") id: RouteParam<typeof getUserRoute, "id">,
): Promise<RouteResponse<typeof getUserRoute>> {
return await getUserService().getById(id);
}

@Post()
@ResponseSchema(userSchema)
async create(@Body(createUserInputSchema) input: CreateUserInput): Promise<User> {
@Post(createUserRoute)
async create(
@Body(createUserRoute) input: RouteBody<typeof createUserRoute>,
): Promise<RouteResponse<typeof createUserRoute>> {
return await getUserService().create(input);
}

@Put("/:id")
@ResponseSchema(userSchema)
@Put(updateUserRoute)
async update(
@Param("id", userIdSchema) id: string,
@Body(createUserInputSchema) input: CreateUserInput,
): Promise<User> {
@Param(updateUserRoute, "id") id: RouteParam<typeof updateUserRoute, "id">,
@Body(updateUserRoute) input: RouteBody<typeof updateUserRoute>,
): Promise<RouteResponse<typeof updateUserRoute>> {
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<typeof deleteUserRoute, "id">,
): Promise<RouteResponse<typeof deleteUserRoute>> {
return await getUserService().delete(id);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defineRouteContract, HttpMethod } from "@croco/protocols-rest";
import { z } from "zod";

export const userIdSchema = z.string();
Expand All @@ -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<typeof userSchema>;
export type CreateUserInput = z.infer<typeof createUserInputSchema>;
Loading
Loading