conceptadev / conceptadev/rockets

RFC: one schema-driven validation/serialization engine on Nest 12 Standard Schema — collapse the four validation paths

Open
#104 5 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
1
Forks
2
Avg merge
2d 10h
Merged PRs (30d)
23

Description

> [!IMPORTANT]
> **Status — implemented by #105, with one deliberate deviation from this proposal.**
>
> What shipped is **one schema contract, not one execution path**. The text below sketches a single *globally registered* `StandardSchemaValidationPipe`; the implementation is **route-scoped**: upstream reads controller-level `request.validation` and wires Nest's native pipe per route, and core *rejects* a global `StandardSchemaValidationPipe` at boot (a global one would double-validate every body and hide a route that lost its pipe — the `requireSchemaPipe` audit is what catches that instead).
>
> `operationResource` deliberately **keeps its own inline output validation** rather than Nest's serializer: the serializer lets `null`/`undefined` through and maps arrays per item, which contradicts the pinned 500-on-mismatch policy. So there are three executors reading one contract — the native pipe for requests, upstream's CRUD serializer for generated responses, inline validation for operation outputs — and that split is intentional.
>
> Everything else below landed as proposed: the schema is the single source of truth for validation, serialization and OpenAPI; `ZodBodyValidationInterceptor`, `compileDtoClass`'s class-transformer metadata, `@Allow()` stamps and the `class-validator` / `class-transformer` dependencies are gone. The section that follows is the original RFC, kept for the record.

---

## Summary

Proposal to make the schema the **single source of truth** for request validation, response serialization, and OpenAPI — executed by Nest 12's native Standard Schema pipe and serializer — and to delete the home-grown validation/serialization layers plus the `class-validator` / `class-transformer` dependency from `rockets-core`.

This is issue #74's stated direction ("make Nest's native Standard Schema components the single runtime validation/serialization boundary") taken to completion. It is **not** a proposal to adopt #75's public DTO factories; see "What this is not".

Open for discussion before any code. Nothing here is started.

## The problem, as verified on `main` (2026-08-25)

Request-body validation currently runs through **four separate implementations** of the same `~standard` contract:

