From 634597c9b8a9f4877c3e1040d4320545e7ae65b3 Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Fri, 28 Aug 2026 23:35:28 -0300 Subject: [PATCH] feat!: make standard schema integration schema-first --- .changeset/schema-first-classes.md | 9 + CHANGELOG.md | 8 + CONTRIBUTING.md | 14 +- README.md | 610 +++++------------- examples/nest-cli-zod/README.md | 4 +- .../{product.dto.ts => product.schemas.ts} | 18 +- .../src/products/products.controller.ts | 26 +- .../src/products/products.service.ts | 6 +- package.json | 6 +- scripts/test-packed-consumer.mjs | 22 +- src/create-response-schema-class.ts | 32 + src/create-schema-class.ts | 30 + src/create-standard-schema-dto.ts | 32 - ...reate-standard-schema-response-dto.spec.ts | 79 --- src/create-standard-schema-response-dto.ts | 30 - src/index.ts | 18 +- src/plugin/index.cts | 101 +-- ...s => schema-class-validation.pipe.spec.ts} | 30 +- ...ipe.ts => schema-class-validation.pipe.ts} | 11 +- src/schema-class.spec.ts | 92 +++ src/schema.spec.ts | 66 -- src/schema.ts | 62 +- ...standard-schema-response.decorator.spec.ts | 10 +- src/standard-schema-response.decorator.ts | 3 +- src/standard-schema.module.ts | 7 +- ...standard-schema-response.decorator.spec.ts | 62 +- .../api-standard-schema-response.decorator.ts | 55 +- src/swagger/index.ts | 1 - test/compiler/compiler-plugin.spec.ts | 513 ++++++++------- test/native-integration.e2e-spec.ts | 91 ++- 30 files changed, 800 insertions(+), 1248 deletions(-) create mode 100644 .changeset/schema-first-classes.md rename examples/nest-cli-zod/src/products/{product.dto.ts => product.schemas.ts} (71%) create mode 100644 src/create-response-schema-class.ts create mode 100644 src/create-schema-class.ts delete mode 100644 src/create-standard-schema-dto.ts delete mode 100644 src/create-standard-schema-response-dto.spec.ts delete mode 100644 src/create-standard-schema-response-dto.ts rename src/{standard-schema-dto-validation.pipe.spec.ts => schema-class-validation.pipe.spec.ts} (72%) rename src/{standard-schema-dto-validation.pipe.ts => schema-class-validation.pipe.ts} (54%) create mode 100644 src/schema-class.spec.ts delete mode 100644 src/schema.spec.ts diff --git a/.changeset/schema-first-classes.md b/.changeset/schema-first-classes.md new file mode 100644 index 0000000..c6306f8 --- /dev/null +++ b/.changeset/schema-first-classes.md @@ -0,0 +1,9 @@ +--- +'@nestm/standard-schema': minor +--- + +Replace the DTO-oriented API with schema-first validation, serialization, and OpenAPI integration. + +Raw Standard Schemas remain first-class through Nest's native decorator metadata. Applications that want zero-argument `@Body()`, `@Query()`, and `@Param()` reflection can use the new `createSchemaClass` and `createResponseSchemaClass` adapters. The runtime pipe, compiler plugin, examples, diagnostics, and low-level types now use schema-class terminology, and the deprecated Swagger array adapter has been removed. + +This is an intentional breaking change with no compatibility aliases. Existing 0.1 alpha releases remain available for applications using the previous DTO API. diff --git a/CHANGELOG.md b/CHANGELOG.md index 469876d..18b3c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed + +- Repositioned the package around schema-first validation, serialization, and OpenAPI integration. +- Replaced the DTO factories and types with `createSchemaClass`, `createResponseSchemaClass`, `SchemaClass`, and `ResponseSchemaClass`. +- Replaced `StandardSchemaDtoValidationPipe` with `SchemaClassValidationPipe` while continuing to delegate parsing to Nest's native Standard Schema components. +- Updated the optional compiler plugin and example to use schema-class terminology and `*.schemas.ts` files. +- Removed the deprecated `withStandardSchemaResponseArrays` adapter now that Nest Swagger 12 handles array shaping natively. + ## [0.1.0-alpha.0] - 2026-07-30 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 237c45b..6351803 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,14 +69,14 @@ After that one-time setup, merge the Changesets release pull request to let GitH ## Design guidelines -- Preserve the native integration boundary: DTO metadata may select a schema, but Nest's native validation pipe and serializer should parse values. +- Preserve the native integration boundary: explicit metadata or a schema class may select a schema, but Nest's native validation pipe and serializer should parse values. - Keep public APIs compatible with any implementation of Standard Schema. - Do not add a runtime dependency on Zod for core behavior. Zod can be used in examples and tests. -- Keep request DTOs output-oriented: controller parameters receive `StandardSchemaV1.InferOutput`. -- Keep response DTOs input-oriented: handlers return `StandardSchemaV1.InferInput`, and clients receive `StandardSchemaV1.InferOutput`. -- Use concrete runtime DTO classes where reflection is required. Runtime request discovery cannot recover aliases or interfaces. -- Keep response inference build-time and opt-in. It may unwrap supported `Promise` and array annotations only when the TypeScript compiler plugin has a concrete response-branded DTO. -- Let explicit `@StandardSchemaResponse(...)` or `@SerializeOptions(...)` metadata win. Ambiguous response DTO contracts should fail by default or honor the configured skip behavior. +- Keep request schema classes output-oriented: controller parameters receive `StandardSchemaV1.InferOutput`. +- Keep response schema classes input-oriented: handlers return `StandardSchemaV1.InferInput`, and clients receive `StandardSchemaV1.InferOutput`. +- Treat raw schemas as the primary contract. Use concrete schema classes only where runtime reflection improves ergonomics; runtime discovery cannot recover erased aliases or interfaces. +- Keep response inference build-time and opt-in. It may unwrap supported `Promise` and array annotations only when the TypeScript compiler plugin has a concrete response schema class. +- Let explicit `@StandardSchemaResponse(...)` or `@SerializeOptions(...)` metadata win. Ambiguous response schema-class contracts should fail by default or honor the configured skip behavior. - Keep the CommonJS compiler entry isolated from the ESM runtime entry. Runtime users should not load TypeScript merely by importing the package. - Include `.js` suffixes for local imports in TypeScript source compiled as Node ESM. @@ -87,7 +87,7 @@ Tests should cover both type-level ergonomics and runtime behavior where applica - parsed request values reaching the controller; - coercions, defaults, and transforms; - response handler input types and serialized client output types; -- `@Body()`, `@Query()`, and `@Param()` DTO discovery; +- `@Body()`, `@Query()`, and `@Param()` schema-class discovery; - explicit native parameter schemas continuing to work; - object and array response serialization; - controller-level and method-level response schemas; diff --git a/README.md b/README.md index b572ff7..9289e9a 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,36 @@ # @nestm/standard-schema -DTO ergonomics for NestJS 12's native [Standard Schema](https://standardschema.dev/) validation and serialization. +Schema-first validation, serialization, and OpenAPI integration for NestJS using [Standard Schema](https://standardschema.dev/). > [!CAUTION] -> This package is still prerelease software. Its API may change before the first stable release. +> This package is prerelease software. Its API may change before the first stable release. -`@nestm/standard-schema` connects runtime DTO classes to the Standard Schema support built into NestJS 12. It is schema-vendor-neutral: Zod is used in the examples, but the library API accepts Standard Schema-compatible schemas. +`@nestm/standard-schema` builds on the native Standard Schema validation and serialization support in NestJS 12. It accepts schemas from Zod, Valibot, ArkType, or any other Standard Schema-compatible library. -## Why this exists - -Nest's native API is deliberately explicit: - -```ts -@Body({ schema: CreateProductSchema }) -create(body: CreateProduct): Product { - // ... -} -``` - -That is ideal when using TypeScript type aliases, because aliases do not exist at runtime. If you prefer the familiar DTO-class syntax, this package lets the schema live on a runtime class: - -```ts -@Body() -create(body: CreateProductDto): Product { - // ... -} -``` - -The DTO-aware pipe finds the schema on `CreateProductDto` and delegates validation and transformation to Nest's native `StandardSchemaValidationPipe`. - -Responses can use the explicit runtime decorator: - -```ts -@StandardSchemaResponse(ProductResponseDto) -``` - -Or an optional Nest CLI compiler plugin can inject the same metadata from an -explicit response DTO return annotation. With `swagger: true`, it also attaches -native request schema metadata and Standard Schema response metadata understood -by `@nestjs/swagger`: - -```ts -async findAll(): Promise { - // ... -} -``` - -Runtime reflection alone records this return type as `Promise`, so the automatic form is a build-time feature rather than runtime type magic. +Raw schemas are the primary contract. Optional schema classes provide the familiar zero-argument `@Body()`, `@Query()`, and `@Param()` experience when runtime reflection is useful. ## Installation -Install the package alongside NestJS 12 and a Standard Schema implementation: +Install the package alongside NestJS 12 and your schema library: ```sh pnpm add @nestm/standard-schema@alpha -pnpm add @nestjs/common@12.0.1 @nestjs/core@12.0.1 -pnpm add reflect-metadata rxjs +pnpm add @nestjs/common@12 @nestjs/core@12 reflect-metadata rxjs +pnpm add zod ``` -For the Zod examples: +OpenAPI support is optional: ```sh -pnpm add zod@4.4.3 +pnpm add @nestjs/swagger@12 ``` -OpenAPI integration is optional. Install Nest Swagger 12 only when using -`@nestm/standard-schema/swagger` or compiler `"swagger": true`: - -```sh -pnpm add @nestjs/swagger@12.0.0 -``` +## Schema-first usage -The commands show the stable NestJS versions used by this package's test suite. - -## Quick start - -### 1. Define schemas and DTO classes +Define schemas using the library your application already uses: ```ts -// products.dto.ts -import { - createStandardSchemaDto, - createStandardSchemaResponseDto, -} from '@nestm/standard-schema'; +// product.schemas.ts import { z } from 'zod'; export const CreateProductSchema = z.object({ @@ -91,9 +39,7 @@ export const CreateProductSchema = z.object({ active: z.boolean().default(true), }); -export class CreateProductDto extends createStandardSchemaDto( - CreateProductSchema, -) {} +export type CreateProduct = z.output; export const ProductResponseSchema = z.object({ id: z.number().int().positive(), @@ -103,479 +49,241 @@ export const ProductResponseSchema = z.object({ publishedAt: z.date().transform((value) => value.toISOString()), }); -export class ProductResponseDto extends createStandardSchemaResponseDto( - ProductResponseSchema, -) {} -``` - -The two factories model opposite sides of schema parsing: - -- A request DTO instance is `StandardSchemaV1.InferOutput`, because the handler receives the parsed request. In the example, `price` is a number and `active` is present. -- A response DTO instance is `StandardSchemaV1.InferInput`, because the handler returns the value that the serializer will parse. In the example, `publishedAt` is a `Date`. -- The HTTP client receives `StandardSchemaV1.InferOutput`. In the example, `publishedAt` is an ISO string. - -The generated DTO class is a runtime metadata carrier. The controller receives the schema's parsed plain object; the package does not instantiate the DTO or apply class-transformer behavior. - -### 2. Register the module once - -```ts -// app.module.ts -import { Module } from '@nestjs/common'; -import { StandardSchemaModule } from '@nestm/standard-schema'; -import { ProductsController } from './products.controller.js'; - -@Module({ - imports: [StandardSchemaModule.forRoot()], - controllers: [ProductsController], -}) -export class AppModule {} -``` - -`StandardSchemaModule.forRoot()` registers the DTO-aware validation pipe and Nest's native Standard Schema serializer globally. Import it once in the root application module. - -### 3. Enable automatic request, response, and OpenAPI metadata - -Add the optional plugin to `nest-cli.json`: - -```json -{ - "$schema": "https://json.schemastore.org/nest-cli", - "compilerOptions": { - "builder": "tsc", - "plugins": [ - { - "name": "@nestm/standard-schema", - "options": { - "controllerFileNameSuffix": [".controller.ts", ".controller.mts"], - "onAmbiguous": "error", - "swagger": true - } - } - ] - } -} +export type ProductResponse = z.input; +export type ProductJson = z.output; ``` -The suffixes shown above and `onAmbiguous: "error"` are the defaults. -`swagger` defaults to `false`, preserving response-only compiler behavior -without an `@nestjs/swagger` dependency. The package exposes a CommonJS -`@nestm/standard-schema/plugin` entry because the Nest CLI loads compiler -plugins synchronously; application code does not import that entry. - -### 4. Use normal Nest decorators +Pass request schemas through Nest's native decorator metadata: ```ts -// products.controller.ts -import { Body, Controller, Get, Post } from '@nestjs/common'; -import { CreateProductDto, ProductResponseDto } from './products.dto.js'; +import { Body, Controller, Post } from '@nestjs/common'; +import { StandardSchemaResponse } from '@nestm/standard-schema'; +import { + CreateProductSchema, + ProductResponseSchema, + type CreateProduct, + type ProductResponse, +} from './product.schemas.js'; @Controller('products') export class ProductsController { @Post() - create(@Body() body: CreateProductDto): ProductResponseDto { - const product = { + @StandardSchemaResponse(ProductResponseSchema) + create( + @Body({ schema: CreateProductSchema }) input: CreateProduct, + ): ProductResponse { + return { id: 1, - ...body, + ...input, publishedAt: new Date(), - internalRevision: 1, }; - - return product; - } - - @Get() - async findAll(): Promise { - const products = [ - { - id: 1, - name: 'Keyboard', - price: 99, - active: true, - publishedAt: new Date(), - internalRevision: 3, - }, - ]; - - return products; } } ``` -The plugin injects native `{ schema: Dto.schema }` options into zero-argument, -whole-object `@Body()`, `@Query()`, and `@Param()` decorators. It also injects -the equivalent of `@ApiStandardSchemaResponse(ProductResponseDto, ...)` for -supported response signatures, including the standard Nest status (`POST` is -201; other routes are 200) and one array layer. +The request handler receives the schema output after coercions, defaults, transforms, and key handling. The response handler returns the response schema input, and the HTTP client receives its output. -The plugin is optional. Without it, or when a route needs a contract that cannot be inferred, keep the explicit decorator: +## Optional schema classes -```ts -@Get('summary') -@StandardSchemaResponse(ProductSummaryResponseDto) -summary(): ProductSummaryResponseDto { - return this.products.summary(); -} -``` - -Explicit `@StandardSchemaResponse(...)`, -`@ApiStandardSchemaResponse(...)`, and Nest `@SerializeOptions(...)` metadata -always win, whether placed on the method or controller. DTO classes and raw -Standard Schema objects are accepted by the explicit decorators. - -With `swagger: true`, a method carrying a single-argument -`@StandardSchemaResponse(Source)` is rewritten to -`@ApiStandardSchemaResponse(Source, { status })`, so its schema is documented -under the real success status rather than the `default` response key. The status -is derived exactly as inference derives it: `@HttpCode` wins, otherwise `@Post` -is 201 and every other verb is 200. - -The rewrite backs off — leaving your decorator untouched — whenever the status -cannot be known or the entry would collide: a raw `@Res()` parameter, a -`@Redirect()` route, `@HttpCode(204)`, a status that is not statically -resolvable, or any `@nestjs/swagger` response decorator already on the method. -Backing off is safe: with no response metadata, Swagger's own explorer emits the -correct status key for you. - -**Pass `{ status }` when you write `@ApiStandardSchemaResponse` by hand.** -Without it the schema lands on `default`, which most client generators read as -the error type — leaving the success response untyped. - -When Swagger is installed, the composite decorator is also available from its -optional subpath: +Type aliases are erased from emitted JavaScript, so Nest cannot discover a schema from this parameter alone: ```ts -import { ApiStandardSchemaResponse } from '@nestm/standard-schema/swagger'; - -@Get('summary') -@ApiStandardSchemaResponse(ProductSummaryResponseDto, { - description: 'Product summary returned.', - status: 200, -}) -summary(): ProductSummaryResponseDto { - return this.products.summary(); -} +create(@Body() input: CreateProduct) {} ``` -It combines native runtime serialization with `@nestjs/swagger` -`standardSchema` metadata. `isArray: true` produces an array OpenAPI schema -directly for Standard Schema implementations that expose the Standard JSON -Schema converter. - -Nest Swagger 12 stable applies `isArray` after a custom -`standardSchemaConverter`, so converter-only schemas retain their response -array shape and components without an adapter: +Use a schema class when you want automatic runtime discovery with a zero-argument Nest decorator: ```ts -import { SwaggerModule } from '@nestjs/swagger'; +import { + createResponseSchemaClass, + createSchemaClass, +} from '@nestm/standard-schema'; +import { z } from 'zod'; -const document = SwaggerModule.createDocument(app, config, { - standardSchemaConverter, +const CreateProductSchema = z.object({ + name: z.string().trim().min(1), + price: z.coerce.number().nonnegative(), }); -``` - -## Runnable example -The complete [Nest CLI + Zod products API](./examples/nest-cli-zod) uses the -same request DTO discovery and compiler-inferred response metadata as a real -consumer. It includes `@Body()`, `@Query()`, and `@Param()` parsing, an -in-memory service, object and array responses, and HTTP plus OpenAPI smoke -tests. +export class CreateProduct extends createSchemaClass(CreateProductSchema) {} -From this repository: +const ProductResponseSchema = z.object({ + id: z.number(), + name: z.string(), + publishedAt: z.date().transform((value) => value.toISOString()), +}); -```sh -pnpm install -pnpm run example:start +export class ProductResponse extends createResponseSchemaClass( + ProductResponseSchema, +) {} ``` -Use `pnpm run example:build` when you only want to compile it. - -To verify the example against the actual npm artifact boundary: +The controller keeps the familiar class-reflection form: -```sh -pnpm run example:test +```ts +@Post() +@StandardSchemaResponse(ProductResponse) +create(@Body() input: CreateProduct): ProductResponse { + return { + id: 1, + ...input, + publishedAt: new Date(), + }; +} ``` -The verification packs this package, installs the tarball into an isolated -copy of the example, builds through Nest's CLI, and exercises the HTTP API. - -## How it works +A schema class is a runtime metadata adapter, not an instantiated transport object. The controller receives the parsed plain value returned by the schema. The schema remains the source of truth. -### Requests +`createSchemaClass()` gives the class instance the schema output type used by request handlers. `createResponseSchemaClass()` gives it the schema input type accepted from response handlers. Both retain the original raw schema for delegation to Nest and Swagger. -1. TypeScript emits the concrete DTO class as Nest parameter metadata. -2. With compiler `swagger: true`, zero-argument whole-object request decorators - are emitted with native `{ schema: Dto.schema }` metadata. -3. Otherwise, `StandardSchemaDtoValidationPipe` discovers the schema stored on - the reflected DTO class. -4. Nest's native `StandardSchemaValidationPipe` parses the value exactly once. -5. The controller receives the parsed output, including schema-defined - coercions, transforms, defaults, and key handling. +## Application setup -Automatic discovery requires the normal Nest TypeScript decorator metadata options, including `experimentalDecorators` and `emitDecoratorMetadata`. - -This also means interfaces and type aliases cannot provide automatic schema lookup: +Register the module once in the root application module: ```ts -type CreateProduct = z.output; +import { Module } from '@nestjs/common'; +import { StandardSchemaModule } from '@nestm/standard-schema'; -// CreateProduct is erased at runtime, so use Nest's explicit native form: -create( - @Body({ schema: CreateProductSchema }) body: CreateProduct, -) {} +@Module({ + imports: [StandardSchemaModule.forRoot()], +}) +export class AppModule {} ``` -### Responses - -At build time, the optional compiler plugin finds concrete `@Controller()` -route methods with explicit return annotations. When the final type is a class -created by `createStandardSchemaResponseDto(...)`, it injects runtime response -serialization without changing declaration output. In Swagger mode, it also -adds the success status, output Standard Schema, and array shape. Existing -success `@Api*Response({ description })` metadata is merged rather than -discarded. +The module globally registers: -At runtime, `@StandardSchemaResponse(...)` resolves the class to its schema and composes Nest's native `@SerializeOptions({ schema })` metadata. The serializer registered by `StandardSchemaModule` validates and parses object responses, and applies the item schema to array responses. +- `SchemaClassValidationPipe`, a thin extension that discovers schemas from reflected schema classes before delegating to Nest's native `StandardSchemaValidationPipe`. +- Nest's native `StandardSchemaSerializerInterceptor`. -Outbound data that does not satisfy the response schema is a server contract error; it is not converted into a client validation error. +Explicit `{ schema }` request metadata always takes priority over a reflected schema class. -### Compiler plugin contract - -The plugin infers one response item DTO from these explicit annotations: - -| Return annotation | Behavior | -| ---------------------------------------- | ------------------------------------ | -| `ProductResponseDto` | Infer the DTO schema | -| `Promise` | Unwrap `Promise` | -| `ProductResponseDto[]` | Infer the array item schema | -| `readonly ProductResponseDto[]` | Infer the array item schema | -| `Array` | Infer the array item schema | -| `Promise` | Unwrap `Promise` and one array layer | -| `Promise` | Unwrap `Promise` and one array layer | -| `Promise>` | Unwrap `Promise` and one array layer | - -A direct `import type { ProductResponseDto }` is promoted to a value import in emitted JavaScript when it is safe to do so. The plugin only infers classes created by `createStandardSchemaResponseDto(...)`; request DTOs, interfaces, type aliases, anonymous object types, primitives, and unrelated classes are ignored. - -The plugin also skips: - -- methods without an explicit return annotation; -- methods without a Nest HTTP route decorator; -- `void`, `Promise`, and 204 routes; -- handlers using raw `@Res()` or `@Response()` (literal - `{ passthrough: true }` remains eligible); and -- routes already covered by method- or controller-level `@StandardSchemaResponse(...)` or `@SerializeOptions(...)`. - -With `"swagger": true`, request inference supports a concrete -`createStandardSchemaDto(...)` subclass on zero-argument whole-object -`@Body()`, `@Query()`, or `@Param()`. A direct type-only import is promoted to a -runtime import when its export chain is safe. Native decorator options that -already contain `schema` win. - -Property-bound request DTO decorators, DTO unions, generic wrappers, tuples, -arrays of request DTOs, nested response arrays, and statically unresolved -`@HttpCode(...)` values are ambiguous. Under the default -`"onAmbiguous": "error"` they stop the build with an explicit-decorator -escape hatch. Primitives, streams, raw responses, and 204 routes remain -untouched. - -By default, response DTO contracts that are visible but unsafe to reduce to one schema stop the build with guidance to add explicit metadata. This includes unions, intersections, tuples, nested arrays, unresolved generics, structural envelopes such as `Page`, and DTOs that cannot be referenced safely at runtime. Prefer one concrete response DTO backed by a union or envelope schema: +Both integrations can be configured or disabled independently: ```ts -class ProductPageResponseDto extends createStandardSchemaResponseDto( - ProductPageResponseSchema, -) {} +StandardSchemaModule.forRoot({ + validation: { + transform: true, + }, + serialization: false, +}); ``` -To leave ambiguous routes untouched instead, set `"onAmbiguous": "skip"` in the plugin options. An explicit response decorator remains the escape hatch and always overrides inference. +Do not register duplicate global Standard Schema pipes or serializers. Schemas with non-idempotent transforms would otherwise be parsed more than once. -### Compiler compatibility +## OpenAPI -Automatic request/response metadata currently requires `nest build` with the -Nest CLI `tsc` builder. Plain `tsc`, Vitest, ts-jest, SWC, webpack, and rspack -do not automatically load this plugin. Use explicit metadata when building -through those paths. - -The normal package entry is ESM. Only the compiler subpath is CommonJS for the Nest CLI loader, and it is isolated from the runtime entry so applications that do not enable the plugin do not load TypeScript. Continue using `.js` suffixes for local imports in NodeNext ESM source. - -The compiler plugin requires the JavaScript compiler API exposed by TypeScript -5.5 through 6.x. TypeScript 7 uses the native compiler and does not expose that -API, so TypeScript 7 applications should omit the plugin and use explicit -`@StandardSchemaResponse(...)` metadata. The runtime DTO, validation, and -serialization integrations support TypeScript 7. - -## API - -### `createStandardSchemaDto(schema)` - -Creates a runtime DTO base class backed by a Standard Schema whose parsed output is an object. +`@nestm/standard-schema/swagger` combines runtime response serialization and Nest Swagger metadata: ```ts -class SearchQueryDto extends createStandardSchemaDto(SearchQuerySchema) {} -``` - -Extend the returned class so Nest can reflect the concrete DTO type from `@Body()`, `@Query()`, or `@Param()`. - -The parsed output must be an object. A scalar parameter such as an ID should keep Nest's explicit native form: +import { ApiStandardSchemaResponse } from '@nestm/standard-schema/swagger'; -```ts -findOne( - @Param('id', { schema: ProductIdSchema }) id: number, -) {} +@Post() +@ApiStandardSchemaResponse(ProductResponseSchema, { + description: 'Product created.', + status: 201, +}) +create( + @Body({ schema: CreateProductSchema }) input: CreateProduct, +): ProductResponse { + // ... +} ``` -For a DTO-discovered route parameter, validate the whole params object instead: +Raw schemas and response schema classes are both accepted. Pass `isArray: true` when the response is an array of items. -```ts -class ProductParamsDto extends createStandardSchemaDto( - z.object({ id: z.coerce.number().int().positive() }), -) {} - -findOne(@Param() params: ProductParamsDto) {} -``` +Pass an explicit `status` when writing `@ApiStandardSchemaResponse` by hand. Without one, Nest Swagger stores the schema under the `default` response key. -Likewise, `@Body() items: ItemDto[]` reflects only the `Array` constructor. Attach an explicit array schema or create one DTO carrier whose schema parses the whole array. +## Optional Nest CLI plugin -### `createStandardSchemaResponseDto(schema)` +The compiler plugin preserves automatic response serialization and OpenAPI metadata when controllers use schema-class return annotations: -Creates a response DTO base class whose instance type is `StandardSchemaV1.InferInput`. - -```ts -class ProductResponseDto extends createStandardSchemaResponseDto( - ProductResponseSchema, -) {} +```json +{ + "$schema": "https://json.schemastore.org/nest-cli", + "compilerOptions": { + "builder": "tsc", + "plugins": [ + { + "name": "@nestm/standard-schema", + "options": { + "controllerFileNameSuffix": [".controller.ts", ".controller.mts"], + "onAmbiguous": "error", + "swagger": true + } + } + ] + } +} ``` -Annotate a handler return value with the concrete class. Nest's native serializer accepts that input and sends `StandardSchemaV1.InferOutput` to the client. The compiler plugin only infers response metadata from DTOs created by this factory. +With the plugin enabled: -This separate input type matters for schemas that transform or encode values. It lets a handler return a `Date`, for example, while the serialized client contract exposes a string. +- A zero-argument whole-object `@Body()`, `@Query()`, or `@Param()` using a request schema class receives native `{ schema: Class.schema }` metadata. +- A concrete response schema-class return annotation receives runtime `@StandardSchemaResponse(...)` metadata. +- With `swagger: true`, request and response schemas, success status, and one response array layer are documented. +- Explicit native, package, or Swagger metadata always wins. -### `StandardSchemaDtoValidationPipe` +The plugin supports concrete response classes, `Promise`, `Class[]`, `readonly Class[]`, and one combination of `Promise` plus an array. Ambiguous unions, intersections, tuples, nested arrays, and generic envelopes require an explicit response decorator by default. Set `onAmbiguous` to `"skip"` to leave them untouched. -A DTO-aware extension of Nest's native `StandardSchemaValidationPipe`. It supplies the schema stored on a DTO class when Nest parameter metadata does not already contain an explicit schema. +Automatic compiler metadata currently requires the Nest CLI `tsc` builder with TypeScript 5.5 through 6.x. TypeScript 7 applications can use all runtime APIs with explicit request and response schema metadata, but its native compiler does not expose the transformer API used by this plugin. -Use `StandardSchemaModule.forRoot()` for normal application setup. The pipe is exported for applications that need to compose their own global providers. +## API -### `@StandardSchemaResponse(DtoOrSchema)` +### `createSchemaClass(schema)` -A controller or method decorator that resolves either: +Creates a reflectable class whose instance type is `StandardSchemaV1.InferOutput`. The parsed output must be an object. -- a class created with `createStandardSchemaDto(...)` or `createStandardSchemaResponseDto(...)`; or -- a Standard Schema object. +### `createResponseSchemaClass(schema)` -It then supplies that schema through Nest's native serialization options. +Creates a reflectable response class whose instance type is `StandardSchemaV1.InferInput`. The schema input must be an object. -An optional second argument accepts `validateOptions`, which is passed through to Nest's native serializer: +### `SchemaClassValidationPipe` -```ts -@StandardSchemaResponse(ProductResponseDto, { - validateOptions: { - // Standard Schema validation options - }, -}) -``` +Discovers a raw schema from reflected schema-class metadata and delegates to Nest's native validation pipe. Normal applications should register it through `StandardSchemaModule.forRoot()`. -### `@nestm/standard-schema/swagger` +### `@StandardSchemaResponse(schemaOrClass, options?)` -The optional subpath exports -`ApiStandardSchemaResponse(source, options)`. Its options combine -`@nestjs/swagger` response metadata (`status`, `description`, `isArray`, -examples, headers, and links) with the native serializer's -`validateOptions`. +Attaches a raw schema or schema class through Nest's native serialization metadata. `validateOptions` is forwarded to the schema's `~standard.validate()` call. -It also exports -`withStandardSchemaResponseArrays(standardSchemaConverter)`, which decorates a -custom Nest Swagger converter with array-response handling while preserving the -converter's component schemas. This wrapper is only needed for schemas that -depend on the custom converter instead of exposing the Standard JSON Schema -conversion protocol themselves. +### `@ApiStandardSchemaResponse(schemaOrClass, options?)` -Importing this subpath requires the optional `@nestjs/swagger` peer. The root -runtime entry and compiler-only entry do not load Swagger. +Available from `@nestm/standard-schema/swagger`. Combines `@StandardSchemaResponse` with `@nestjs/swagger` response metadata. ### `StandardSchemaModule.forRoot(options?)` -Returns a Nest dynamic module that globally registers: - -- `StandardSchemaDtoValidationPipe`; and -- Nest's native Standard Schema serializer. - -Import it once at the application root. - -Do not also register Nest's native global Standard Schema pipe or serializer separately. Explicitly annotated values could otherwise be parsed twice, which is observable for non-idempotent transforms. - -Both integrations are enabled by default. `forRoot` also accepts Nest's native option objects, or `false` to skip one of the global providers: - -```ts -interface StandardSchemaModuleOptions { - validation?: false | StandardSchemaValidationPipeOptions; - serialization?: false | StandardSchemaSerializerInterceptorOptions; -} -``` - -For example, an application that registers its own response interceptor can disable only the module's serializer: - -```ts -StandardSchemaModule.forRoot({ - serialization: false, -}); -``` +Registers validation and serialization globally. `validation` and `serialization` accept their corresponding native Nest option objects or `false`. ### Low-level helpers -`getStandardSchema`, `isStandardSchema`, `isStandardSchemaDto`, `isStandardSchemaResponseDto`, `STANDARD_SCHEMA_DTO`, `STANDARD_SCHEMA_RESPONSE_DTO`, `StandardSchemaDtoClass`, `StandardSchemaResponseDtoClass`, and `StandardSchemaSource` are exported for authors building custom integrations. Application code should normally use the DTO factories, module, pipe, response decorator, and optional compiler plugin instead. - -## Native NestJS and this package - -Use native NestJS directly when explicit schema metadata is the clearest fit: - -```ts -@Body({ schema: CreateProductSchema }) -``` - -Use this package when your team wants runtime DTO classes and the shorter parameter syntax: - -```ts -@Body() body: CreateProductDto -``` +`getStandardSchema`, `isStandardSchema`, `isSchemaClass`, `isResponseSchemaClass`, `SchemaClass`, `ResponseSchemaClass`, `StandardSchemaSource`, `STANDARD_SCHEMA_CLASS`, and `STANDARD_SCHEMA_RESPONSE_CLASS` are available for custom integrations. -For responses, choose explicit `@StandardSchemaResponse(...)` metadata or enable the compiler plugin and use a response DTO return annotation. In every case, Nest's native Standard Schema components perform the request and response parsing. This package is an adapter for metadata, not a replacement validation engine and not a Zod-specific DTO layer. +## Migrating from 0.1 alpha -## Difference from `nestjs-zod` +This release intentionally provides no compatibility aliases: -[`nestjs-zod`](https://github.com/BenLorantfy/nestjs-zod) is a mature Zod-specific integration with its own validation pipe, serializer, OpenAPI support, and codec behavior. It calls Zod's parsing APIs directly. +| 0.1 alpha | Schema-first API | +| ---------------------------------- | ------------------------------------------------ | +| `createStandardSchemaDto` | `createSchemaClass` | +| `createStandardSchemaResponseDto` | `createResponseSchemaClass` | +| `StandardSchemaDtoValidationPipe` | `SchemaClassValidationPipe` | +| `StandardSchemaDtoClass` | `SchemaClass` | +| `StandardSchemaResponseDtoClass` | `ResponseSchemaClass` | +| `isStandardSchemaDto` | `isSchemaClass` | +| `isStandardSchemaResponseDto` | `isResponseSchemaClass` | +| `STANDARD_SCHEMA_DTO` | `STANDARD_SCHEMA_CLASS` | +| `STANDARD_SCHEMA_RESPONSE_DTO` | `STANDARD_SCHEMA_RESPONSE_CLASS` | +| `withStandardSchemaResponseArrays` | Removed; Nest Swagger 12 handles arrays natively | -This package has a narrower purpose for NestJS 12: it makes runtime DTO classes ergonomic and can inject native response metadata at compile time while delegating execution to Nest's `StandardSchemaValidationPipe` and `StandardSchemaSerializerInterceptor`. It has no Zod runtime dependency and can carry any schema that implements Standard Schema. +Rename `*.dto.ts` files to `*.schemas.ts` or `*.contracts.ts` and remove `Dto` suffixes from schema-class names. Existing 0.1 alpha releases remain available for applications that need the previous API. -## Current scope +## Scope -The package focuses on request DTO discovery, response schema metadata, and an -optional bridge to Nest Swagger's native Standard Schema support. It does not -define an application response envelope or recover arbitrary schemas from -erased interfaces, aliases, or structural types. +The package is a metadata and setup adapter. It does not implement a validation engine, depend on a particular schema vendor, instantiate schema classes, define an application response envelope, or recover runtime schemas from erased aliases and interfaces. ## Compatibility - NestJS 12 +- Nest Swagger 12 for optional OpenAPI integration - Node.js 22.12 or newer -- Standard Schema-compatible schema libraries -- TypeScript 5.5 through 7.x for runtime DTO, validation, and serialization APIs -- TypeScript 5.5 through 6.x when the optional compiler plugin is enabled - -Review NestJS and package release notes before upgrading production applications. - -## Upstream references - -- [NestJS 12 Standard Schema integration](https://github.com/nestjs/nest/pull/16391) -- [Standard Schema specification](https://standardschema.dev/schema) - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for local setup and pull request guidance. - -## Security - -See [SECURITY.md](./SECURITY.md) for reporting instructions. - -## License - -[MIT](./LICENSE) © 2026 Kauan Guesser +- TypeScript 5.5 through 7.x for runtime APIs +- TypeScript 5.5 through 6.x for the optional compiler plugin diff --git a/examples/nest-cli-zod/README.md b/examples/nest-cli-zod/README.md index 1f00311..548d9d0 100644 --- a/examples/nest-cli-zod/README.md +++ b/examples/nest-cli-zod/README.md @@ -18,7 +18,7 @@ No response decorator is needed on the controller: ```ts @Get() -findAll(@Query() query: ListProductsQueryDto): ProductResponseDto[] { +findAll(@Query() query: ListProductsQuery): ProductResponse[] { return this.productsService.findAll(query); } ``` @@ -53,7 +53,7 @@ installs the tarball as a real dependency, builds it through the Nest CLI `tsc` builder, runs its HTTP and OpenAPI smoke tests, proves transforms and array responses at the artifact boundary, and verifies that an ambiguous response union fails the packed consumer build. It also performs a second -build without the plugin to prove that request DTO discovery remains +build without the plugin to prove that request schema-class discovery remains independent while automatic response serialization is opt-in. ## Try the API diff --git a/examples/nest-cli-zod/src/products/product.dto.ts b/examples/nest-cli-zod/src/products/product.schemas.ts similarity index 71% rename from examples/nest-cli-zod/src/products/product.dto.ts rename to examples/nest-cli-zod/src/products/product.schemas.ts index d217aff..ecdd2c2 100644 --- a/examples/nest-cli-zod/src/products/product.dto.ts +++ b/examples/nest-cli-zod/src/products/product.schemas.ts @@ -1,6 +1,6 @@ import { - createStandardSchemaDto, - createStandardSchemaResponseDto, + createSchemaClass, + createResponseSchemaClass, } from '@nestm/standard-schema'; import { z } from 'zod'; @@ -10,9 +10,7 @@ const CreateProductSchema = z.object({ active: z.boolean().default(true), }); -export class CreateProductDto extends createStandardSchemaDto( - CreateProductSchema, -) {} +export class CreateProduct extends createSchemaClass(CreateProductSchema) {} const ListProductsQuerySchema = z.object({ search: z.string().trim().min(1).optional(), @@ -21,7 +19,7 @@ const ListProductsQuerySchema = z.object({ offset: z.coerce.number().int().nonnegative().default(0), }); -export class ListProductsQueryDto extends createStandardSchemaDto( +export class ListProductsQuery extends createSchemaClass( ListProductsQuerySchema, ) {} @@ -29,9 +27,7 @@ const ProductParamsSchema = z.object({ id: z.coerce.number().int().positive(), }); -export class ProductParamsDto extends createStandardSchemaDto( - ProductParamsSchema, -) {} +export class ProductParams extends createSchemaClass(ProductParamsSchema) {} const DateToIsoStringSchema = z.codec(z.date(), z.iso.datetime(), { decode: (value) => value.toISOString(), @@ -47,7 +43,7 @@ const ProductResponseSchema = z.object({ updatedAt: DateToIsoStringSchema, }); -export class ProductResponseDto extends createStandardSchemaResponseDto( +export class ProductResponse extends createResponseSchemaClass( ProductResponseSchema, ) {} @@ -55,6 +51,6 @@ const ProductSummaryResponseSchema = z.object({ count: z.number().int().nonnegative(), }); -export class ProductSummaryResponseDto extends createStandardSchemaResponseDto( +export class ProductSummaryResponse extends createResponseSchemaClass( ProductSummaryResponseSchema, ) {} diff --git a/examples/nest-cli-zod/src/products/products.controller.ts b/examples/nest-cli-zod/src/products/products.controller.ts index 68b55e7..d8b9cd6 100644 --- a/examples/nest-cli-zod/src/products/products.controller.ts +++ b/examples/nest-cli-zod/src/products/products.controller.ts @@ -10,12 +10,12 @@ import { ApiCreatedResponse, ApiOkResponse } from '@nestjs/swagger'; import { ApiStandardSchemaResponse } from '@nestm/standard-schema/swagger'; import { - CreateProductDto, - ListProductsQueryDto, - ProductParamsDto, - type ProductResponseDto, - ProductSummaryResponseDto, -} from './product.dto.js'; + CreateProduct, + ListProductsQuery, + ProductParams, + type ProductResponse, + ProductSummaryResponse, +} from './product.schemas.js'; import { ProductsService } from './products.service.js'; @ApiController('products') @@ -24,32 +24,32 @@ export class ProductsController { @Create() @ApiCreatedResponse({ description: 'Product created.' }) - create(@Payload() input: CreateProductDto): ProductResponseDto { + create(@Payload() input: CreateProduct): ProductResponse { return this.productsService.create(input); } @Read() @ApiOkResponse({ description: 'Products returned.' }) async findAll( - @Search() query: ListProductsQueryDto, - ): Promise { + @Search() query: ListProductsQuery, + ): Promise { return this.productsService.findAll(query); } @Read('summary') - @ApiStandardSchemaResponse(ProductSummaryResponseDto, { + @ApiStandardSchemaResponse(ProductSummaryResponse, { description: 'Product summary returned.', status: 200, }) - getSummary(): ProductSummaryResponseDto | ProductResponseDto { + getSummary(): ProductSummaryResponse | ProductResponse { return this.productsService.getSummary(); } @Read(':id') @ApiOkResponse({ description: 'Product returned.' }) async findOne( - @RouteParams() params: ProductParamsDto, - ): Promise { + @RouteParams() params: ProductParams, + ): Promise { return this.productsService.findOne(params.id); } } diff --git a/examples/nest-cli-zod/src/products/products.service.ts b/examples/nest-cli-zod/src/products/products.service.ts index 131aa9c..95fd5b5 100644 --- a/examples/nest-cli-zod/src/products/products.service.ts +++ b/examples/nest-cli-zod/src/products/products.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import type { CreateProductDto, ListProductsQueryDto } from './product.dto.js'; +import type { CreateProduct, ListProductsQuery } from './product.schemas.js'; export interface ProductRecord { readonly id: number; @@ -23,7 +23,7 @@ export class ProductsService { private nextId = 1; private revision = 0; - create(input: CreateProductDto): ProductRecord { + create(input: CreateProduct): ProductRecord { const now = new Date(); const product: ProductRecord = { id: this.nextId, @@ -39,7 +39,7 @@ export class ProductsService { return product; } - findAll(query: ListProductsQueryDto): ProductRecord[] { + findAll(query: ListProductsQuery): ProductRecord[] { const normalizedSearch = query.search?.toLowerCase(); const filtered = [...this.products.values()].filter((product) => { const matchesSearch = diff --git a/package.json b/package.json index 0a0de31..2af685c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@nestm/standard-schema", "version": "0.1.0-alpha.10", - "description": "DTO ergonomics for NestJS 12's native Standard Schema validation and serialization.", + "description": "Schema-first validation, serialization, and OpenAPI integration for NestJS using Standard Schema.", "license": "MIT", "author": "Kauan Guesser", "type": "module", @@ -50,9 +50,11 @@ "keywords": [ "nestjs", "standard-schema", - "dto", "validation", "serialization", + "openapi", + "schema-first", + "type-safe", "zod", "valibot", "arktype" diff --git a/scripts/test-packed-consumer.mjs b/scripts/test-packed-consumer.mjs index 79664b4..2eec2da 100644 --- a/scripts/test-packed-consumer.mjs +++ b/scripts/test-packed-consumer.mjs @@ -187,18 +187,16 @@ function assertCompilerOutput(root) { 'utf8', ); - if ( - !pluginJavaScript.includes('ApiStandardSchemaResponse(ProductResponseDto') - ) { + if (!pluginJavaScript.includes('ApiStandardSchemaResponse(ProductResponse')) { throw new Error( 'Nest CLI build did not inject response and Swagger schema metadata.', ); } for (const expectedParameterMetadata of [ - 'Payload({ schema: CreateProductDto.schema })', - 'Search({ schema: ListProductsQueryDto.schema })', - 'RouteParams({ schema: ProductParamsDto.schema })', + 'Payload({ schema: CreateProduct.schema })', + 'Search({ schema: ListProductsQuery.schema })', + 'RouteParams({ schema: ProductParams.schema })', ]) { if (!pluginJavaScript.includes(expectedParameterMetadata)) { throw new Error( @@ -238,14 +236,14 @@ function verifyPackedAmbiguity(root) { ` import { Controller, Get } from '@nestjs/common'; import type { - ProductResponseDto, - ProductSummaryResponseDto, -} from './product.dto.js'; + ProductResponse, + ProductSummaryResponse, +} from './product.schemas.js'; @Controller('ambiguous') export class AmbiguousController { @Get() - find(): ProductResponseDto | ProductSummaryResponseDto { + find(): ProductResponse | ProductSummaryResponse { throw new Error('not executed'); } } @@ -280,7 +278,9 @@ export class AmbiguousController { } if ( - !output.includes('union response types require one concrete response DTO') + !output.includes( + 'union response types require one concrete response schema class', + ) ) { throw new Error( `Packed consumer failed for an unexpected reason:\n${output}`, diff --git a/src/create-response-schema-class.ts b/src/create-response-schema-class.ts new file mode 100644 index 0000000..d34bced --- /dev/null +++ b/src/create-response-schema-class.ts @@ -0,0 +1,32 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +import { + isStandardSchema, + STANDARD_SCHEMA_RESPONSE_CLASS, + type ResponseSchemaClass, +} from './schema.js'; + +/** + * Creates a runtime response class backed by a Standard Schema. + * + * The returned class is itself a Standard Schema and its instance type is the + * schema input accepted from a response handler. Nest's serializer parses that + * value into the schema output sent to the client. + */ +export function createResponseSchemaClass< + const Schema extends StandardSchemaV1, +>(schema: Schema): ResponseSchemaClass { + if (!isStandardSchema(schema)) { + throw new TypeError( + 'createResponseSchemaClass() expected a Standard Schema.', + ); + } + + class GeneratedResponseSchemaClass { + static readonly ['~standard'] = schema['~standard']; + static readonly [STANDARD_SCHEMA_RESPONSE_CLASS] = true as const; + static readonly schema = schema; + } + + return GeneratedResponseSchemaClass as unknown as ResponseSchemaClass; +} diff --git a/src/create-schema-class.ts b/src/create-schema-class.ts new file mode 100644 index 0000000..dfc6729 --- /dev/null +++ b/src/create-schema-class.ts @@ -0,0 +1,30 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +import { + isStandardSchema, + STANDARD_SCHEMA_CLASS, + type SchemaClass, +} from './schema.js'; + +/** + * Creates a runtime class backed by a Standard Schema. + * + * The returned class is itself a Standard Schema and its instance type is the + * schema's parsed object output. Extending it gives Nest a concrete metatype + * from which the schema-class validation pipe can discover the schema. + */ +export function createSchemaClass< + const Schema extends StandardSchemaV1, +>(schema: Schema): SchemaClass { + if (!isStandardSchema(schema)) { + throw new TypeError('createSchemaClass() expected a Standard Schema.'); + } + + class GeneratedSchemaClass { + static readonly ['~standard'] = schema['~standard']; + static readonly [STANDARD_SCHEMA_CLASS] = true as const; + static readonly schema = schema; + } + + return GeneratedSchemaClass as unknown as SchemaClass; +} diff --git a/src/create-standard-schema-dto.ts b/src/create-standard-schema-dto.ts deleted file mode 100644 index ec04f31..0000000 --- a/src/create-standard-schema-dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; - -import { - isStandardSchema, - STANDARD_SCHEMA_DTO, - type StandardSchemaDtoClass, -} from './schema.js'; - -/** - * Creates a runtime DTO base class whose instance type is the schema's parsed - * object output. - * - * Nest reflects a concrete subclass at runtime, allowing the DTO-aware pipe to - * discover the schema from `@Body()`, `@Query()`, or whole-object `@Param()` - * metadata. - */ -export function createStandardSchemaDto< - const Schema extends StandardSchemaV1, ->(schema: Schema): StandardSchemaDtoClass { - if (!isStandardSchema(schema)) { - throw new TypeError( - 'createStandardSchemaDto() expected a Standard Schema.', - ); - } - - class StandardSchemaDto { - static readonly [STANDARD_SCHEMA_DTO] = true as const; - static readonly schema = schema; - } - - return StandardSchemaDto as unknown as StandardSchemaDtoClass; -} diff --git a/src/create-standard-schema-response-dto.spec.ts b/src/create-standard-schema-response-dto.spec.ts deleted file mode 100644 index 2fa1648..0000000 --- a/src/create-standard-schema-response-dto.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; -import { z } from 'zod'; - -import { createStandardSchemaDto } from './create-standard-schema-dto.js'; -import { createStandardSchemaResponseDto } from './create-standard-schema-response-dto.js'; -import { - getStandardSchema, - isStandardSchemaDto, - isStandardSchemaResponseDto, - STANDARD_SCHEMA_RESPONSE_DTO, - type StandardSchemaResponseDtoClass, - type StandardSchemaSource, -} from './schema.js'; - -const TransformingSchema = z.object({ - name: z.string().trim(), - publishedAt: z.date().transform((value) => value.toISOString()), -}); - -class RequestDto extends createStandardSchemaDto(TransformingSchema) {} - -class ResponseDto extends createStandardSchemaResponseDto(TransformingSchema) {} - -describe(createStandardSchemaResponseDto.name, () => { - it('carries the original schema on a response-branded runtime class', () => { - expect(ResponseDto.schema).toBe(TransformingSchema); - expect(ResponseDto[STANDARD_SCHEMA_RESPONSE_DTO]).toBe(true); - expect(isStandardSchemaResponseDto(ResponseDto)).toBe(true); - expect(isStandardSchemaDto(ResponseDto)).toBe(false); - }); - - it('uses schema input for responses while request DTOs keep schema output', () => { - expectTypeOf().toEqualTypeOf<{ - name: string; - publishedAt: Date; - }>(); - expectTypeOf< - StandardSchemaV1.InferOutput - >().toEqualTypeOf<{ - name: string; - publishedAt: string; - }>(); - expectTypeOf().toEqualTypeOf<{ - name: string; - publishedAt: string; - }>(); - expectTypeOf(ResponseDto).toMatchTypeOf< - StandardSchemaResponseDtoClass - >(); - }); - - it('resolves the carried response schema', () => { - expect(getStandardSchema(ResponseDto)).toBe(TransformingSchema); - expectTypeOf(getStandardSchema(ResponseDto)).toEqualTypeOf< - typeof TransformingSchema - >(); - }); - - it('rejects unbranded response schema carriers', () => { - class UnbrandedResponseDto { - static readonly schema = TransformingSchema; - } - - expect(isStandardSchemaResponseDto(UnbrandedResponseDto)).toBe(false); - expect(() => - getStandardSchema( - UnbrandedResponseDto as unknown as StandardSchemaSource, - ), - ).toThrow(TypeError); - }); - - it('rejects invalid schemas passed from untyped JavaScript', () => { - expect(() => - createStandardSchemaResponseDto( - {} as unknown as typeof TransformingSchema, - ), - ).toThrow(TypeError); - }); -}); diff --git a/src/create-standard-schema-response-dto.ts b/src/create-standard-schema-response-dto.ts deleted file mode 100644 index 44feea4..0000000 --- a/src/create-standard-schema-response-dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; - -import { - isStandardSchema, - STANDARD_SCHEMA_RESPONSE_DTO, - type StandardSchemaResponseDtoClass, -} from './schema.js'; - -/** - * Creates a runtime DTO base class whose instance type is the schema's input. - * - * A response handler returns the schema input, while Nest's native serializer - * validates that value and sends the schema output to the HTTP client. - */ -export function createStandardSchemaResponseDto< - const Schema extends StandardSchemaV1, ->(schema: Schema): StandardSchemaResponseDtoClass { - if (!isStandardSchema(schema)) { - throw new TypeError( - 'createStandardSchemaResponseDto() expected a Standard Schema.', - ); - } - - class StandardSchemaResponseDto { - static readonly [STANDARD_SCHEMA_RESPONSE_DTO] = true as const; - static readonly schema = schema; - } - - return StandardSchemaResponseDto as unknown as StandardSchemaResponseDtoClass; -} diff --git a/src/index.ts b/src/index.ts index 7d6feee..4937949 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,17 @@ -export { createStandardSchemaDto } from './create-standard-schema-dto.js'; -export { createStandardSchemaResponseDto } from './create-standard-schema-response-dto.js'; +export { createResponseSchemaClass } from './create-response-schema-class.js'; +export { createSchemaClass } from './create-schema-class.js'; export { getStandardSchema, + isResponseSchemaClass, + isSchemaClass, isStandardSchema, - isStandardSchemaDto, - isStandardSchemaResponseDto, - STANDARD_SCHEMA_DTO, - STANDARD_SCHEMA_RESPONSE_DTO, - type StandardSchemaDtoClass, - type StandardSchemaResponseDtoClass, + STANDARD_SCHEMA_CLASS, + STANDARD_SCHEMA_RESPONSE_CLASS, + type ResponseSchemaClass, + type SchemaClass, type StandardSchemaSource, } from './schema.js'; -export { StandardSchemaDtoValidationPipe } from './standard-schema-dto-validation.pipe.js'; +export { SchemaClassValidationPipe } from './schema-class-validation.pipe.js'; export { StandardSchemaResponse, type StandardSchemaResponseOptions, diff --git a/src/plugin/index.cts b/src/plugin/index.cts index 2a49b28..15fc897 100644 --- a/src/plugin/index.cts +++ b/src/plugin/index.cts @@ -70,7 +70,7 @@ interface DecoratorIdentity { readonly name: string; } -interface DtoReference { +interface SchemaClassReference { readonly classSymbol: ts.Symbol; readonly expression: ts.Expression; } @@ -79,7 +79,7 @@ type ReturnAnalysis = | { readonly kind: 'infer'; readonly isArray: boolean; - readonly reference: DtoReference; + readonly reference: SchemaClassReference; readonly status: number | undefined; } | { @@ -90,7 +90,7 @@ type RequestAnalysis = | { readonly kind: 'infer'; readonly decorator: ts.Decorator; - readonly reference: DtoReference; + readonly reference: SchemaClassReference; } | { readonly kind: 'skip'; @@ -171,7 +171,7 @@ function assertCompilerApiSupport(): void { typeof compiler.version === 'string' ? ` ${compiler.version}` : ''; throw new Error( - `${PACKAGE_NAME}/plugin requires the TypeScript 5.5 or 6.x compiler API; detected TypeScript${version}, which does not expose that API. TypeScript 7 applications can use the runtime DTO integration with explicit response metadata instead.`, + `${PACKAGE_NAME}/plugin requires the TypeScript 5.5 or 6.x compiler API; detected TypeScript${version}, which does not expose that API. TypeScript 7 applications can use the runtime schema-class integration with explicit response metadata instead.`, ); } @@ -822,10 +822,10 @@ function analyzeRequestParameter( } const declaredType = unwrapResponseType(parameter.type); - const containsDto = containsRequestDto(declaredType, checker); + const containsSchemaClass = containsRequestSchemaClass(declaredType, checker); if (requestDecorators.length > 1) { - return containsDto + return containsSchemaClass ? ambiguousRequest( options, parameter, @@ -839,7 +839,7 @@ function analyzeRequestParameter( const decorator = requestDecorators[0]; if (decorator === undefined || !ts.isCallExpression(decorator.expression)) { - return containsDto + return containsSchemaClass ? ambiguousRequest( options, parameter, @@ -859,7 +859,7 @@ function analyzeRequestParameter( firstArgument !== undefined && isPropertyBoundRequestArgument(firstArgument, checker) ) { - return containsDto + return containsSchemaClass ? ambiguousRequest( options, parameter, @@ -888,28 +888,28 @@ function analyzeRequestParameter( (ts.isTypeReferenceNode(declaredType) && (declaredType.typeArguments?.length ?? 0) > 0) ) { - return containsDto + return containsSchemaClass ? ambiguousRequest( options, parameter, method, sourceFile, - 'request unions, wrappers, tuples, and arrays require one concrete request DTO', + 'request unions, wrappers, tuples, and arrays require one concrete request schema class', ) : { kind: 'skip' }; } const resolvedType = checker.getTypeFromTypeNode(declaredType); - const requestClassSymbol = getRequestDtoClassSymbol(resolvedType, checker); + const requestClassSymbol = getRequestSchemaClassSymbol(resolvedType, checker); if (requestClassSymbol === undefined) { - return containsDto + return containsSchemaClass ? ambiguousRequest( options, parameter, method, sourceFile, - 'the request DTO cannot be reduced to one concrete runtime class', + 'the request schema class cannot be reduced to one concrete runtime class', ) : { kind: 'skip' }; } @@ -923,7 +923,7 @@ function analyzeRequestParameter( parameter, method, sourceFile, - 'the request DTO cannot be referenced as a concrete runtime class', + 'the request schema class cannot be referenced as a concrete runtime class', ); } @@ -942,7 +942,7 @@ function analyzeRequestParameter( parameter, method, sourceFile, - 'the request DTO is type-only or cannot be referenced safely at runtime', + 'the request schema class is type-only or cannot be referenced safely at runtime', ); } @@ -1002,7 +1002,7 @@ function ambiguousRequest( throw new Error( `${PACKAGE_NAME}/plugin: ${className}.${methodName}(${parameterName}): ${reason}. ` + - `Use a zero-argument @Body(), @Query(), or @Param() with one concrete request DTO, ` + + `Use a zero-argument @Body(), @Query(), or @Param() with one concrete request schema class, ` + `or add native { schema } metadata explicitly. ` + `(${sourceFile.fileName}:${position.line + 1}:${position.character + 1})`, ); @@ -1059,7 +1059,7 @@ function analyzeReturnType( promiseDepth > 1 || arrayDepth > 1 ) { - return containsResponseDto(current, checker) + return containsResponseSchemaClass(current, checker) ? ambiguous( options, method, @@ -1074,29 +1074,29 @@ function analyzeReturnType( } if (ts.isUnionTypeNode(current)) { - return containsResponseDto(current, checker) + return containsResponseSchemaClass(current, checker) ? ambiguous( options, method, sourceFile, - 'union response types require one concrete response DTO', + 'union response types require one concrete response schema class', ) : { kind: 'skip' }; } if (ts.isIntersectionTypeNode(current)) { - return containsResponseDto(current, checker) + return containsResponseSchemaClass(current, checker) ? ambiguous( options, method, sourceFile, - 'intersection response types require one concrete response DTO', + 'intersection response types require one concrete response schema class', ) : { kind: 'skip' }; } if (ts.isTupleTypeNode(current)) { - return containsResponseDto(current, checker) + return containsResponseSchemaClass(current, checker) ? ambiguous( options, method, @@ -1117,15 +1117,18 @@ function analyzeReturnType( ); } - const responseClassSymbol = getResponseDtoClassSymbol(resolvedType, checker); + const responseClassSymbol = getResponseSchemaClassSymbol( + resolvedType, + checker, + ); if (responseClassSymbol === undefined) { - if (containsResponseDtoInTypeArguments(current, checker)) { + if (containsResponseSchemaClassInTypeArguments(current, checker)) { return ambiguous( options, method, sourceFile, - 'response envelopes and generic wrappers require one concrete response DTO', + 'response envelopes and generic wrappers require one concrete response schema class', ); } @@ -1137,7 +1140,7 @@ function analyzeReturnType( options, method, sourceFile, - 'the response DTO cannot be referenced as a concrete runtime class', + 'the response schema class cannot be referenced as a concrete runtime class', ); } @@ -1155,7 +1158,7 @@ function analyzeReturnType( options, method, sourceFile, - 'the response DTO is type-only or cannot be referenced safely at runtime', + 'the response schema class is type-only or cannot be referenced safely at runtime', ); } @@ -1659,24 +1662,24 @@ function responseClassHasRuntimeDeclaration( ); } -function getResponseDtoClassSymbol( +function getResponseSchemaClassSymbol( type: ts.Type, checker: ts.TypeChecker, ): ts.Symbol | undefined { - return getDtoClassSymbol(type, checker, 'STANDARD_SCHEMA_RESPONSE_DTO'); + return getSchemaClassSymbol(type, checker, 'STANDARD_SCHEMA_RESPONSE_CLASS'); } -function getRequestDtoClassSymbol( +function getRequestSchemaClassSymbol( type: ts.Type, checker: ts.TypeChecker, ): ts.Symbol | undefined { - return getDtoClassSymbol(type, checker, 'STANDARD_SCHEMA_DTO'); + return getSchemaClassSymbol(type, checker, 'STANDARD_SCHEMA_CLASS'); } -function getDtoClassSymbol( +function getSchemaClassSymbol( type: ts.Type, checker: ts.TypeChecker, - brandName: 'STANDARD_SCHEMA_DTO' | 'STANDARD_SCHEMA_RESPONSE_DTO', + brandName: 'STANDARD_SCHEMA_CLASS' | 'STANDARD_SCHEMA_RESPONSE_CLASS', ): ts.Symbol | undefined { const symbol = type.getSymbol(); @@ -1695,7 +1698,7 @@ function getDtoClassSymbol( resolvedSymbol, declaration, ); - const dtoBrand = staticType.getProperties().find((property) => { + const schemaClassBrand = staticType.getProperties().find((property) => { return property.declarations?.some((propertyDeclaration) => { const propertyName = (propertyDeclaration as ts.NamedDeclaration).name; @@ -1719,17 +1722,17 @@ function getDtoClassSymbol( }); }); - if (dtoBrand === undefined) { + if (schemaClassBrand === undefined) { return undefined; } const brandDeclaration = - dtoBrand.valueDeclaration ?? dtoBrand.declarations?.[0]; + schemaClassBrand.valueDeclaration ?? schemaClassBrand.declarations?.[0]; if ( brandDeclaration === undefined || checker.typeToString( - checker.getTypeOfSymbolAtLocation(dtoBrand, brandDeclaration), + checker.getTypeOfSymbolAtLocation(schemaClassBrand, brandDeclaration), ) !== 'true' ) { return undefined; @@ -1738,21 +1741,21 @@ function getDtoClassSymbol( return resolvedSymbol; } -function containsRequestDto( +function containsRequestSchemaClass( typeNode: ts.TypeNode, checker: ts.TypeChecker, ): boolean { - return containsDto(typeNode, checker, getRequestDtoClassSymbol); + return containsSchemaClass(typeNode, checker, getRequestSchemaClassSymbol); } -function containsResponseDto( +function containsResponseSchemaClass( typeNode: ts.TypeNode, checker: ts.TypeChecker, ): boolean { - return containsDto(typeNode, checker, getResponseDtoClassSymbol); + return containsSchemaClass(typeNode, checker, getResponseSchemaClassSymbol); } -function containsDto( +function containsSchemaClass( typeNode: ts.TypeNode, checker: ts.TypeChecker, getClassSymbol: ( @@ -1769,37 +1772,37 @@ function containsDto( if (ts.isUnionTypeNode(unwrapped) || ts.isIntersectionTypeNode(unwrapped)) { return unwrapped.types.some((member) => - containsDto(member, checker, getClassSymbol), + containsSchemaClass(member, checker, getClassSymbol), ); } if (ts.isArrayTypeNode(unwrapped)) { - return containsDto(unwrapped.elementType, checker, getClassSymbol); + return containsSchemaClass(unwrapped.elementType, checker, getClassSymbol); } if (ts.isTupleTypeNode(unwrapped)) { return unwrapped.elements.some((element) => - containsDto(element, checker, getClassSymbol), + containsSchemaClass(element, checker, getClassSymbol), ); } return ( ts.isTypeReferenceNode(unwrapped) && (unwrapped.typeArguments?.some((argument) => - containsDto(argument, checker, getClassSymbol), + containsSchemaClass(argument, checker, getClassSymbol), ) ?? false) ); } -function containsResponseDtoInTypeArguments( +function containsResponseSchemaClassInTypeArguments( typeNode: ts.TypeNode, checker: ts.TypeChecker, ): boolean { return ( ts.isTypeReferenceNode(typeNode) && (typeNode.typeArguments?.some((argument) => - containsResponseDto(argument, checker), + containsResponseSchemaClass(argument, checker), ) ?? false) ); @@ -1920,7 +1923,7 @@ function isRedirectRoute( * Landing on a real status means sharing a response key with such a decorator, and the merge is * destructive in a way source order cannot fix: `ApiResponse` merges incoming over existing, then * `ResponseObjectFactory` short-circuits on `standardSchema` and omits `type`, so a hand-written - * `@ApiOkResponse({ type: LegacyDto })` loses `LegacyDto` with no diagnostic. Backing off is the + * `@ApiOkResponse({ type: LegacyClass })` loses `LegacyClass` with no diagnostic. Backing off is the * only correct answer — and it is what the inference path already does through * `hasExplicitContract`. * diff --git a/src/standard-schema-dto-validation.pipe.spec.ts b/src/schema-class-validation.pipe.spec.ts similarity index 72% rename from src/standard-schema-dto-validation.pipe.spec.ts rename to src/schema-class-validation.pipe.spec.ts index caeb8db..a20ac7e 100644 --- a/src/standard-schema-dto-validation.pipe.spec.ts +++ b/src/schema-class-validation.pipe.spec.ts @@ -2,8 +2,8 @@ import { BadRequestException, type ArgumentMetadata } from '@nestjs/common'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { z } from 'zod'; -import { createStandardSchemaDto } from './create-standard-schema-dto.js'; -import { StandardSchemaDtoValidationPipe } from './standard-schema-dto-validation.pipe.js'; +import { createSchemaClass } from './create-schema-class.js'; +import { SchemaClassValidationPipe } from './schema-class-validation.pipe.js'; const CreateProductSchema = z.object({ name: z.string().trim().min(1), @@ -11,7 +11,7 @@ const CreateProductSchema = z.object({ active: z.boolean().default(true), }); -class CreateProductDto extends createStandardSchemaDto(CreateProductSchema) {} +class CreateProduct extends createSchemaClass(CreateProductSchema) {} const AsyncVendorNeutralSchema: StandardSchemaV1 = { '~standard': { @@ -33,18 +33,18 @@ const AsyncVendorNeutralSchema: StandardSchemaV1 = { }, }; -class AsyncVendorNeutralDto extends createStandardSchemaDto( +class AsyncVendorNeutralInput extends createSchemaClass( AsyncVendorNeutralSchema, ) {} const bodyMetadata: ArgumentMetadata = { type: 'body', - metatype: CreateProductDto, + metatype: CreateProduct, }; -describe(StandardSchemaDtoValidationPipe.name, () => { - it('infers the DTO schema and returns Nest native parsed output', async () => { - const pipe = new StandardSchemaDtoValidationPipe(); +describe(SchemaClassValidationPipe.name, () => { + it('infers the class schema and returns Nest native parsed output', async () => { + const pipe = new SchemaClassValidationPipe(); const result = await pipe.transform( { @@ -63,7 +63,7 @@ describe(StandardSchemaDtoValidationPipe.name, () => { }); it('keeps an explicit native metadata schema as the highest priority', async () => { - const pipe = new StandardSchemaDtoValidationPipe(); + const pipe = new SchemaClassValidationPipe(); const explicitSchema = z.object({ page: z.coerce.number().int().positive(), }); @@ -80,15 +80,15 @@ describe(StandardSchemaDtoValidationPipe.name, () => { }); it('preserves Nest native validation exceptions', async () => { - const pipe = new StandardSchemaDtoValidationPipe(); + const pipe = new SchemaClassValidationPipe(); await expect( pipe.transform({ name: '', price: -1 }, bodyMetadata), ).rejects.toBeInstanceOf(BadRequestException); }); - it('passes values without an explicit or DTO-carried schema through', async () => { - const pipe = new StandardSchemaDtoValidationPipe(); + it('passes values without an explicit or class-carried schema through', async () => { + const pipe = new SchemaClassValidationPipe(); const input = { untouched: true }; const result = await pipe.transform(input, { @@ -100,7 +100,7 @@ describe(StandardSchemaDtoValidationPipe.name, () => { }); it('honors native pipe options such as transform: false', async () => { - const pipe = new StandardSchemaDtoValidationPipe({ + const pipe = new SchemaClassValidationPipe({ transform: false, }); const input = { @@ -114,13 +114,13 @@ describe(StandardSchemaDtoValidationPipe.name, () => { }); it('supports asynchronous, non-Zod Standard Schema implementations', async () => { - const pipe = new StandardSchemaDtoValidationPipe(); + const pipe = new SchemaClassValidationPipe(); const result = await pipe.transform( { value: '42' }, { type: 'body', - metatype: AsyncVendorNeutralDto, + metatype: AsyncVendorNeutralInput, }, ); diff --git a/src/standard-schema-dto-validation.pipe.ts b/src/schema-class-validation.pipe.ts similarity index 54% rename from src/standard-schema-dto-validation.pipe.ts rename to src/schema-class-validation.pipe.ts index 69c4d2b..d628dd9 100644 --- a/src/standard-schema-dto-validation.pipe.ts +++ b/src/schema-class-validation.pipe.ts @@ -4,21 +4,22 @@ import { type ArgumentMetadata, } from '@nestjs/common'; -import { isStandardSchemaDto } from './schema.js'; +import { isResponseSchemaClass, isSchemaClass } from './schema.js'; /** - * Adds DTO-carried schemas to Nest argument metadata, then delegates parsing - * and error handling to Nest's native `StandardSchemaValidationPipe`. + * Discovers a Standard Schema from a reflected schema class, then delegates + * parsing and error handling to Nest's native validation pipe. */ @Injectable() -export class StandardSchemaDtoValidationPipe extends StandardSchemaValidationPipe { +export class SchemaClassValidationPipe extends StandardSchemaValidationPipe { override transform( value: T, metadata: ArgumentMetadata, ): Promise { const schema = metadata.schema ?? - (isStandardSchemaDto(metadata.metatype) + (isSchemaClass(metadata.metatype) || + isResponseSchemaClass(metadata.metatype) ? metadata.metatype.schema : undefined); diff --git a/src/schema-class.spec.ts b/src/schema-class.spec.ts new file mode 100644 index 0000000..24531c5 --- /dev/null +++ b/src/schema-class.spec.ts @@ -0,0 +1,92 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { z } from 'zod'; + +import { createResponseSchemaClass } from './create-response-schema-class.js'; +import { createSchemaClass } from './create-schema-class.js'; +import { + getStandardSchema, + isResponseSchemaClass, + isSchemaClass, + isStandardSchema, + type ResponseSchemaClass, + type SchemaClass, +} from './schema.js'; + +const TransformingSchema = z.object({ + name: z.string().trim(), + publishedAt: z.date().transform((value) => value.toISOString()), +}); + +class ParsedProduct extends createSchemaClass(TransformingSchema) {} + +class ProductResponse extends createResponseSchemaClass(TransformingSchema) {} + +describe('schema classes', () => { + it('makes a request class a Standard Schema while preserving its source', () => { + expect(isStandardSchema(ParsedProduct)).toBe(true); + expect(isSchemaClass(ParsedProduct)).toBe(true); + expect(isResponseSchemaClass(ParsedProduct)).toBe(false); + expect(ParsedProduct.schema).toBe(TransformingSchema); + expect(ParsedProduct['~standard']).toBe(TransformingSchema['~standard']); + expect(getStandardSchema(ParsedProduct)).toBe(TransformingSchema); + expectTypeOf(ParsedProduct).toMatchTypeOf(); + expectTypeOf(ParsedProduct).toMatchTypeOf< + SchemaClass + >(); + }); + + it('uses schema output as the request class instance type', () => { + expectTypeOf().toEqualTypeOf<{ + name: string; + publishedAt: string; + }>(); + }); + + it('uses schema input as the response class instance type', () => { + expect(isStandardSchema(ProductResponse)).toBe(true); + expect(isSchemaClass(ProductResponse)).toBe(false); + expect(isResponseSchemaClass(ProductResponse)).toBe(true); + expect(ProductResponse.schema).toBe(TransformingSchema); + expect(getStandardSchema(ProductResponse)).toBe(TransformingSchema); + expectTypeOf().toEqualTypeOf<{ + name: string; + publishedAt: Date; + }>(); + expectTypeOf(ProductResponse).toMatchTypeOf< + ResponseSchemaClass + >(); + expectTypeOf< + StandardSchemaV1.InferOutput + >().toEqualTypeOf<{ + name: string; + publishedAt: string; + }>(); + }); + + it('accepts raw Standard Schemas without wrapping them', () => { + expect(getStandardSchema(TransformingSchema)).toBe(TransformingSchema); + expectTypeOf(getStandardSchema(TransformingSchema)).toEqualTypeOf< + typeof TransformingSchema + >(); + }); + + it('rejects invalid schemas passed from untyped JavaScript', () => { + expect(() => + createSchemaClass({} as unknown as typeof TransformingSchema), + ).toThrow(TypeError); + expect(() => + createResponseSchemaClass({} as unknown as typeof TransformingSchema), + ).toThrow(TypeError); + }); + + it('rejects unrelated classes and values', () => { + class UnrelatedClass {} + + expect(isStandardSchema(UnrelatedClass)).toBe(false); + expect(isStandardSchema({})).toBe(false); + expect(isStandardSchema(null)).toBe(false); + expect(() => + getStandardSchema(UnrelatedClass as unknown as typeof TransformingSchema), + ).toThrow(TypeError); + }); +}); diff --git a/src/schema.spec.ts b/src/schema.spec.ts deleted file mode 100644 index 3d125dc..0000000 --- a/src/schema.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { z } from 'zod'; - -import { createStandardSchemaDto } from './create-standard-schema-dto.js'; -import { - getStandardSchema, - isStandardSchema, - isStandardSchemaDto, - STANDARD_SCHEMA_DTO, - type StandardSchemaSource, -} from './schema.js'; - -const ExampleSchema = z.object({ - name: z.string().trim(), - quantity: z.coerce.number().int(), -}); - -class ExampleDto extends createStandardSchemaDto(ExampleSchema) {} - -describe('Standard Schema DTO carrier', () => { - it('carries the original schema on a branded runtime class', () => { - expect(ExampleDto.schema).toBe(ExampleSchema); - expect(ExampleDto[STANDARD_SCHEMA_DTO]).toBe(true); - expect(isStandardSchemaDto(ExampleDto)).toBe(true); - }); - - it('preserves the schema output as the DTO instance type', () => { - expectTypeOf().toEqualTypeOf<{ - name: string; - quantity: number; - }>(); - }); - - it('recognizes Standard Schema implementations', () => { - expect(isStandardSchema(ExampleSchema)).toBe(true); - expect(isStandardSchema({})).toBe(false); - expect(isStandardSchema(null)).toBe(false); - }); - - it('resolves either a DTO class or a schema', () => { - expect(getStandardSchema(ExampleDto)).toBe(ExampleSchema); - expect(getStandardSchema(ExampleSchema)).toBe(ExampleSchema); - expectTypeOf(getStandardSchema(ExampleDto)).toEqualTypeOf< - typeof ExampleSchema - >(); - expectTypeOf(getStandardSchema(ExampleSchema)).toEqualTypeOf< - typeof ExampleSchema - >(); - }); - - it('rejects unbranded schema-like classes', () => { - class UnbrandedDto { - static readonly schema = ExampleSchema; - } - - expect(isStandardSchemaDto(UnbrandedDto)).toBe(false); - expect(() => - getStandardSchema(UnbrandedDto as unknown as StandardSchemaSource), - ).toThrow(TypeError); - }); - - it('rejects invalid schemas passed from untyped JavaScript', () => { - expect(() => - createStandardSchemaDto({} as unknown as typeof ExampleSchema), - ).toThrow(TypeError); - }); -}); diff --git a/src/schema.ts b/src/schema.ts index 6a2fbd5..beba867 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,45 +1,52 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; -export const STANDARD_SCHEMA_DTO = Symbol.for('@nestm/standard-schema/dto'); -export const STANDARD_SCHEMA_RESPONSE_DTO = Symbol.for( - '@nestm/standard-schema/response-dto', +export const STANDARD_SCHEMA_CLASS = Symbol.for('@nestm/standard-schema/class'); +export const STANDARD_SCHEMA_RESPONSE_CLASS = Symbol.for( + '@nestm/standard-schema/response-class', ); /** - * Runtime class created by `createStandardSchemaDto`. + * Runtime class backed by a Standard Schema. * - * Its instance type is the schema's parsed object output. + * The class value is itself a Standard Schema, while its instance type is the + * parsed schema output received by a request handler. */ -export interface StandardSchemaDtoClass< +export interface SchemaClass< Schema extends StandardSchemaV1 = StandardSchemaV1< unknown, object >, +> extends StandardSchemaV1< + StandardSchemaV1.InferInput, + StandardSchemaV1.InferOutput > { new (): StandardSchemaV1.InferOutput; - readonly [STANDARD_SCHEMA_DTO]: true; + readonly [STANDARD_SCHEMA_CLASS]: true; readonly schema: Schema; } /** - * Runtime class created by `createStandardSchemaResponseDto`. + * Runtime class backed by a response Standard Schema. * - * Its instance type is the value accepted from the response handler, before - * the schema parses it into the serialized HTTP output. + * The class value is itself a Standard Schema, while its instance type is the + * value accepted from a response handler before serialization. */ -export interface StandardSchemaResponseDtoClass< +export interface ResponseSchemaClass< Schema extends StandardSchemaV1 = StandardSchemaV1< object, unknown >, +> extends StandardSchemaV1< + StandardSchemaV1.InferInput, + StandardSchemaV1.InferOutput > { new (): StandardSchemaV1.InferInput; - readonly [STANDARD_SCHEMA_RESPONSE_DTO]: true; + readonly [STANDARD_SCHEMA_RESPONSE_CLASS]: true; readonly schema: Schema; } export type StandardSchemaSource = - StandardSchemaV1 | StandardSchemaDtoClass | StandardSchemaResponseDtoClass; + StandardSchemaV1 | SchemaClass | ResponseSchemaClass; /** Returns whether a value implements Standard Schema V1. */ export function isStandardSchema(value: unknown): value is StandardSchemaV1 { @@ -56,51 +63,46 @@ export function isStandardSchema(value: unknown): value is StandardSchemaV1 { ); } -/** Returns whether a value is a branded Standard Schema DTO class. */ -export function isStandardSchemaDto( - value: unknown, -): value is StandardSchemaDtoClass { +export function isSchemaClass(value: unknown): value is SchemaClass { if (typeof value !== 'function') { return false; } const candidate = value as unknown as { - readonly [STANDARD_SCHEMA_DTO]?: unknown; + readonly [STANDARD_SCHEMA_CLASS]?: unknown; readonly schema?: unknown; }; return ( - candidate[STANDARD_SCHEMA_DTO] === true && + candidate[STANDARD_SCHEMA_CLASS] === true && isStandardSchema(candidate.schema) ); } -/** Returns whether a value is a branded Standard Schema response DTO class. */ -export function isStandardSchemaResponseDto( +export function isResponseSchemaClass( value: unknown, -): value is StandardSchemaResponseDtoClass { +): value is ResponseSchemaClass { if (typeof value !== 'function') { return false; } const candidate = value as unknown as { - readonly [STANDARD_SCHEMA_RESPONSE_DTO]?: unknown; + readonly [STANDARD_SCHEMA_RESPONSE_CLASS]?: unknown; readonly schema?: unknown; }; return ( - candidate[STANDARD_SCHEMA_RESPONSE_DTO] === true && + candidate[STANDARD_SCHEMA_RESPONSE_CLASS] === true && isStandardSchema(candidate.schema) ); } -/** Resolves a raw Standard Schema or the schema carried by a generated DTO. */ export function getStandardSchema< Schema extends StandardSchemaV1, ->(source: StandardSchemaDtoClass): Schema; +>(source: SchemaClass): Schema; export function getStandardSchema< Schema extends StandardSchemaV1, ->(source: StandardSchemaResponseDtoClass): Schema; +>(source: ResponseSchemaClass): Schema; export function getStandardSchema( source: Schema, ): Schema; @@ -110,7 +112,7 @@ export function getStandardSchema( export function getStandardSchema( source: StandardSchemaSource, ): StandardSchemaV1 { - if (isStandardSchemaDto(source) || isStandardSchemaResponseDto(source)) { + if (isSchemaClass(source) || isResponseSchemaClass(source)) { return source.schema; } @@ -118,9 +120,7 @@ export function getStandardSchema( return source; } - throw new TypeError( - 'Expected a Standard Schema or a class created by a Standard Schema DTO factory.', - ); + throw new TypeError('Expected a Standard Schema or schema class.'); } function isObjectLike(value: unknown): value is Record { diff --git a/src/standard-schema-response.decorator.spec.ts b/src/standard-schema-response.decorator.spec.ts index c9d089d..3d7de56 100644 --- a/src/standard-schema-response.decorator.spec.ts +++ b/src/standard-schema-response.decorator.spec.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { createStandardSchemaDto } from './create-standard-schema-dto.js'; +import { createSchemaClass } from './create-schema-class.js'; import { StandardSchemaResponse } from './standard-schema-response.decorator.js'; const CLASS_SERIALIZER_OPTIONS = 'class_serializer:options'; @@ -10,14 +10,12 @@ const ProductResponseSchema = z.object({ name: z.string(), }); -class ProductResponseDto extends createStandardSchemaDto( - ProductResponseSchema, -) {} +class ProductResponse extends createSchemaClass(ProductResponseSchema) {} describe(StandardSchemaResponse.name, () => { - it('writes the DTO schema into Nest native serializer metadata', () => { + it('writes a class-backed schema into Nest native serializer metadata', () => { class TestController { - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) findOne(): void {} } diff --git a/src/standard-schema-response.decorator.ts b/src/standard-schema-response.decorator.ts index 1e8299b..3c468ef 100644 --- a/src/standard-schema-response.decorator.ts +++ b/src/standard-schema-response.decorator.ts @@ -8,8 +8,7 @@ export interface StandardSchemaResponseOptions { } /** - * Attaches a raw or DTO-carried schema through Nest's native - * `@SerializeOptions({ schema })` metadata. + * Attaches a Standard Schema through Nest's native serialization metadata. */ export function StandardSchemaResponse( source: StandardSchemaSource, diff --git a/src/standard-schema.module.ts b/src/standard-schema.module.ts index 2d18c82..91a2efe 100644 --- a/src/standard-schema.module.ts +++ b/src/standard-schema.module.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { APP_INTERCEPTOR, APP_PIPE, Reflector } from '@nestjs/core'; -import { StandardSchemaDtoValidationPipe } from './standard-schema-dto-validation.pipe.js'; +import { SchemaClassValidationPipe } from './schema-class-validation.pipe.js'; /** Options forwarded to Nest's native validation and serialization helpers. */ export interface StandardSchemaModuleOptions { @@ -19,7 +19,7 @@ export interface StandardSchemaModuleOptions { @Module({}) export class StandardSchemaModule { /** - * Registers the DTO-aware request pipe and Nest's native Standard Schema + * Registers the schema-class-aware request pipe and Nest's native Standard Schema * serializer as global application enhancers. */ static forRoot(options: StandardSchemaModuleOptions = {}): DynamicModule { @@ -30,8 +30,7 @@ export class StandardSchemaModule { if (validationOptions !== false) { providers.push({ provide: APP_PIPE, - useFactory: () => - new StandardSchemaDtoValidationPipe(validationOptions), + useFactory: () => new SchemaClassValidationPipe(validationOptions), }); } diff --git a/src/swagger/api-standard-schema-response.decorator.spec.ts b/src/swagger/api-standard-schema-response.decorator.spec.ts index 5562efb..a43531e 100644 --- a/src/swagger/api-standard-schema-response.decorator.spec.ts +++ b/src/swagger/api-standard-schema-response.decorator.spec.ts @@ -4,11 +4,8 @@ import { DECORATORS } from '@nestjs/swagger'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { z } from 'zod'; -import { createStandardSchemaResponseDto } from '../create-standard-schema-response-dto.js'; -import { - ApiStandardSchemaResponse, - withStandardSchemaResponseArrays, -} from './api-standard-schema-response.decorator.js'; +import { createResponseSchemaClass } from '../create-response-schema-class.js'; +import { ApiStandardSchemaResponse } from './api-standard-schema-response.decorator.js'; const CLASS_SERIALIZER_OPTIONS = 'class_serializer:options'; @@ -24,14 +21,14 @@ const ConverterOnlyProductSchema: StandardSchemaV1 = { }, }; -class ProductResponseDto extends createStandardSchemaResponseDto( +class ProductResponse extends createResponseSchemaClass( ProductResponseSchema, ) {} describe(ApiStandardSchemaResponse.name, () => { it('combines native response serialization and Swagger metadata', () => { class TestController { - @ApiStandardSchemaResponse(ProductResponseDto, { + @ApiStandardSchemaResponse(ProductResponse, { description: 'Products created by the request.', example: { id: 1, @@ -141,53 +138,8 @@ describe(ApiStandardSchemaResponse.name, () => { readonly standardSchema: StandardSchemaV1; }; }; - const components = { - Product: { - properties: { - id: { type: 'number' }, - }, - type: 'object', - }, - }; - const converter = vi.fn(() => ({ - components, - schema: { - $ref: '#/components/schemas/Product', - }, - })); - const wrappedConverter = withStandardSchemaResponseArrays(converter); - - expect( - wrappedConverter(swaggerMetadata[200].standardSchema, { - schemaType: 'output', - }), - ).toEqual({ - components, - schema: { - $ref: '#/components/schemas/Product', - }, - }); - expect(converter).toHaveBeenCalledWith(ConverterOnlyProductSchema, { - schemaType: 'output', - }); - }); - - it('unwraps legacy array metadata created by another installed package copy', () => { - const converter = vi.fn(() => ({ schema: { type: 'object' } })); - const wrappedConverter = withStandardSchemaResponseArrays(converter); - const duplicateCopySchema = { - [Symbol.for('@nestm/standard-schema:swagger-array-item')]: - ConverterOnlyProductSchema, - '~standard': ConverterOnlyProductSchema['~standard'], - } as StandardSchemaV1; - - expect( - wrappedConverter(duplicateCopySchema, { schemaType: 'output' }), - ).toEqual({ - schema: { type: 'object' }, - }); - expect(converter).toHaveBeenCalledWith(ConverterOnlyProductSchema, { - schemaType: 'output', - }); + expect(swaggerMetadata[200].standardSchema).toBe( + ConverterOnlyProductSchema, + ); }); }); diff --git a/src/swagger/api-standard-schema-response.decorator.ts b/src/swagger/api-standard-schema-response.decorator.ts index aa12b5a..8eb3265 100644 --- a/src/swagger/api-standard-schema-response.decorator.ts +++ b/src/swagger/api-standard-schema-response.decorator.ts @@ -1,10 +1,5 @@ import { applyDecorators } from '@nestjs/common'; -import { - ApiResponse, - type ApiResponseMetadata, - type StandardSchemaConverter, -} from '@nestjs/swagger'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { ApiResponse, type ApiResponseMetadata } from '@nestjs/swagger'; import { StandardSchemaResponse, @@ -12,10 +7,6 @@ import { } from '../standard-schema-response.decorator.js'; import { getStandardSchema, type StandardSchemaSource } from '../schema.js'; -const ARRAY_ITEM_STANDARD_SCHEMA = Symbol.for( - '@nestm/standard-schema:swagger-array-item', -); - type DistributiveOmit = T extends unknown ? Omit : never; @@ -46,47 +37,3 @@ export function ApiStandardSchemaResponse( }), ); } - -/** - * Unwraps array metadata emitted by prerelease versions of this package before - * delegating to a custom Nest Swagger Standard Schema converter. - * - * @deprecated Nest Swagger 12 stable applies `isArray` after custom Standard - * Schema conversion without an adapter. - */ -export function withStandardSchemaResponseArrays( - converter: StandardSchemaConverter, -): StandardSchemaConverter { - return (schema, options) => { - if (!isArrayStandardSchema(schema)) { - return converter(schema, options); - } - - return converter(schema[ARRAY_ITEM_STANDARD_SCHEMA], options); - }; -} - -type ArrayStandardSchemaMarker = { - readonly [ARRAY_ITEM_STANDARD_SCHEMA]: StandardSchemaV1; -}; - -function isArrayStandardSchema( - value: unknown, -): value is ArrayStandardSchemaMarker { - return ( - typeof value === 'object' && - value !== null && - ARRAY_ITEM_STANDARD_SCHEMA in value && - isStandardSchemaValue(value[ARRAY_ITEM_STANDARD_SCHEMA]) - ); -} - -function isStandardSchemaValue(value: unknown): value is StandardSchemaV1 { - return ( - typeof value === 'object' && - value !== null && - '~standard' in value && - typeof value['~standard'] === 'object' && - value['~standard'] !== null - ); -} diff --git a/src/swagger/index.ts b/src/swagger/index.ts index b66f3fb..f78015a 100644 --- a/src/swagger/index.ts +++ b/src/swagger/index.ts @@ -1,5 +1,4 @@ export { ApiStandardSchemaResponse, type ApiStandardSchemaResponseOptions, - withStandardSchemaResponseArrays, } from './api-standard-schema-response.decorator.js'; diff --git a/test/compiler/compiler-plugin.spec.ts b/test/compiler/compiler-plugin.spec.ts index 4251d9f..8193de8 100644 --- a/test/compiler/compiler-plugin.spec.ts +++ b/test/compiler/compiler-plugin.spec.ts @@ -4,10 +4,10 @@ import type ts from 'typescript'; import { compileFixture, formatDiagnostics } from './compile-fixture.js'; -const responseDtoSource = ` +const schemaClassSource = ` import { - createStandardSchemaDto, - createStandardSchemaResponseDto, + createSchemaClass, + createResponseSchemaClass, } from '@nestm/standard-schema'; import { z } from 'zod'; @@ -17,19 +17,19 @@ const ProductResponseSchema = z.object({ publishedAt: z.date().transform((value) => value.toISOString()), }); -export class ProductResponseDto extends createStandardSchemaResponseDto( +export class ProductResponse extends createResponseSchemaClass( ProductResponseSchema, ) {} -export class OtherProductResponseDto extends createStandardSchemaResponseDto( +export class OtherProductResponse extends createResponseSchemaClass( ProductResponseSchema, ) {} -export class RequestDto extends createStandardSchemaDto( +export class ProductInput extends createSchemaClass( z.object({ name: z.string() }), ) {} -export class OtherRequestDto extends createStandardSchemaDto( +export class ProductLookup extends createSchemaClass( z.object({ id: z.coerce.number() }), ) {} @@ -54,57 +54,57 @@ describe('@nestm/standard-schema Nest compiler plugin', () => { const controllerSource = ` import { Controller, Get } from '@nestjs/common'; import type { - ProductResponseDto, + ProductResponse, UnusedType, -} from './product.dto.js'; +} from './product.schemas.js'; @Controller('products') export class ProductsController { @Get('direct') - direct(): ProductResponseDto { + direct(): ProductResponse { return { id: 1, name: 'Direct', publishedAt: new Date() }; } @Get('async') - async asyncOne(): Promise { + async asyncOne(): Promise { return { id: 2, name: 'Async', publishedAt: new Date() }; } @Get('array') - array(): ProductResponseDto[] { + array(): ProductResponse[] { return []; } @Get('generic-array') - genericArray(): Array { + genericArray(): Array { return []; } @Get('async-array') - async asyncArray(): Promise { + async asyncArray(): Promise { return []; } @Get('readonly-array') - readonlyArray(): readonly ProductResponseDto[] { + readonlyArray(): readonly ProductResponse[] { return []; } @Get('async-readonly-array') - async asyncReadonlyArray(): Promise { + async asyncReadonlyArray(): Promise { return []; } } `; const baseline = compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': controllerSource, }, { usePlugin: false }, ); const transformed = compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': controllerSource, }); @@ -124,36 +124,36 @@ export class ProductsController { 'import * as _nestmStandardSchema from "@nestm/standard-schema";', ); expect(javascript).toMatch( - /import \{ ProductResponseDto \} from ['"]\.\/product\.dto\.js['"];/, + /import \{ ProductResponse \} from ['"]\.\/product\.schemas\.js['"];/, ); expect(javascript).not.toContain('UnusedType'); expect( countOccurrences( javascript, - '_nestmStandardSchema.StandardSchemaResponse(ProductResponseDto)', + '_nestmStandardSchema.StandardSchemaResponse(ProductResponse)', ), ).toBe(7); expect(transformedDeclaration).toBe(baselineDeclaration); }); - it('supports aliased decorators and type-only DTO imports without collisions', () => { + it('supports aliased decorators and type-only schema-class imports without collisions', () => { const result = compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller as ApiController, Get as Read, } from '@nestjs/common'; import type { - ProductResponseDto as ProductDto, -} from './product.dto.js'; + ProductResponse as ProductContract, +} from './product.schemas.js'; const _nestmStandardSchema = 'occupied'; @ApiController('products') export class ProductsController { @Read() - find(): ProductDto { + find(): ProductContract { return { id: 1, name: _nestmStandardSchema, publishedAt: new Date() }; } } @@ -166,10 +166,10 @@ export class ProductsController { 'import * as _nestmStandardSchema2 from "@nestm/standard-schema";', ); expect(javascript).toMatch( - /import \{ ProductResponseDto as ProductDto \} from ['"]\.\/product\.dto\.js['"];/, + /import \{ ProductResponse as ProductContract \} from ['"]\.\/product\.schemas\.js['"];/, ); expect(javascript).toContain( - '_nestmStandardSchema2.StandardSchemaResponse(ProductDto)', + '_nestmStandardSchema2.StandardSchemaResponse(ProductContract)', ); }); @@ -178,15 +178,15 @@ export class ProductsController { 'nest-common.ts': ` export { Controller, Get } from '@nestjs/common'; `, - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get } from './nest-common.js'; -import type { ProductResponseDto } from './product.dto.js'; +import type { ProductResponse } from './product.schemas.js'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { return { id: 1, name: 'Product', publishedAt: new Date() }; } } @@ -196,41 +196,41 @@ export class ProductsController { expect(formatDiagnostics(result.diagnostics)).toBe(''); expect(javascript).toContain( - '_nestmStandardSchema.StandardSchemaResponse(ProductResponseDto)', + '_nestmStandardSchema.StandardSchemaResponse(ProductResponse)', ); }); - it('promotes default and named DTOs from the same type-only import', () => { + it('promotes default and named schema classes from the same type-only import', () => { const result = compileFixture({ - 'mixed.dto.ts': ` -import { createStandardSchemaResponseDto } from '@nestm/standard-schema'; + 'mixed.schemas.ts': ` +import { createResponseSchemaClass } from '@nestm/standard-schema'; import { z } from 'zod'; const ResponseSchema = z.object({ id: z.number() }); -export default class DefaultResponseDto extends createStandardSchemaResponseDto( +export default class DefaultResponse extends createResponseSchemaClass( ResponseSchema, ) {} -export class NamedResponseDto extends createStandardSchemaResponseDto( +export class NamedResponse extends createResponseSchemaClass( ResponseSchema, ) {} `, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import DefaultResponseDto, { - type NamedResponseDto, -} from './mixed.dto.js'; +import DefaultResponse, { + type NamedResponse, +} from './mixed.schemas.js'; @Controller('products') export class ProductsController { @Get('default') - defaultResponse(): DefaultResponseDto { + defaultResponse(): DefaultResponse { return { id: 1 }; } @Get('named') - namedResponse(): NamedResponseDto { + namedResponse(): NamedResponse { return { id: 2 }; } } @@ -240,30 +240,30 @@ export class ProductsController { expect(formatDiagnostics(result.diagnostics)).toBe(''); expect(javascript).toMatch( - /import DefaultResponseDto, \{ NamedResponseDto \} from ['"]\.\/mixed\.dto\.js['"];/, + /import DefaultResponse, \{ NamedResponse \} from ['"]\.\/mixed\.schemas\.js['"];/, ); - expect(javascript).toContain('StandardSchemaResponse(DefaultResponseDto)'); - expect(javascript).toContain('StandardSchemaResponse(NamedResponseDto)'); + expect(javascript).toContain('StandardSchemaResponse(DefaultResponse)'); + expect(javascript).toContain('StandardSchemaResponse(NamedResponse)'); }); - it('promotes an anonymous default response DTO class', () => { + it('promotes an anonymous default response schema class', () => { const result = compileFixture({ - 'anonymous.dto.ts': ` -import { createStandardSchemaResponseDto } from '@nestm/standard-schema'; + 'anonymous.schemas.ts': ` +import { createResponseSchemaClass } from '@nestm/standard-schema'; import { z } from 'zod'; const ResponseSchema = z.object({ id: z.number() }); -export default class extends createStandardSchemaResponseDto(ResponseSchema) {} +export default class extends createResponseSchemaClass(ResponseSchema) {} `, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type ProductResponseDto from './anonymous.dto.js'; +import type ProductResponse from './anonymous.schemas.js'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { return { id: 1 }; } } @@ -273,16 +273,16 @@ export class ProductsController { expect(formatDiagnostics(result.diagnostics)).toBe(''); expect(javascript).toMatch( - /import ProductResponseDto from ['"]\.\/anonymous\.dto\.js['"];/, + /import ProductResponse from ['"]\.\/anonymous\.schemas\.js['"];/, ); expect(javascript).toContain( - '_nestmStandardSchema.StandardSchemaResponse(ProductResponseDto)', + '_nestmStandardSchema.StandardSchemaResponse(ProductResponse)', ); }); it('lets explicit metadata win and skips routes without a serializable body', () => { const result = compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, @@ -297,26 +297,26 @@ import { StandardSchemaResponse, } from '@nestm/standard-schema'; import { - ProductResponseDto, - RequestDto, -} from './product.dto.js'; + ProductResponse, + ProductInput, +} from './product.schemas.js'; @Controller('products') export class ProductsController { @Get('inferred') - inferred(): ProductResponseDto { + inferred(): ProductResponse { return { id: 1, name: 'Inferred', publishedAt: new Date() }; } @Get('explicit') - @StandardSchemaResponse(ProductResponseDto) - explicit(): ProductResponseDto { + @StandardSchemaResponse(ProductResponse) + explicit(): ProductResponse { return { id: 2, name: 'Explicit', publishedAt: new Date() }; } @Get('native') - @SerializeOptions({ schema: ProductResponseDto.schema }) - native(): ProductResponseDto { + @SerializeOptions({ schema: ProductResponse.schema }) + native(): ProductResponse { return { id: 3, name: 'Native', publishedAt: new Date() }; } @@ -328,17 +328,17 @@ export class ProductsController { @Get('no-content') @HttpCode(HttpStatus.NO_CONTENT) - noContent(): ProductResponseDto { + noContent(): ProductResponse { return { id: 4, name: 'No content', publishedAt: new Date() }; } @Get('raw') - raw(@Res() _response: unknown): ProductResponseDto { + raw(@Res() _response: unknown): ProductResponse { return { id: 5, name: 'Raw', publishedAt: new Date() }; } - @Get('request-dto') - requestDto(): RequestDto { + @Get('request-schema-class') + requestSchemaClass(): ProductInput { return { name: 'Request' }; } @@ -357,16 +357,16 @@ export class ProductsController { return { id: 6 }; } - helper(): ProductResponseDto { + helper(): ProductResponse { return { id: 7, name: 'Helper', publishedAt: new Date() }; } } @Controller('explicit-controller') -@StandardSchemaResponse(ProductResponseDto) +@StandardSchemaResponse(ProductResponse) export class ExplicitController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { return { id: 8, name: 'Controller', publishedAt: new Date() }; } } @@ -378,14 +378,11 @@ export class ExplicitController { expect( countOccurrences( javascript, - '_nestmStandardSchema.StandardSchemaResponse(ProductResponseDto)', + '_nestmStandardSchema.StandardSchemaResponse(ProductResponse)', ), ).toBe(1); expect( - countOccurrences( - javascript, - 'StandardSchemaResponse(ProductResponseDto)', - ), + countOccurrences(javascript, 'StandardSchemaResponse(ProductResponse)'), ).toBe(3); }); @@ -397,35 +394,35 @@ export class ExplicitController { // `StandardSchemaResponse(source, {})` plus `ApiResponse`, so serialization is unchanged. const result = compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get, Post } from '@nestjs/common'; import { StandardSchemaResponse } from '@nestm/standard-schema'; import { ApiStandardSchemaResponse } from '@nestm/standard-schema/swagger'; -import { ProductResponseDto, OtherProductResponseDto } from './product.dto.js'; +import { ProductResponse, OtherProductResponse } from './product.schemas.js'; @Controller('products') export class ProductsController { @Get('documented') - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) documented() { return { id: 1, name: 'Documented', publishedAt: new Date() }; } @Get('already-documented') - @ApiStandardSchemaResponse(OtherProductResponseDto) + @ApiStandardSchemaResponse(OtherProductResponse) alreadyDocumented() { return { id: 2, name: 'Hand written', publishedAt: new Date() }; } @Post('with-options') - @StandardSchemaResponse(ProductResponseDto, { validateOptions: {} }) + @StandardSchemaResponse(ProductResponse, { validateOptions: {} }) withOptions() { return { id: 3, name: 'Options', publishedAt: new Date() }; } @Post('created') - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) created() { return { id: 4, name: 'Created', publishedAt: new Date() }; } @@ -442,28 +439,28 @@ export class ProductsController { // what keeps the schema off the `default` response key, where generators read it as the error // type and leave the success response untyped. expect(javascript).toContain( - '_nestmStandardSchemaSwagger.ApiStandardSchemaResponse(ProductResponseDto, { status: 200 })', + '_nestmStandardSchemaSwagger.ApiStandardSchemaResponse(ProductResponse, { status: 200 })', ); // @Post carries Nest's 201, matching what the inference path derives. expect(javascript).toContain( - '_nestmStandardSchemaSwagger.ApiStandardSchemaResponse(ProductResponseDto, { status: 201 })', + '_nestmStandardSchemaSwagger.ApiStandardSchemaResponse(ProductResponse, { status: 201 })', ); // A hand-written swagger decorator is authoritative and must not be duplicated. expect( countOccurrences( javascript, - 'ApiStandardSchemaResponse(OtherProductResponseDto)', + 'ApiStandardSchemaResponse(OtherProductResponse)', ), ).toBe(1); // Two arguments partition options differently between the two decorators, so rewriting could // move a serialization key into the document. Left exactly as written. expect(javascript).toContain( - 'StandardSchemaResponse(ProductResponseDto, { validateOptions: {} })', + 'StandardSchemaResponse(ProductResponse, { validateOptions: {} })', ); expect(javascript).not.toContain( - 'ApiStandardSchemaResponse(ProductResponseDto, { validateOptions: {} })', + 'ApiStandardSchemaResponse(ProductResponse, { validateOptions: {} })', ); }); @@ -474,7 +471,7 @@ export class ProductsController { // is the right escape hatch and "rewrite without a status" is not. const result = compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, @@ -487,42 +484,42 @@ import { } from '@nestjs/common'; import { ApiOkResponse } from '@nestjs/swagger'; import { StandardSchemaResponse } from '@nestm/standard-schema'; -import { ProductResponseDto, OtherProductResponseDto } from './product.dto.js'; +import { ProductResponse, OtherProductResponse } from './product.schemas.js'; declare const RUNTIME_STATUS: number; @Controller('products') export class ProductsController { @Get('raw') - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) raw(@Res() _response: unknown) { return { id: 1, name: 'Raw', publishedAt: new Date() }; } @Get('redirect') @Redirect('https://example.com', 301) - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) redirect() { return { id: 2, name: 'Redirect', publishedAt: new Date() }; } @Delete('no-content') @HttpCode(HttpStatus.NO_CONTENT) - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) noContent() { return { id: 3, name: 'NoContent', publishedAt: new Date() }; } @Get('dynamic-status') @HttpCode(RUNTIME_STATUS) - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) dynamicStatus() { return { id: 4, name: 'Dynamic', publishedAt: new Date() }; } @Get('already-swaggered') - @ApiOkResponse({ type: OtherProductResponseDto, description: 'Hand written.' }) - @StandardSchemaResponse(ProductResponseDto) + @ApiOkResponse({ type: OtherProductResponse, description: 'Hand written.' }) + @StandardSchemaResponse(ProductResponse) alreadySwaggered() { return { id: 5, name: 'Swaggered', publishedAt: new Date() }; } @@ -541,50 +538,47 @@ export class ProductsController { // Not one of the five was rewritten. expect(javascript).not.toContain('ApiStandardSchemaResponse'); expect( - countOccurrences( - javascript, - 'StandardSchemaResponse(ProductResponseDto)', - ), + countOccurrences(javascript, 'StandardSchemaResponse(ProductResponse)'), ).toBe(5); // And the hand-written Swagger contract survives intact. Sharing its response key would let // ResponseObjectFactory's standardSchema short-circuit drop `type` — silently, and in a way // decorator order cannot fix. - expect(javascript).toContain('type: OtherProductResponseDto'); + expect(javascript).toContain('type: OtherProductResponse'); }); it('derives the status the same way the inference path does', () => { const result = compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get, HttpCode, HttpStatus, Patch, Post } from '@nestjs/common'; import { StandardSchemaResponse } from '@nestm/standard-schema'; -import { ProductResponseDto } from './product.dto.js'; +import { ProductResponse } from './product.schemas.js'; @Controller('products') export class ProductsController { @Get('read') - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) read() { return { id: 1, name: 'Read', publishedAt: new Date() }; } @Post('create') - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) create() { return { id: 2, name: 'Create', publishedAt: new Date() }; } @Patch('update') - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) update() { return { id: 3, name: 'Update', publishedAt: new Date() }; } @Get('accepted') @HttpCode(HttpStatus.ACCEPTED) - @StandardSchemaResponse(ProductResponseDto) + @StandardSchemaResponse(ProductResponse) accepted() { return { id: 4, name: 'Accepted', publishedAt: new Date() }; } @@ -604,11 +598,11 @@ export class ProductsController { it('leaves StandardSchemaResponse alone when swagger output is disabled', () => { const result = compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; import { StandardSchemaResponse } from '@nestm/standard-schema'; -import { ProductResponseDto } from './product.dto.js'; +import { ProductResponse } from './product.schemas.js'; @Controller('products') export class ProductsController { @@ -627,10 +621,10 @@ export class ProductsController { it('does not treat unrelated local decorators as explicit response metadata', () => { const result = compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type { ProductResponseDto } from './product.dto.js'; +import type { ProductResponse } from './product.schemas.js'; function SerializeOptions(): ClassDecorator & MethodDecorator { return () => undefined; @@ -645,7 +639,7 @@ function StandardSchemaResponse(): ClassDecorator & MethodDecorator { export class ProductsController { @Get() @SerializeOptions() - find(): ProductResponseDto { + find(): ProductResponse { return { id: 1, name: 'Product', publishedAt: new Date() }; } } @@ -655,7 +649,7 @@ export class ProductsController { export class OtherProductsController { @Get() @StandardSchemaResponse() - find(): ProductResponseDto { + find(): ProductResponse { return { id: 2, name: 'Other product', publishedAt: new Date() }; } } @@ -667,27 +661,27 @@ export class OtherProductsController { expect( countOccurrences( javascript, - '_nestmStandardSchema.StandardSchemaResponse(ProductResponseDto)', + '_nestmStandardSchema.StandardSchemaResponse(ProductResponse)', ), ).toBe(2); }); it('infers passthrough responses while continuing to skip raw responses', () => { const result = compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get, Res } from '@nestjs/common'; -import type { ProductResponseDto } from './product.dto.js'; +import type { ProductResponse } from './product.schemas.js'; @Controller('products') export class ProductsController { @Get('passthrough') - passthrough(@Res({ passthrough: true }) _response: unknown): ProductResponseDto { + passthrough(@Res({ passthrough: true }) _response: unknown): ProductResponse { return { id: 1, name: 'Passthrough', publishedAt: new Date() }; } @Get('raw') - raw(@Res() _response: unknown): ProductResponseDto { + raw(@Res() _response: unknown): ProductResponse { return { id: 2, name: 'Raw', publishedAt: new Date() }; } } @@ -699,7 +693,7 @@ export class ProductsController { expect( countOccurrences( javascript, - '_nestmStandardSchema.StandardSchemaResponse(ProductResponseDto)', + '_nestmStandardSchema.StandardSchemaResponse(ProductResponse)', ), ).toBe(1); }); @@ -707,33 +701,33 @@ export class ProductsController { it.each([ { name: 'union', - declaration: 'find(): ProductResponseDto | OtherProductResponseDto', + declaration: 'find(): ProductResponse | OtherProductResponse', reason: 'union response types', }, { name: 'nested array', - declaration: 'find(): ProductResponseDto[][]', + declaration: 'find(): ProductResponse[][]', reason: 'nested Promise or array response types', }, { name: 'tuple', - declaration: 'find(): [ProductResponseDto]', + declaration: 'find(): [ProductResponse]', reason: 'tuple response types', }, { name: 'readonly tuple', - declaration: 'find(): readonly [ProductResponseDto]', + declaration: 'find(): readonly [ProductResponse]', reason: 'tuple response types', }, { name: 'structural envelope', prelude: 'type Page = { data: T[] };', - declaration: 'find(): Page', + declaration: 'find(): Page', reason: 'response envelopes and generic wrappers', }, { name: 'intersection', - declaration: 'find(): ProductResponseDto & { readonly extra: string }', + declaration: 'find(): ProductResponse & { readonly extra: string }', reason: 'intersection response types', }, ])( @@ -741,13 +735,13 @@ export class ProductsController { ({ declaration, prelude = '', reason }) => { expect(() => compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; import type { - OtherProductResponseDto, - ProductResponseDto, -} from './product.dto.js'; + OtherProductResponse, + ProductResponse, +} from './product.schemas.js'; ${prelude} @@ -796,17 +790,17 @@ export class ProductsController { ({ name, wrapper }) => { expect(() => compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type { ProductResponseDto } from './product.dto.js'; +import type { ProductResponse } from './product.schemas.js'; ${wrapper} @Controller('products') export class ProductsController { @Get() - find(): ${name} { + find(): ${name} { throw new Error('not executed'); } } @@ -819,23 +813,23 @@ export class ProductsController { it('aggregates ambiguous contracts during preflight', () => { expect(() => compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; import type { - OtherProductResponseDto, - ProductResponseDto, -} from './product.dto.js'; + OtherProductResponse, + ProductResponse, +} from './product.schemas.js'; @Controller('products') export class ProductsController { @Get('union') - union(): ProductResponseDto | OtherProductResponseDto { + union(): ProductResponse | OtherProductResponse { throw new Error('not executed'); } @Get('nested') - nested(): ProductResponseDto[][] { + nested(): ProductResponse[][] { throw new Error('not executed'); } } @@ -845,21 +839,21 @@ export class ProductsController { }); it.each([ - `export type { ProductResponseDto } from './product.dto.js';`, - `export type * from './product.dto.js';`, + `export type { ProductResponse } from './product.schemas.js';`, + `export type * from './product.schemas.js';`, ])('rejects promotion through a type-only re-export', (barrelSource) => { expect(() => compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'product.barrel.ts': barrelSource, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type { ProductResponseDto } from './product.barrel.js'; +import type { ProductResponse } from './product.barrel.js'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { throw new Error('not executed'); } } @@ -872,18 +866,18 @@ export class ProductsController { expect(() => compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'product.barrel.ts': ` -export type { ProductResponseDto } from './product.dto.js'; +export type { ProductResponse } from './product.schemas.js'; `, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import { ProductResponseDto } from './product.barrel.js'; +import { ProductResponse } from './product.barrel.js'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { throw new Error('not executed'); } } @@ -901,22 +895,22 @@ export class ProductsController { it('rejects ambient response classes declared in implementation files', () => { expect(() => compileFixture({ - 'ghost.dto.ts': ` -import { STANDARD_SCHEMA_RESPONSE_DTO } from '@nestm/standard-schema'; + 'ghost.schemas.ts': ` +import { STANDARD_SCHEMA_RESPONSE_CLASS } from '@nestm/standard-schema'; -export declare class GhostResponseDto { - static readonly [STANDARD_SCHEMA_RESPONSE_DTO]: true; +export declare class GhostResponse { + static readonly [STANDARD_SCHEMA_RESPONSE_CLASS]: true; readonly id: number; } `, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type { GhostResponseDto } from './ghost.dto.js'; +import type { GhostResponse } from './ghost.schemas.js'; @Controller('products') export class ProductsController { @Get() - find(): GhostResponseDto { + find(): GhostResponse { throw new Error('not executed'); } } @@ -928,24 +922,24 @@ export class ProductsController { it('rejects a separately exported ambient response class', () => { expect(() => compileFixture({ - 'ghost.dto.ts': ` -import { STANDARD_SCHEMA_RESPONSE_DTO } from '@nestm/standard-schema'; + 'ghost.schemas.ts': ` +import { STANDARD_SCHEMA_RESPONSE_CLASS } from '@nestm/standard-schema'; -declare class GhostResponseDto { - static readonly [STANDARD_SCHEMA_RESPONSE_DTO]: true; +declare class GhostResponse { + static readonly [STANDARD_SCHEMA_RESPONSE_CLASS]: true; readonly id: number; } -export { GhostResponseDto }; +export { GhostResponse }; `, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type { GhostResponseDto } from './ghost.dto.js'; +import type { GhostResponse } from './ghost.schemas.js'; @Controller('products') export class ProductsController { @Get() - find(): GhostResponseDto { + find(): GhostResponse { throw new Error('not executed'); } } @@ -959,17 +953,17 @@ export class ProductsController { compileFixture({ 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import { STANDARD_SCHEMA_RESPONSE_DTO } from '@nestm/standard-schema'; +import { STANDARD_SCHEMA_RESPONSE_CLASS } from '@nestm/standard-schema'; -declare class GhostResponseDto { - static readonly [STANDARD_SCHEMA_RESPONSE_DTO]: true; +declare class GhostResponse { + static readonly [STANDARD_SCHEMA_RESPONSE_CLASS]: true; readonly id: number; } @Controller('products') export class ProductsController { @Get() - find(): GhostResponseDto { + find(): GhostResponse { throw new Error('not executed'); } } @@ -982,20 +976,20 @@ export class ProductsController { expect(() => compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'product.barrel.ts': ` -import type { ProductResponseDto } from './product.dto.js'; +import type { ProductResponse } from './product.schemas.js'; -export { ProductResponseDto }; +export { ProductResponse }; `, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type { ProductResponseDto } from './product.barrel.js'; +import type { ProductResponse } from './product.barrel.js'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { throw new Error('not executed'); } } @@ -1013,22 +1007,22 @@ export class ProductsController { it('rejects split type and value exports with different identities', () => { expect(() => compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'product.value.ts': ` -export const ProductResponseDto = class UnrelatedRuntimeValue {}; +export const ProductResponse = class UnrelatedRuntimeValue {}; `, 'product.barrel.ts': ` -export type { ProductResponseDto } from './product.dto.js'; -export { ProductResponseDto } from './product.value.js'; +export type { ProductResponse } from './product.schemas.js'; +export { ProductResponse } from './product.value.js'; `, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type { ProductResponseDto } from './product.barrel.js'; +import type { ProductResponse } from './product.barrel.js'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { throw new Error('not executed'); } } @@ -1037,20 +1031,20 @@ export class ProductsController { ).toThrow('cannot be referenced safely at runtime'); }); - it('promotes a response DTO through a safe runtime barrel re-export', () => { + it('promotes a response schema class through a safe runtime barrel re-export', () => { const result = compileFixture({ - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'product.barrel.ts': ` -export { ProductResponseDto } from './product.dto.js'; +export { ProductResponse } from './product.schemas.js'; `, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -import type { ProductResponseDto } from './product.barrel.js'; +import type { ProductResponse } from './product.barrel.js'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { return { id: 1, name: 'Product', publishedAt: new Date() }; } } @@ -1060,26 +1054,26 @@ export class ProductsController { expect(formatDiagnostics(result.diagnostics)).toBe(''); expect(javascript).toMatch( - /import \{ ProductResponseDto \} from ['"]\.\/product\.barrel\.js['"];/, + /import \{ ProductResponse \} from ['"]\.\/product\.barrel\.js['"];/, ); expect(javascript).toContain( - '_nestmStandardSchema.StandardSchemaResponse(ProductResponseDto)', + '_nestmStandardSchema.StandardSchemaResponse(ProductResponse)', ); }); it('removes type-only resolution-mode attributes from promoted imports', () => { const result = compileFixture({ - 'product.dto.mts': responseDtoSource, + 'product.schemas.mts': schemaClassSource, 'products.controller.mts': ` import { Controller, Get } from '@nestjs/common'; -import type { ProductResponseDto } from './product.dto.mjs' with { +import type { ProductResponse } from './product.schemas.mjs' with { 'resolution-mode': 'import', }; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { return { id: 1, name: 'Product', publishedAt: new Date() }; } } @@ -1089,7 +1083,7 @@ export class ProductsController { expect(formatDiagnostics(result.diagnostics)).toBe(''); expect(javascript).toMatch( - /import \{ ProductResponseDto \} from ['"]\.\/product\.dto\.mjs['"];/, + /import \{ ProductResponse \} from ['"]\.\/product\.schemas\.mjs['"];/, ); expect(javascript).not.toContain('resolution-mode'); }); @@ -1099,16 +1093,16 @@ export class ProductsController { 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; -const STANDARD_SCHEMA_RESPONSE_DTO: unique symbol = Symbol('lookalike'); +const STANDARD_SCHEMA_RESPONSE_CLASS: unique symbol = Symbol('lookalike'); -class FakeResponseDto { - static readonly [STANDARD_SCHEMA_RESPONSE_DTO] = true; +class FakeResponse { + static readonly [STANDARD_SCHEMA_RESPONSE_CLASS] = true; } @Controller('products') export class ProductsController { @Get() - find(): FakeResponseDto { + find(): FakeResponse { return {}; } } @@ -1118,13 +1112,13 @@ export class ProductsController { expect(formatDiagnostics(result.diagnostics)).toBe(''); expect(javascript).not.toContain('_nestmStandardSchema'); - expect(javascript).not.toContain('StandardSchemaResponse(FakeResponseDto)'); + expect(javascript).not.toContain('StandardSchemaResponse(FakeResponse)'); }); it('adds native request schemas and enriches existing Swagger success descriptions', () => { const result = compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Body as Payload, @@ -1142,22 +1136,22 @@ import { ApiResponse, } from '@nestjs/swagger'; import type { - OtherRequestDto, - ProductResponseDto, - RequestDto, -} from './product.dto.js'; + ProductLookup, + ProductResponse, + ProductInput, +} from './product.schemas.js'; @ApiController('products') export class ProductsController { @Create() @ApiCreatedResponse({ description: 'Product created.' }) - create(@Payload() body: RequestDto): ProductResponseDto { + create(@Payload() body: ProductInput): ProductResponse { return { id: 1, name: body.name, publishedAt: new Date() }; } @Read() @ApiOkResponse({ description: 'Products listed.' }) - findAll(@Search() query: OtherRequestDto): ProductResponseDto[] { + findAll(@Search() query: ProductLookup): ProductResponse[] { return query.id === undefined ? [] : []; } @@ -1167,7 +1161,7 @@ export class ProductsController { status: HttpStatus.ACCEPTED, description: 'Product accepted.', }) - findOne(@RouteParams() params: OtherRequestDto): ProductResponseDto { + findOne(@RouteParams() params: ProductLookup): ProductResponse { return { id: params.id, name: 'Product', publishedAt: new Date() }; } } @@ -1183,13 +1177,13 @@ export class ProductsController { expect(formatDiagnostics(result.diagnostics)).toBe(''); expect(javascript).toMatch( - /Payload\(\{\s*schema: RequestDto\.schema\s*\}\)/, + /Payload\(\{\s*schema: ProductInput\.schema\s*\}\)/, ); expect(javascript).toMatch( - /Search\(\{\s*schema: OtherRequestDto\.schema\s*\}\)/, + /Search\(\{\s*schema: ProductLookup\.schema\s*\}\)/, ); expect(javascript).toMatch( - /RouteParams\(\{\s*schema: OtherRequestDto\.schema\s*\}\)/, + /RouteParams\(\{\s*schema: ProductLookup\.schema\s*\}\)/, ); expect(javascript).toContain( "ApiCreatedResponse({ description: 'Product created.' })", @@ -1203,24 +1197,24 @@ export class ProductsController { expect( countOccurrences( javascript, - '_nestmStandardSchemaSwagger.ApiStandardSchemaResponse(ProductResponseDto', + '_nestmStandardSchemaSwagger.ApiStandardSchemaResponse(ProductResponse', ), ).toBe(3); expect(javascript).toMatch( - /ApiStandardSchemaResponse\(ProductResponseDto, \{\s*status: 201\s*\}\)/, + /ApiStandardSchemaResponse\(ProductResponse, \{\s*status: 201\s*\}\)/, ); expect(javascript).toMatch( - /ApiStandardSchemaResponse\(ProductResponseDto, \{\s*status: 200,\s*isArray: true\s*\}\)/, + /ApiStandardSchemaResponse\(ProductResponse, \{\s*status: 200,\s*isArray: true\s*\}\)/, ); expect(javascript).toMatch( - /ApiStandardSchemaResponse\(ProductResponseDto, \{\s*status: 202\s*\}\)/, + /ApiStandardSchemaResponse\(ProductResponse, \{\s*status: 202\s*\}\)/, ); }); it('injects the composite Swagger decorator with Nest default statuses and array shape', () => { const result = compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, @@ -1232,29 +1226,29 @@ import { RequestMethod, } from '@nestjs/common'; import { ApiDefaultResponse } from '@nestjs/swagger'; -import type { ProductResponseDto } from './product.dto.js'; +import type { ProductResponse } from './product.schemas.js'; @Controller('products') export class ProductsController { @Post() - create(): ProductResponseDto { + create(): ProductResponse { return { id: 1, name: 'Created', publishedAt: new Date() }; } @RequestMapping({ method: RequestMethod.POST, path: 'mapped' }) - mappedPost(): ProductResponseDto { + mappedPost(): ProductResponse { return { id: 4, name: 'Mapped', publishedAt: new Date() }; } @Get() - findAll(): Promise { + findAll(): Promise { return Promise.resolve([]); } @Get('accepted') @HttpCode(HttpStatus.ACCEPTED) @ApiDefaultResponse({ description: 'Unexpected failure.' }) - accepted(): ProductResponseDto { + accepted(): ProductResponse { return { id: 2, name: 'Accepted', publishedAt: new Date() }; } } @@ -1273,19 +1267,19 @@ export class ProductsController { 'import * as _nestmStandardSchemaSwagger from "@nestm/standard-schema/swagger";', ); expect(javascript).toMatch( - /ApiStandardSchemaResponse\(ProductResponseDto, \{\s*status: 201\s*\}\)/, + /ApiStandardSchemaResponse\(ProductResponse, \{\s*status: 201\s*\}\)/, ); expect( countOccurrences( javascript, - 'ApiStandardSchemaResponse(ProductResponseDto, { status: 201 })', + 'ApiStandardSchemaResponse(ProductResponse, { status: 201 })', ), ).toBe(2); expect(javascript).toMatch( - /ApiStandardSchemaResponse\(ProductResponseDto, \{\s*status: 200,\s*isArray: true\s*\}\)/, + /ApiStandardSchemaResponse\(ProductResponse, \{\s*status: 200,\s*isArray: true\s*\}\)/, ); expect(javascript).toMatch( - /ApiStandardSchemaResponse\(ProductResponseDto, \{\s*status: 202\s*\}\)/, + /ApiStandardSchemaResponse\(ProductResponse, \{\s*status: 202\s*\}\)/, ); expect(javascript).toContain( "ApiDefaultResponse({ description: 'Unexpected failure.' })", @@ -1298,7 +1292,7 @@ export class ProductsController { it('preserves explicit request, serialization, composite, and Swagger schemas', () => { const result = compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Body, @@ -1309,23 +1303,23 @@ import { import { ApiOkResponse } from '@nestjs/swagger'; import { ApiStandardSchemaResponse } from '@nestm/standard-schema/swagger'; import { - OtherProductResponseDto, - ProductResponseDto, - RequestDto, -} from './product.dto.js'; + OtherProductResponse, + ProductResponse, + ProductInput, +} from './product.schemas.js'; @Controller('products') export class ProductsController { @Get('request') explicitRequest( - @Body({ schema: RequestDto.schema }) body: RequestDto, + @Body({ schema: ProductInput.schema }) body: ProductInput, ): string { return body.name; } @Get('native') - @SerializeOptions({ schema: ProductResponseDto.schema }) - native(): ProductResponseDto { + @SerializeOptions({ schema: ProductResponse.schema }) + native(): ProductResponse { return { id: 1, name: 'Native', publishedAt: new Date() }; } @@ -1334,16 +1328,16 @@ export class ProductsController { description: 'Explicit schema.', schema: { type: 'string' }, }) - swaggerSchema(): ProductResponseDto { + swaggerSchema(): ProductResponse { return { id: 2, name: 'Swagger', publishedAt: new Date() }; } @Get('composite') - @ApiStandardSchemaResponse(ProductResponseDto, { + @ApiStandardSchemaResponse(ProductResponse, { description: 'Explicit composite.', status: 200, }) - composite(): ProductResponseDto | OtherProductResponseDto { + composite(): ProductResponse | OtherProductResponse { return { id: 3, name: 'Composite', publishedAt: new Date() }; } } @@ -1358,43 +1352,40 @@ export class ProductsController { const javascript = getOutput(result.emitted, 'products.controller.js'); expect(formatDiagnostics(result.diagnostics)).toBe(''); - expect(countOccurrences(javascript, 'schema: RequestDto.schema')).toBe(1); + expect(countOccurrences(javascript, 'schema: ProductInput.schema')).toBe(1); expect(javascript).toMatch( /ApiOkResponse\(\{\s*description: ['"]Explicit schema\.['"],\s*schema: \{ type: ['"]string['"] \},?\s*\}\)/, ); expect( countOccurrences( javascript, - '_nestmStandardSchema.StandardSchemaResponse(ProductResponseDto)', + '_nestmStandardSchema.StandardSchemaResponse(ProductResponse)', ), ).toBe(1); expect( - countOccurrences( - javascript, - 'ApiStandardSchemaResponse(ProductResponseDto', - ), + countOccurrences(javascript, 'ApiStandardSchemaResponse(ProductResponse'), ).toBe(1); }); it.each([ { name: 'property-bound parameter', - parameter: "@Param('id') input: RequestDto", + parameter: "@Param('id') input: ProductInput", reason: 'property-bound request decorators', }, { name: 'request union', - parameter: '@Body() input: RequestDto | OtherRequestDto', + parameter: '@Body() input: ProductInput | ProductLookup', reason: 'request unions, wrappers, tuples, and arrays', }, { name: 'request wrapper', - parameter: '@Query() input: Array', + parameter: '@Query() input: Array', reason: 'request unions, wrappers, tuples, and arrays', }, { name: 'nested request array', - parameter: '@Body() input: RequestDto[][]', + parameter: '@Body() input: ProductInput[][]', reason: 'request unions, wrappers, tuples, and arrays', }, ])( @@ -1403,13 +1394,13 @@ export class ProductsController { expect(() => compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Body, Controller, Param, Post, Query } from '@nestjs/common'; import type { - OtherRequestDto, - RequestDto, -} from './product.dto.js'; + ProductLookup, + ProductInput, +} from './product.schemas.js'; @Controller('products') export class ProductsController { @@ -1434,10 +1425,10 @@ export class ProductsController { expect(() => compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get, HttpCode } from '@nestjs/common'; -import type { ProductResponseDto } from './product.dto.js'; +import type { ProductResponse } from './product.schemas.js'; declare function resolveStatus(): number; @@ -1445,7 +1436,7 @@ declare function resolveStatus(): number; export class ProductsController { @Get() @HttpCode(resolveStatus()) - find(): ProductResponseDto { + find(): ProductResponse { return { id: 1, name: 'Product', publishedAt: new Date() }; } } @@ -1463,18 +1454,18 @@ export class ProductsController { it('can skip ambiguous contracts when explicitly configured', () => { const result = compileFixture( { - 'product.dto.ts': responseDtoSource, + 'product.schemas.ts': schemaClassSource, 'products.controller.ts': ` import { Controller, Get } from '@nestjs/common'; import type { - OtherProductResponseDto, - ProductResponseDto, -} from './product.dto.js'; + OtherProductResponse, + ProductResponse, +} from './product.schemas.js'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto | OtherProductResponseDto { + find(): ProductResponse | OtherProductResponse { throw new Error('not executed'); } } @@ -1495,13 +1486,13 @@ export class ProductsController { it('supports a configurable controller suffix and .mts controllers', () => { const defaultResult = compileFixture({ - 'product.dto.ts': responseDtoSource, - 'products.api.ts': createSimpleControllerSource('./product.dto.js'), + 'product.schemas.ts': schemaClassSource, + 'products.api.ts': createSimpleControllerSource('./product.schemas.js'), }); const customResult = compileFixture( { - 'product.dto.ts': responseDtoSource, - 'products.api.ts': createSimpleControllerSource('./product.dto.js'), + 'product.schemas.ts': schemaClassSource, + 'products.api.ts': createSimpleControllerSource('./product.schemas.js'), }, { pluginOptions: { @@ -1510,27 +1501,29 @@ export class ProductsController { }, ); const mtsResult = compileFixture({ - 'product.dto.mts': responseDtoSource, - 'products.controller.mts': - createSimpleControllerSource('./product.dto.mjs'), + 'product.schemas.mts': schemaClassSource, + 'products.controller.mts': createSimpleControllerSource( + './product.schemas.mjs', + ), }); expect(getOutput(defaultResult.emitted, 'products.api.js')).not.toContain( 'StandardSchemaResponse', ); expect(getOutput(customResult.emitted, 'products.api.js')).toContain( - 'StandardSchemaResponse(ProductResponseDto)', + 'StandardSchemaResponse(ProductResponse)', ); expect(getOutput(mtsResult.emitted, 'products.controller.mjs')).toContain( - 'StandardSchemaResponse(ProductResponseDto)', + 'StandardSchemaResponse(ProductResponse)', ); }); it('is idempotent when the transformer is registered more than once', () => { const files = { - 'product.dto.ts': responseDtoSource, - 'products.controller.ts': - createSimpleControllerSource('./product.dto.js'), + 'product.schemas.ts': schemaClassSource, + 'products.controller.ts': createSimpleControllerSource( + './product.schemas.js', + ), }; const once = compileFixture(files); const twice = compileFixture(files, { transformerPasses: 2 }); @@ -1575,15 +1568,15 @@ export class ProductsController { }); }); -function createSimpleControllerSource(dtoImport: string): string { +function createSimpleControllerSource(schemaClassImport: string): string { return ` import { Controller, Get } from '@nestjs/common'; -import type { ProductResponseDto } from '${dtoImport}'; +import type { ProductResponse } from '${schemaClassImport}'; @Controller('products') export class ProductsController { @Get() - find(): ProductResponseDto { + find(): ProductResponse { return { id: 1, name: 'Product', publishedAt: new Date() }; } } diff --git a/test/native-integration.e2e-spec.ts b/test/native-integration.e2e-spec.ts index 3933709..d8b593d 100644 --- a/test/native-integration.e2e-spec.ts +++ b/test/native-integration.e2e-spec.ts @@ -14,11 +14,8 @@ import { Test } from '@nestjs/testing'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { z } from 'zod'; -import { createStandardSchemaDto, StandardSchemaModule } from '../src/index.js'; -import { - ApiStandardSchemaResponse, - withStandardSchemaResponseArrays, -} from '../src/swagger/index.js'; +import { createSchemaClass, StandardSchemaModule } from '../src/index.js'; +import { ApiStandardSchemaResponse } from '../src/swagger/index.js'; const CreateProductSchema = z.object({ name: z.string().trim().min(1), @@ -26,21 +23,19 @@ const CreateProductSchema = z.object({ active: z.boolean().default(true), }); -class CreateProductDto extends createStandardSchemaDto(CreateProductSchema) {} +class CreateProduct extends createSchemaClass(CreateProductSchema) {} const ListProductsQuerySchema = z.object({ active: z.stringbool().optional(), }); -class ListProductsQueryDto extends createStandardSchemaDto( - ListProductsQuerySchema, -) {} +class ListProductsQuery extends createSchemaClass(ListProductsQuerySchema) {} const ProductParamsSchema = z.object({ id: z.coerce.number().int().positive(), }); -class ProductParamsDto extends createStandardSchemaDto(ProductParamsSchema) {} +class ProductParams extends createSchemaClass(ProductParamsSchema) {} const ProductResponseSchema = z.object({ id: z.number().int().positive(), @@ -49,9 +44,7 @@ const ProductResponseSchema = z.object({ active: z.boolean(), }); -class ProductResponseDto extends createStandardSchemaDto( - ProductResponseSchema, -) {} +class ProductResponse extends createSchemaClass(ProductResponseSchema) {} interface ConverterOnlyProduct { readonly id: number; @@ -97,13 +90,13 @@ const converterOnlySchemaTypes: Array<'input' | 'output'> = []; @Controller('products') class ProductsController { @Post() - @ApiStandardSchemaResponse(ProductResponseDto, { + @ApiStandardSchemaResponse(ProductResponse, { description: 'Product created.', status: 201, }) create( - @Body({ schema: CreateProductDto.schema }) input: CreateProductDto, - ): ProductResponseDto { + @Body({ schema: CreateProduct.schema }) input: CreateProduct, + ): ProductResponse { capturedBody = input; const product = { @@ -116,15 +109,15 @@ class ProductsController { } @Get() - @ApiStandardSchemaResponse(ProductResponseDto, { + @ApiStandardSchemaResponse(ProductResponse, { description: 'Products returned.', isArray: true, status: 200, }) findAll( - @Query({ schema: ListProductsQueryDto.schema }) - query: ListProductsQueryDto, - ): ProductResponseDto[] { + @Query({ schema: ListProductsQuery.schema }) + query: ListProductsQuery, + ): ProductResponse[] { capturedQuery = query; const products = [ @@ -148,8 +141,8 @@ class ProductsController { } @Get('broken') - @ApiStandardSchemaResponse(ProductResponseDto, { status: 200 }) - broken(): ProductResponseDto { + @ApiStandardSchemaResponse(ProductResponse, { status: 200 }) + broken(): ProductResponse { return { id: -1, name: 'Broken', @@ -169,10 +162,10 @@ class ProductsController { } @Get(':id') - @ApiStandardSchemaResponse(ProductResponseDto, { status: 200 }) + @ApiStandardSchemaResponse(ProductResponse, { status: 200 }) findOne( - @Param({ schema: ProductParamsDto.schema }) params: ProductParamsDto, - ): ProductResponseDto { + @Param({ schema: ProductParams.schema }) params: ProductParams, + ): ProductResponse { capturedParams = params; return { @@ -208,31 +201,29 @@ describe('Nest native Standard Schema integration', () => { .setVersion('1') .build(), { - standardSchemaConverter: withStandardSchemaResponseArrays( - (schema, options) => { - if (schema !== ConverterOnlyProductSchema) { - return undefined; - } - - converterOnlySchemaTypes.push(options.schemaType); - - return { - components: { - ConverterOnlyProduct: { - properties: { - id: { type: 'number' }, - name: { type: 'string' }, - }, - required: ['id', 'name'], - type: 'object', + standardSchemaConverter: (schema, options) => { + if (schema !== ConverterOnlyProductSchema) { + return undefined; + } + + converterOnlySchemaTypes.push(options.schemaType); + + return { + components: { + ConverterOnlyProduct: { + properties: { + id: { type: 'number' }, + name: { type: 'string' }, }, + required: ['id', 'name'], + type: 'object', }, - schema: { - $ref: '#/components/schemas/ConverterOnlyProduct', - }, - }; - }, - ), + }, + schema: { + $ref: '#/components/schemas/ConverterOnlyProduct', + }, + }; + }, }, ); }); @@ -247,7 +238,7 @@ describe('Nest native Standard Schema integration', () => { capturedParams = undefined; }); - it('infers a body schema from @Body() DTO metadata and returns parsed values', async () => { + it('infers a body schema from @Body() class metadata and returns parsed values', async () => { const response = await app.inject({ method: 'POST', payload: { @@ -264,7 +255,7 @@ describe('Nest native Standard Schema integration', () => { price: 49.9, active: true, }); - expect(capturedBody).not.toBeInstanceOf(CreateProductDto); + expect(capturedBody).not.toBeInstanceOf(CreateProduct); expect(response.json()).toEqual({ id: 1, name: 'Keyboard',