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/strict-generated-contract-graphs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@croco/cli": patch
"@croco/openapi-spec": patch
"@croco/problems-core": patch
"@croco/protocols-core": patch
"@croco/rpc-codegen": patch
"create-croco-app": patch
---

Generated OpenAPI and RPC contract paths now run strict ContractGraph schema checks by default, fail generated app scripts on strict ContractGraph diagnostics, and keep legacy compatibility behavior behind explicit opt-out flags.
6 changes: 3 additions & 3 deletions docs/problem-code-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@
},
"sources": [
{
"file": "packages/create-croco-app/templates/admin-console/apps/api-server/src/controllers/AdminController.ts",
"line": 47,
"column": 20,
"file": "packages/create-croco-app/templates/admin-console/apps/api-server/src/controllers/adminSchemas.ts",
"line": 6,
"column": 79,
"kind": "problem-metadata"
}
]
Expand Down
17 changes: 14 additions & 3 deletions docs/release/contract-first-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ reported as non-breaking.

`contract:openapi` and `contract:client` should run after the check and diff gates so generated
artifacts are produced only from an accepted contract graph.
Generated REST app templates pass `--strict-schemas` to both generators so schema-less routes fail
Generated REST app templates run schema-strict ContractGraph validation for both generators and pass
`--fail-on-diagnostics` so schema-less routes or missing generated-client Problem contracts fail
before OpenAPI output or permissive `unknown | undefined` RPC success types are written.
`croco-openapi-spec` and `croco-rpc-codegen` default to strict schema mode and strict Problem
contract diagnostics; use `--compatibility-schemas` or `--compatibility-problems` only as explicit
migration opt-outs for legacy routes that are not part of the generated 1.0 app path.

`contract:coverage` writes `contract-graph.coverage.json` with the same route graph plus consumer
coverage diagnostics. Unsupported graph fields are reported explicitly so generator omissions do not
Expand Down Expand Up @@ -86,10 +90,17 @@ croco contracts check --controllers 'apps/api-server/src/controllers/**/*.ts' --
croco contracts check --controllers 'apps/api-server/src/controllers/**/*.ts' --json --out contract-graph.snapshot.json
croco contracts check --controllers 'apps/api-server/src/controllers/**/*.ts' --json --out contract-graph.coverage.json
croco contracts diff --baseline contract-graph.snapshot.json --controllers 'apps/api-server/src/controllers/**/*.ts' --strict-schemas
croco-openapi-spec --controllers 'apps/api-server/src/controllers/**/*.ts' --strict-schemas --out openapi.json
croco-rpc-codegen --controllers 'apps/api-server/src/controllers/**/*.ts' --strict-schemas --out libs/shared/provider-rpc/src
croco-openapi-spec --controllers 'apps/api-server/src/controllers/**/*.ts' --out openapi.json
croco-rpc-codegen --controllers 'apps/api-server/src/controllers/**/*.ts' --out libs/shared/provider-rpc/src
```

`croco contracts check --json` prints the same stable JSON snapshot to stdout when `--out` is not
provided. `croco contracts diff --json` prints a machine-readable diff report and exits non-zero
when breaking changes exist.

For legacy migration only, `croco-openapi-spec --compatibility-schemas` and
`croco-rpc-codegen --compatibility-schemas` keep the old schema-less generator behavior available.
`--compatibility-problems` similarly keeps missing generated client Problem unions out of the
strict diagnostic report. `--fail-on-diagnostics` is the generated-app gate that treats warnings and
errors as blocking before writing OpenAPI or RPC output. Do not use compatibility opt-outs in
generated app CI or 1.0 release evidence.
62 changes: 59 additions & 3 deletions packages/cli/src/commands/generateUsageDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,10 +755,30 @@ function formatRuntimeImportError(error: unknown): string {
}

function controllerTemplate(route: RouteParts): string {
const contractPath =
route.methodPath === "/" ? route.controllerPath : `${route.controllerPath}${route.methodPath}`;

return `import { Component } from "@croco/framework-context";
import { Controller, Ctx, Get, ResponseSchema } from "@croco/protocols-rest";
import { ProblemCategory } from "@croco/problems-core";
import {
Controller,
Ctx,
defineRouteContract,
defineRouteProblem,
Get,
HttpMethod,
ProblemResponses,
ResponseSchema,
routeProblemResponses,
} from "@croco/protocols-rest";
import type { CrocoHttpContext } from "@croco/transports-http";
import { z } from "zod";
import {
UsageDashboardMeterNotFoundProblem,
UsageDashboardProviderUnavailableProblem,
UsageDashboardTenantNotFoundProblem,
UsageDashboardTenantRequiredProblem,
} from "../usage-dashboard/UsageDashboardProblems";
import type { UsageDashboardSnapshot } from "../usage-dashboard/UsageDashboardService";

