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
10 changes: 10 additions & 0 deletions .changeset/contract-graph-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@croco/protocols-core": patch
"@croco/transports-http": patch
"@croco/openapi-spec": patch
"@croco/rpc-codegen": patch
"@croco/cli": patch
"create-croco-app": patch
---

Expose a canonical REST contract graph with route diagnostics and add a contract check path before OpenAPI and RPC client generation.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.3.5",
"typedi": "0.10.0",
"vitest": "^4.0.0"
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/bin/croco.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { defineCommand, runMain } from "citty";
import { codegen } from "../commands/codegen.js";
import { contracts } from "../commands/contracts.js";
import { create } from "../commands/create.js";
import { generate } from "../commands/generate.js";
import { make } from "../commands/make.js";
Expand All @@ -20,6 +21,7 @@ const main = defineCommand({
create,
generate,
codegen,
contracts,
migrate,
},
});
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/commands/contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineCommand } from "citty";
import { contractsCheck } from "./contractsCheck.js";
import { GLOBAL_OPTIONS } from "./options.js";

export const contracts = defineCommand({
meta: {
name: "contracts",
description: "Validate Croco contract graph artifacts",
},
args: {
...GLOBAL_OPTIONS,
},
subCommands: {
check: contractsCheck,
},
});
69 changes: 69 additions & 0 deletions packages/cli/src/commands/contractsCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { type ChildProcess, type SpawnOptions, spawn } from "node:child_process";
import { createRequire } from "node:module";
import { basename, dirname, join } from "node:path";
import { defineCommand } from "citty";
import { GLOBAL_OPTIONS } from "./options.js";

const require = createRequire(import.meta.url);

export type ContractsCheckSpawn = (
command: string,
args: string[],
options: SpawnOptions,
) => ChildProcess;

export const contractsCheck = defineCommand({
meta: {
name: "check",
description: "Validate the canonical contract graph without generating artifacts",
},
args: {
...GLOBAL_OPTIONS,
},
run({ rawArgs }) {
runContractsCheck(rawArgs);
},
});

export function runContractsCheck(
args: string[],
options: {
readonly resolveBin?: () => string;
readonly spawn?: ContractsCheckSpawn;
readonly setExitCode?: (code: number) => void;
readonly writeError?: (message: string) => void;
} = {},
): void {
const resolveBin = options.resolveBin ?? resolveRpcCodegenBin;
const spawnChild = options.spawn ?? spawn;
const setExitCode =
options.setExitCode ??
((code: number) => {
process.exitCode = code;
});
const writeError = options.writeError ?? ((message: string) => console.error(message));
const child = spawnChild(process.execPath, [resolveBin(), "--check", ...args], {
stdio: "inherit",
});

child.on("exit", (code) => {
setExitCode(code ?? 1);
});

child.on("error", (error) => {
writeError(error.message);
setExitCode(1);
});
}

export function resolveRpcCodegenBin(): string {
return resolveRpcCodegenBinFromEntry(require.resolve("@croco/rpc-codegen"));
}

export function resolveRpcCodegenBinFromEntry(entry: string): string {
const entryDir = dirname(entry);

return basename(entryDir) === "src"
? join(dirname(entryDir), "dist", "cli.js")
: join(entryDir, "cli.js");
}
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export { confirmOverwrite, selectMode, textInput, NoTtyError } from "./libs/prom
export { codegen } from "./commands/codegen";
export { codegenOpenapi } from "./commands/codegenOpenapi";
export { codegenRpc } from "./commands/codegenRpc";
export { contracts } from "./commands/contracts";
export { contractsCheck, runContractsCheck } from "./commands/contractsCheck";
export { create } from "./commands/create";
export { createDomain } from "./commands/createDomain";
export { createPage } from "./commands/createPage";
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/tests/contractsCheck.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { EventEmitter } from "node:events";
import { join } from "node:path";
import { Container } from "typedi";
import { beforeEach, describe, expect, it } from "vitest";
import {
type ContractsCheckSpawn,
resolveRpcCodegenBinFromEntry,
runContractsCheck,
} from "../commands/contractsCheck.js";