| Route kind | Who validates | Where |
|---|---|---|
| Generated CRUD (`zodResource` / `defineResource`) | `ZodBodyValidationInterceptor` — a global `APP_INTERCEPTOR` reading the DTO's carried schema and validating raw `req.body` | `packages/rockets-core/src/infrastructure/interceptors/zod-body-validation.interceptor.ts`, registered in `rockets-core.module-definition.ts` |
| `operationResource` | inline `applyInputDto` / `applyOutputDto` / `validateAndWhitelistDto` inside the generated controller | `packages/rockets-core/src/infrastructure/resource/operation-resource/build-operation-controller.ts` |
| Hand-written controllers (what `examples/sample-server` actually does) | Nest's native `StandardSchemaValidationPipe` + explicit `@Body({ schema })` | `examples/sample-server/src/main.ts`, `auth.controller.ts` |
| `@concepta/rockets-core/standard-schema` (#75) | `StandardSchemaDtoValidationPipe` via `StandardSchemaModule` | **zero consumers** in `packages/` or `examples/` |

On top of that, upstream `@concepta/nestjs-crud@8.0.0-alpha.8` stamps a **class-validator `ValidationPipe` per body param** (`crud-init-validation.decorator.js`) that validates nothing useful for a zod DTO (the compiled class carries only `@Allow()` stamps), and its response path (`crud-serialize.interceptor.js`) runs on `class-transformer`. Rockets' `compileDtoClass` therefore has to emit `@Exclude`/`@Expose`/`@Transform` metadata for response projection and `@Allow()` stamps (#83) so bodies survive a foreign `whitelist: true` pipe — both sample apps register one globally.

Net effect: two libraries the project is otherwise moving away from stay in the critical path, held together by compatibility shims, and the same validation contract is implemented four times. The #83 field report (schema DTO silently emptied to `{}` with a `201`) is the kind of defect this shape produces.

## Proposal

One engine, driven by the schema:

1. **Request** — every generated route (CRUD and `operationResource`) is emitted with native route metadata (`Body({ schema })`, `Query({ schema })`, params) and **one** globally registered `StandardSchemaValidationPipe` validates. Hand-written routes already work this way.
2. **Response** — every generated route carries `SerializeOptions({ schema: responseSchema })` and Nest's `StandardSchemaSerializerInterceptor` serializes. The response schema comes from the zod layer's existing projection machinery (`packages/rockets-core/src/zod/zod-projections.ts` → `projectSchema`), extended for computed fields and nested relations. Invalid handler output fails closed (500), matching current semantics.
3. **OpenAPI** — generated from the same schemas (zod v4 `toJSONSchema` / Standard JSON Schema), so docs cannot drift from validation.
4. **DTO classes** become a thin carrier (`static schema`) with no `class-transformer` or `class-validator` metadata.

Authoring code (`zodResource`, `operationResource`, hand-written `@Body({ schema })`) does not change.

### Before / after (plumbing only)

```ts
// BEFORE — rockets-core.module-definition.ts
{ provide: APP_INTERCEPTOR, useClass: ZodBodyValidationInterceptor },

// BEFORE — build-operation-controller.ts
const input = await applyInputDto(operation.inputDto, rawInput);
return applyOutputDto(operation.output, result, label);

// BEFORE — zod-dto.ts (compileDtoClass)
const cls = allowStandardSchemaKeys(nameGeneratedDto(createZodDto(schema), name), Object.keys(schema.shape));
Exclude()(cls);
for (const [key, field] of Object.entries(schema.shape)) { Expose()(proto, key); Transform(...)(proto, key); }

// BEFORE — examples/sample-server/src/main.ts
app.useGlobalPipes(new StandardSchemaValidationPipe(), new ValidationPipe({ transform: true, whitelist: true }));
```

```ts
// AFTER — rockets-core.module-definition.ts
{ provide: APP_PIPE, useClass: StandardSchemaValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: StandardSchemaSerializerInterceptor },

// AFTER — CRUD generator and build-operation-controller.ts: the route is born with its schemas
Body({ schema: inputSchema })(proto, methodName, bodyIndex);
SerializeOptions({ schema: responseSchema })(proto, methodName, descriptor);
request: { validation: false } // switches off upstream's class-validator pipe — supported today

// AFTER — zod-dto.ts
class Dto { static readonly schema = schema; }

// AFTER — main.ts
app.useGlobalPipes(new StandardSchemaValidationPipe());
```

### What gets deleted

- `ZodBodyValidationInterceptor`
- `applyInputDto` / `applyOutputDto` / `validateAndWhitelistDto` in `build-operation-controller.ts`
- `allowStandardSchemaKeys` and every `@Allow()` stamp (no whitelist pipe left to survive)
- `class-transformer` decorators in `compileDtoClass`
- `class-validator` and `class-transformer` from `rockets-core`'s dependencies
- the unused public surface of `@concepta/rockets-core/standard-schema` (see #75 note below); `nestjs-zod` likely afterwards (only used for the class carrier + OpenAPI factory, both replaceable)

Kept: `isStandardSchema` / `getCarriedStandardSchema` (load-bearing for `whitelistedFromDto` and the transition), and the `@standard-schema/spec` types #75 introduced.

## Why this is the best option for us

- **Correctness by construction.** One validator means one behavior; docs generated from the validating schema cannot lie. The #83 class of bug (validated, then emptied by a second pipe) becomes impossible because there is no second pipe.
- **Less code, fewer dependencies.** Four validation implementations → one (Nest's). Two legacy libraries out of core. The compatibility shims exist only to reconcile the two worlds; with one world they vanish.
- **Aligned with upstream.** `@concepta/nestjs-*@8.0.0-alpha.9` already replaced class-validator/class-transformer with zod schemas built on Nest 12 Standard Schema (verified in #102). Staying on the old shape means diverging from the engine we consume.
- **Vendor neutrality for free.** Everything speaks `~standard`; zod stays the authoring layer. #74's goal is achieved as a byproduct, without maintaining a separate public API for it.
- **Pre-1.0 timing.** The package is not published yet. Removing shims and dependencies now is free; after 1.0 every one of them is a compatibility promise.

## Staged plan (each stage its own PR, each independently shippable)

0. **Trim `@concepta/rockets-core/standard-schema` to its load-bearing helpers** — move `isStandardSchema`/`getCarriedStandardSchema` back to `common/utils/standard-schema.util.ts` (their original home), delete the unused factories/module/pipes/decorators/brands and the two package subpaths, regenerate the API report. Zero internal consumers of the removed symbols (verified).
1. **Upstream `alpha.9` upgrade (#102)** — re-scoped from "deferred, no gain" to "prerequisite": it removes class-validator from the CRUD engine and migrates the identity packages' DTOs to zod schemas (~32 files on our side; analysis in #102).
2. **Request unification** — generated routes emit native schema metadata; single global pipe; delete the interceptor and the inline validators. `request: { validation: false }` already lets us bypass upstream's per-param pipe, so most of this stage is possible before stage 1 lands.
3. **Response unification** — schema-driven projection through the native serializer; delete class-transformer usage. **This is the real engineering:** computed fields (`compute` → response-schema transform), nested relation projections (compose child response schemas), Date/ISO conversion, fail-closed field stripping (zod strips unknown keys by default). Also needs upstream's `CrudSerialize` interceptor disabled or bypassed — to verify whether `response.serialization: false` suffices on alpha.8 or whether this waits for stage 1.
4. **OpenAPI from schemas only** — drop `nestjs-zod` if nothing else needs it; regenerate the pinned contracts from #99.

## Risks and honest caveats

- **Nest 12 is alpha.** The native Standard Schema pipe/serializer/swagger `standardSchema` APIs may change; this proposal builds the whole system on them. Mitigation: upstream Concepta is making the same bet, so churn hits both and gets fixed once.
- **Stage 3 is not small.** Computed fields and nested projections are where the current class-transformer path does real work; the response schema must carry that faithfully. This stage should get its own design review before code.
- **`rockets-server-auth`** has 6 hand-written controllers on class-validator DTOs; they migrate in stage 1 alongside the upstream identity DTOs.
- **Sequencing:** do this **after** the currently open PRs (#92–#100) merge. #94 and #100 edit `build-operation-controller.ts` heavily, #96 touches the planner, #99 pins OpenAPI contracts that this change will (correctly) drift. Starting now means rebasing four reviewed PRs across a foundation change.

## What this is not

- Not adoption of #75's `createStandardSchemaDto` / `StandardSchemaModule` public API. That API has zero consumers and is not needed for the native path; stage 0 removes it. The valuable parts of #75 — the `@standard-schema/spec` types and the carrier-recognition helper — stay.
- Not a rewrite of the authoring layer. `zodResource`, `operationResource`, `f.*`, hooks, ACL: unchanged.

## Related

- #74 / #75 — original Standard Schema direction; this RFC is its "step 2".
- #83 — the whitelist trap this design makes structurally impossible.
- #102 — upstream alpha.9 evaluation; becomes stage 1 here.
- #99 — pinned OpenAPI contracts (will need regeneration in stage 4).

Contributor guide

Open the contributing guide

Research direction

The proposal is already implemented by #105, whose route-scoped design and deliberate operationResource exception supersede the original global-pipe plan. For context, compare the implementation with the named areas: build-operation-controller.ts, zod-projections.ts, rockets-core.module-definition.ts, and the sample server; completion is represented by the shipped #105 behavior and its deviation from this RFC.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend, backend-api-design
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
15/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.