const usageDashboardOveragePolicySchema = z.enum(["BLOCK", "WARN", "ALLOW_WITH_OVERAGE"]);
Expand Down Expand Up @@ -811,10 +831,46 @@ const usageDashboardSnapshotSchema = z.object({
lastUpdatedAt: z.string(),
});

const usageDashboardTenantRequiredProblem = defineRouteProblem(UsageDashboardTenantRequiredProblem, {
code: "CROCO_CLI_USAGE_DASHBOARD_001",
category: ProblemCategory.ValidationError,
description: "Usage dashboard requires tenant context.",
});
const usageDashboardTenantNotFoundProblem = defineRouteProblem(UsageDashboardTenantNotFoundProblem, {
code: "CROCO_CLI_USAGE_DASHBOARD_002",
category: ProblemCategory.NotFound,
description: "The requested tenant does not exist.",
});
const usageDashboardMeterNotFoundProblem = defineRouteProblem(UsageDashboardMeterNotFoundProblem, {
code: "CROCO_CLI_USAGE_DASHBOARD_003",
category: ProblemCategory.NotFound,
description: "A requested meter does not exist.",
});
const usageDashboardProviderUnavailableProblem = defineRouteProblem(UsageDashboardProviderUnavailableProblem, {
code: "CROCO_CLI_USAGE_DASHBOARD_004",
category: ProblemCategory.InternalServerError,
description: "Usage dashboard dependencies are unavailable.",
});

const usageDashboardSnapshotRoute = defineRouteContract({
id: "usage-dashboard.snapshot",
method: HttpMethod.GET,
path: "${contractPath}",
operationId: "getUsageDashboardSnapshot",
response: usageDashboardSnapshotSchema,
problems: [
usageDashboardTenantRequiredProblem,
usageDashboardTenantNotFoundProblem,
usageDashboardMeterNotFoundProblem,
usageDashboardProviderUnavailableProblem,
],
});