describe("contractsCheck", () => {
beforeEach(() => {
Container.reset();
});

it("should resolve a workspace source RPC package entry to the built RPC CLI", () => {
const root = join("workspace", "packages", "rpc-codegen");

expect(resolveRpcCodegenBinFromEntry(join(root, "src", "index.ts"))).toBe(
join(root, "dist", "cli.js"),
);
});

it("should spawn the RPC check mode with forwarded args and preserve its exit code", () => {
const child = new EventEmitter() as unknown as ChildProcess;
const calls: SpawnCall[] = [];
const exitCodes: number[] = [];
const spawnCheck: ContractsCheckSpawn = (command, args, options) => {
calls.push({ command, args, options });
return child;
};

runContractsCheck(["--controllers", "src/**/*.ts"], {
resolveBin: () => "/pkg/dist/cli.js",
spawn: spawnCheck,
setExitCode: (code) => exitCodes.push(code),
});
child.emit("exit", 7);

expect(calls).toEqual([
{
command: process.execPath,
args: ["/pkg/dist/cli.js", "--check", "--controllers", "src/**/*.ts"],
options: { stdio: "inherit" },
},
]);
expect(exitCodes).toEqual([7]);
});

it("should report spawn errors as command failures", () => {
const child = new EventEmitter() as unknown as ChildProcess;
const errors: string[] = [];
const exitCodes: number[] = [];
const spawnCheck: ContractsCheckSpawn = () => child;

runContractsCheck([], {
resolveBin: () => "/pkg/dist/cli.js",
spawn: spawnCheck,
setExitCode: (code) => exitCodes.push(code),
writeError: (message) => errors.push(message),
});
child.emit("error", new Error("spawn failed"));

expect(errors).toEqual(["spawn failed"]);
expect(exitCodes).toEqual([1]);
});
});

type SpawnCall = {
readonly command: string;
readonly args: string[];
readonly options: SpawnOptions;
};
5 changes: 3 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 @@ -84,8 +84,9 @@ function checkSpaBeSplitStructure() {
scripts: expect.objectContaining({
"dev:api": expect.any(String),
"dev:web": expect.any(String),
"contract:openapi": expect.stringContaining("croco-openapi-spec"),
"contract:client": expect.stringContaining("croco-rpc-codegen"),
"contract:check": expect.stringMatching(/croco-rpc-codegen[\s\S]*--check/),
"contract:openapi": expect.stringMatching(/^pnpm contract:check &&[\s\S]*croco-openapi-spec/),
"contract:client": expect.stringMatching(/^pnpm contract:check &&[\s\S]*croco-rpc-codegen/),
codegen: expect.any(String),
test: "turbo test",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@ pnpm dev:web

- API 서버: `http://localhost:3000`
- SPA 개발 서버: Vite 기본 포트
- 계약 그래프 검증: `pnpm contract:check`
- OpenAPI 문서 생성: `pnpm contract:openapi`
- RPC 클라이언트 생성: `pnpm contract:client` 또는 `pnpm codegen`

## 코드 생성

```bash
pnpm contract:check
pnpm contract:openapi
pnpm contract:client
pnpm codegen
```

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

## 구조

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
"scripts": {
"dev:api": "pnpm --filter {{scope}}/api-server dev",
"dev:web": "pnpm --filter {{scope}}/console-web dev",
"contract:openapi": "croco-openapi-spec --controllers 'apps/api-server/src/controllers/**/*.ts' --out openapi.json --title '{{projectName}} API' --version 0.1.0 --server http://localhost:3000",
"contract:client": "croco-rpc-codegen --controllers 'apps/api-server/src/controllers/**/*.ts' --out libs/shared/provider-rpc/src --react-query",
"contract:check": "croco-rpc-codegen --controllers 'apps/api-server/src/controllers/**/*.ts' --check",
"contract:openapi": "pnpm contract:check && croco-openapi-spec --controllers 'apps/api-server/src/controllers/**/*.ts' --out openapi.json --title '{{projectName}} API' --version 0.1.0 --server http://localhost:3000",
"contract:client": "pnpm contract:check && croco-rpc-codegen --controllers 'apps/api-server/src/controllers/**/*.ts' --out libs/shared/provider-rpc/src --react-query",
"codegen": "pnpm contract:client",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"build": "turbo build",
"test": "turbo test",
Expand Down
2 changes: 2 additions & 0 deletions packages/openapi-spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.4",
"@croco/problems-core": "workspace:*",
"@croco/protocols-core": "workspace:*",
"ts-morph": "^24.0.0",
"zod": "^3.23.8"
Expand All @@ -45,6 +46,7 @@
"@redocly/cli": "^2.30.3",
"orval": "8.9.1",
"reflect-metadata": "0.2.2",
"typedi": "0.10.0",
"vitest": "4.0.16"
},
"vitest": {
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-spec/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
emitOpenAPI,
emitOpenAPIFromContractGraph,
type EmitOpenAPIOptions,
type ProblemResponseConfig,
} from "./libs/emitOpenAPI";
Loading
Loading