@Component()
@Controller("${route.controllerPath}")
export class UsageDashboardController {
@Get("${route.methodPath}")
@Get(usageDashboardSnapshotRoute)
@ProblemResponses(...routeProblemResponses(usageDashboardSnapshotRoute))
@ResponseSchema(usageDashboardSnapshotSchema)
async snapshot(@Ctx() ctx: CrocoHttpContext): Promise<UsageDashboardSnapshot> {
const { createUsageDashboardService } = await import("../usage-dashboard/UsageDashboardRuntime");
Expand Down Expand Up @@ -935,7 +991,7 @@ export default function UsageDashboardPage() {
if (!response.ok) {
return {
status: 'error',
message: 'Usage dashboard request failed with HTTP ' + response.status,
message: 'CROCO_USAGE_DASHBOARD_REQUEST_FAILED: HTTP ' + response.status,
} satisfies ViewState;
}

Expand Down
73 changes: 69 additions & 4 deletions packages/cli/src/tests/generateUsageDashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,13 @@ describe("runGenerateUsageDashboard", () => {
expect(result?.page?.files.map((file) => file.status)).toEqual(["created", "created"]);
expect(controllerContent).toContain('import { Component } from "@croco/framework-context";');
expect(controllerContent).toContain("@Component()");
expect(controllerContent).toContain('@Controller("/ops")');
expect(controllerContent).toContain('@Get("/usage")');
expect(readGeneratedUsageDashboardRoute(controllerContent)).toEqual({
controllerPath: "/ops",
contractPath: "/ops/usage",
methodDecorator: "usageDashboardSnapshotRoute",
resolvedMethodPath: "/usage",
});
expect(controllerContent).toContain('import { ProblemCategory } from "@croco/problems-core";');
expect(controllerContent).toContain("@ResponseSchema(usageDashboardSnapshotSchema)");
expect(controllerContent).toContain("const usageDashboardSnapshotSchema = z.object");
expect(controllerContent).toContain('await import("../usage-dashboard/UsageDashboardRuntime")');
Expand Down Expand Up @@ -309,8 +314,12 @@ await app.listen(3000);
"utf-8",
);

expect(controllerContent).toContain('@Controller("/admin/tenants")');
expect(controllerContent).toContain('@Get("/usage")');
expect(readGeneratedUsageDashboardRoute(controllerContent)).toEqual({
controllerPath: "/admin/tenants",
contractPath: "/admin/tenants/usage",
methodDecorator: "usageDashboardSnapshotRoute",
resolvedMethodPath: "/usage",
});
expect(routeContent).toContain("path: '/admin/usage'");
});

Expand Down Expand Up @@ -469,6 +478,62 @@ async function readApiEntry(cwd: string): Promise<string> {
return fs.readFile(path.join(cwd, "apps", "api-server", "src", "app.ts"), "utf-8");
}

function readGeneratedUsageDashboardRoute(controllerContent: string): {
readonly controllerPath: string;
readonly contractPath: string;
readonly methodDecorator: string;
readonly resolvedMethodPath: string;
} {
const controllerPath = readStringCapture(
controllerContent,
/@Controller\("([^"]+)"\)/,
"controller path",
);
const contractPath = readStringCapture(
controllerContent,
/const usageDashboardSnapshotRoute = defineRouteContract\(\{[\s\S]*?path: "([^"]+)"/,
"usage dashboard contract path",
);
const methodDecorator = readStringCapture(
controllerContent,
/@Get\(([A-Za-z0-9_]+)\)/,
"usage dashboard method decorator",
);

return {
controllerPath,
contractPath,
methodDecorator,
resolvedMethodPath: resolveControllerRelativeRoutePath(controllerPath, contractPath),
};
}

function readStringCapture(content: string, pattern: RegExp, label: string): string {
const value = pattern.exec(content)?.[1];

if (!value) {
throw new Error(`Could not read generated ${label}.`);
}

return value;
}

function resolveControllerRelativeRoutePath(controllerPath: string, routePath: string): string {
if (controllerPath === "") {
return routePath === "/" ? "" : routePath;
}

if (routePath === controllerPath) {
return "";
}

if (routePath.startsWith(`${controllerPath}/`)) {
return routePath.slice(controllerPath.length);
}

return routePath;
}

function packageManifest(packageNames: readonly string[]): string {
return JSON.stringify(
{
Expand Down
28 changes: 24 additions & 4 deletions packages/cli/src/tests/integration/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,11 +327,31 @@ declare module 'react' {
declare module '@croco/protocols-rest' {
export function Controller(path: string): ClassDecorator;
export function Ctx(): ParameterDecorator;
export function Delete(path: string): MethodDecorator;
export function Get(path: string): MethodDecorator;
export function Post(path: string): MethodDecorator;
export function Put(path: string): MethodDecorator;
export enum HttpMethod {
DELETE = 'DELETE',
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
}

export type RouteContractFixture = {
readonly path: string;
readonly problems?: readonly unknown[];
};

export function defineRouteContract<const TContract extends RouteContractFixture>(
contract: TContract,
): TContract;
export function defineRouteProblem(problem: Function, metadata: unknown): unknown;
export function Delete(path: string | RouteContractFixture): MethodDecorator;
export function Get(path: string | RouteContractFixture): MethodDecorator;
export function Post(path: string | RouteContractFixture): MethodDecorator;
export function Put(path: string | RouteContractFixture): MethodDecorator;
export function ProblemResponses(...responses: readonly unknown[]): MethodDecorator;
export function ResponseSchema(schema: unknown): MethodDecorator;
export function routeProblemResponses(
contract: { readonly problems: readonly unknown[] },
): readonly unknown[];
}

declare module '@croco/transports-http' {
Expand Down
4 changes: 2 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 @@ -357,7 +357,7 @@ function checkAdminConsoleStructure() {
);
checkFileContains(
"admin-console",
["apps", "api-server", "src", "controllers", "AdminController.ts"],
["apps", "api-server", "src", "controllers", "adminSchemas.ts"],
/admin-console\/user-not-found/,
);
checkFileContains("admin-console", ["apps", "console-web", "src", "App.tsx.hbs"], /adminClient/);
Expand Down Expand Up @@ -702,7 +702,7 @@ function checkSaasStructure() {
);
checkFileContains(
"saas",
["apps", "api-server", "src", "controllers", "OperationsController.ts"],
["apps", "api-server", "src", "controllers", "schemas.ts"],
/\/diagnostics/,
);
checkFileContains(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pnpm codegen
`contract:client`는 admin controller 계약에서 React Query hook을 포함한 fetch client를 생성합니다. Admin web은 `{{scope}}/provider-rpc`의 `adminClient`와 generated output types를 직접 사용하므로 `typecheck`와 `build`는 codegen을 먼저 실행합니다.
`contract:coverage`는 같은 controller 계약에서 `contract-graph.coverage.json`을 써서 OpenAPI/RPC consumer coverage diagnostics를 남깁니다.
`contract:verify`는 `project-map:write`와 `project-map:check`를 실행해 `.croco/manifest` inspectable bundle을 갱신하고 검증합니다. Generated OpenAPI/RPC outputs는 같은 bundle 경로를 source reference로 남깁니다.
Generated OpenAPI/RPC commands run strict ContractGraph schema checks by default and pass `--fail-on-diagnostics`, so strict Problem contract diagnostics block generated artifacts. `--compatibility-schemas` and `--compatibility-problems` are migration-only opt-outs for legacy routes, not generated app CI settings.

## Structure

Expand Down
Loading
Loading