diff --git a/README.md b/README.md index b4d4601..f1471b5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Type Provider for [json-schema-to-ts](https://github.com/ThomasAribart/json-sc ## Install -``` +```bash npm i @fastify/type-provider-json-schema-to-ts ``` @@ -18,8 +18,7 @@ It is required to use `TypeScript@4.3` or above with [`strict`](https://www.typescriptlang.org/tsconfig#strict) mode enabled and [`noStrictGenericChecks`](https://www.typescriptlang.org/tsconfig#noStrictGenericChecks) -disabled. You may take the below configuration (`tsconfig.json`) -as an example. +disabled. You may take the following configuration (`tsconfig.json`) as an example: ```json { @@ -33,7 +32,7 @@ as an example. ## Plugin definition > **Note** -> When using plugin types, withTypeProvider is not required in order to register the plugin +> When using plugin types, `withTypeProvider` is not required to register the plugin. ```ts const plugin: FastifyPluginAsyncJsonSchemaToTs = async function ( @@ -56,17 +55,17 @@ const plugin: FastifyPluginAsyncJsonSchemaToTs = async function ( }, }, (req) => { - /// The `x`, `y`, and `z` types are automatically inferred + // The `x`, `y`, and `z` types are automatically inferred const { x, y, z } = req.body; } ); }; ``` -## Using References from a Shared Schema +## Setting FromSchema for the validator and serializer -JsonSchemaToTsProvider takes a generic that can be passed in the Shared Schema -as shown in the following example +You can set the `FromSchema` settings for things like [`references`](https://github.com/ThomasAribart/json-schema-to-ts#references) and [`deserialization`](https://github.com/ThomasAribart/json-schema-to-ts#deserialization) for the validation and serialization schema by setting `ValidatorSchemaOptions` and `SerializerSchemaOptions` type parameters. +You can use the `deserialize` option in `SerializerSchemaOptions` to allow Date objects in place of date-time strings or other special serialization rules handled by [fast-json-stringify](https://github.com/fastify/fast-json-stringify?tab=readme-ov-file#specific-use-cases). ```ts const userSchema = { @@ -77,23 +76,53 @@ const userSchema = { familyName: { type: "string" }, }, required: ["givenName", "familyName"], -} as const; +} as const satisfies JSONSchema; const sharedSchema = { $id: "shared-schema", definitions: { user: userSchema, }, -} as const; +} as const satisfies JSONSchema; + +const userProfileSchema = { + $id: "userProfile", + type: "object", + additionalProperties: false, + properties: { + user: { + $ref: "shared-schema#/definitions/user", + }, + joinedAt: { type: "string", format: "date-time" }, + }, + required: ["user", "joinedAt"], +} as const satisfies JSONSchema + -// then when using JsonSchemaToTsProvider, the shared schema is passed through the generic -// references takes an array so can pass in multiple shared schema -const fastify = - Fastify().withTypeProvider< - JsonSchemaToTsProvider<{ references: [typeof sharedSchema] }> - >(); +type UserProfile = FromSchema; + +// Use JsonSchemaToTsProvider with shared schema references +const fastify = Fastify().withTypeProvider< + JsonSchemaToTsProvider<{ + ValidatorSchemaOptions: { + references: [typeof sharedSchema] + } + }> +>(); + +const fastify = Fastify().withTypeProvider< + JsonSchemaToTsProvider<{ + ValidatorSchemaOptions: { references: [typeof sharedSchema] } + SerializerSchemaOptions: { + references: [typeof userProfileSchema] + deserialize: [{ pattern: { type: "string"; format: "date-time" }; output: Date }] + } + }> +>() -// now reference the shared schema like the following fastify.get( "/profile", { @@ -107,11 +136,128 @@ fastify.get( }, required: ['user'], }, + response: { + 200: { $ref: "userProfile#" }, + }, } as const, }, - (req) => { - // givenName and familyName will be correctly typed as strings! + (req, reply) => { + // `givenName` and `familyName` are correctly typed as strings const { givenName, familyName } = req.body.user; + + // Construct a compatible response type + const profile: UserProfile = { + user: { givenName: "John", familyName: "Doe" }, + joinedAt: new Date(), // Returning a Date object + }; + + // A type error is surfaced if profile doesn't match the serialization schema + reply.send(profile) } -); +) +``` + +## Using References in a Plugin Definition + +When defining a plugin, shared schema references and deserialization options can also be used with `FastifyPluginAsyncJsonSchemaToTs` and `FastifyPluginCallbackJsonSchemaToTs`. + +### Example + +```ts +const schemaPerson = { + $id: "schema:person", + type: "object", + additionalProperties: false, + properties: { + givenName: { type: "string" }, + familyName: { type: "string" }, + joinedAt: { type: "string", format: "date-time" }, + }, + required: ["givenName", "familyName"], +} as const satisfies JSONSchema; + +const plugin: FastifyPluginAsyncJsonSchemaToTs<{ + ValidatorSchemaOptions: { references: [typeof schemaPerson] } + SerializerSchemaOptions: { + references: [typeof schemaPerson] + deserialize: [{ pattern: { type: "string"; format: "date-time" }; output: Date }] + }; +}> = async function (fastify, _opts) { + fastify.addSchema(schemaPerson) + + fastify.get( + "/profile", + { + schema: { + body: { + type: "object", + properties: { + user: { + $ref: "schema:person", + }, + }, + required: ['user'], + }, + response: { + 200: { $ref: "schema:person" }, + }, + }, // as const satisfies JSONSchema is not required thanks to FastifyPluginAsyncJsonSchemaToTs + }, + (req, reply) => { + // `givenName`, `familyName`, and `joinedAt` are correctly typed as strings and validated for format. + const { givenName, familyName, joinedAt } = req.body.user; + + // Send a serialized response + reply.send({ + givenName: "John", + familyName: "Doe", + // Date objects form DB queries can be returned directly and transformed to string by fast-json-stringify + joinedAt: new Date(), + }) + } + ) +} + +const callbackPlugin: FastifyPluginCallbackJsonSchemaToTs<{ + ValidatorSchemaOptions: { references: [typeof schemaPerson] } + SerializerSchemaOptions: { + references: [typeof schemaPerson] + deserialize: [{ pattern: { type: "string"; format: "date-time" }; output: Date }] + }; +}> = (fastify, options, done) => { + // Type check for custom options + expectType(options.optionA) + + // Schema is already added above + // fastify.addSchema(schemaPerson); + + fastify.get( + "/callback-profile", + { + schema: { + body: { + type: "object", + properties: { + user: { $ref: "schema:person" }, + }, + required: ["user"], + }, + response: { + 200: { $ref: "schema:person" }, + }, + }, + }, + (req, reply) => { + const { givenName, familyName, joinedAt } = req.body.user + + reply.send({ + givenName, + familyName, + joinedAt: new Date(), + }); + } + ); + + done() +}; ``` diff --git a/index.ts b/index.ts index 3c0fd37..d2536a2 100644 --- a/index.ts +++ b/index.ts @@ -10,11 +10,38 @@ import { import { FromSchema, FromSchemaDefaultOptions, FromSchemaOptions, JSONSchema } from 'json-schema-to-ts' -export interface JsonSchemaToTsProvider extends FastifyTypeProvider { - validator: this['schema'] extends JSONSchema ? FromSchema : unknown; - serializer: this['schema'] extends JSONSchema ? FromSchema : unknown; +export interface JsonSchemaToTsProviderOptions { + ValidatorSchemaOptions?: FromSchemaOptions; + SerializerSchemaOptions?: FromSchemaOptions; } +export interface JsonSchemaToTsProvider< + Options extends JsonSchemaToTsProviderOptions = {} +> extends FastifyTypeProvider { + validator: this['schema'] extends JSONSchema + ? FromSchema< + this['schema'], + Options['ValidatorSchemaOptions'] extends FromSchemaOptions + ? Options['ValidatorSchemaOptions'] + : FromSchemaDefaultOptions + > + : unknown; + serializer: this['schema'] extends JSONSchema + ? FromSchema< + this['schema'], + Options['SerializerSchemaOptions'] extends FromSchemaOptions + ? Options['SerializerSchemaOptions'] + : FromSchemaDefaultOptions + > + : unknown; +} + +export interface FastifyPluginJsonSchemaToTsOptions extends JsonSchemaToTsProviderOptions { + Options?: FastifyPluginOptions; + Server?: RawServerBase; + Logger?: FastifyBaseLogger; +}; + /** * FastifyPluginCallback with JSON Schema to Typescript automatic type inference * @@ -22,30 +49,78 @@ export interface JsonSchemaToTsProvider { + * const plugin: FastifyPluginCallbackJsonSchemaToTs<{ + * ValidatorSchemaOptions: { + * references: [ SchemaWrite ], + * }, + * SerializerSchemaOptions: { + * references: [ SchemaRead ], + * deserialize: [{ pattern: { type: 'string'; format: 'date-time'; }; output: Date; }] + * } + * }> = (fastify, options, done) => { * done() * } * ``` */ export type FastifyPluginCallbackJsonSchemaToTs< - Options extends FastifyPluginOptions = Record, - Server extends RawServerBase = RawServerDefault, - Logger extends FastifyBaseLogger = FastifyBaseLogger -> = FastifyPluginCallback + Options extends FastifyPluginJsonSchemaToTsOptions = {} +> = FastifyPluginCallback< + Options['Options'] extends FastifyPluginOptions + ? Options['Options'] + : Record, + Options['Server'] extends RawServerBase + ? Options['Server'] + : RawServerDefault, + JsonSchemaToTsProvider<{ + ValidatorSchemaOptions: Options['ValidatorSchemaOptions'] extends FromSchemaOptions + ? Options['ValidatorSchemaOptions'] + : FromSchemaDefaultOptions, + SerializerSchemaOptions: Options['SerializerSchemaOptions'] extends FromSchemaOptions + ? Options['SerializerSchemaOptions'] + : FromSchemaDefaultOptions + }>, + Options['Logger'] extends FastifyBaseLogger + ? Options['Logger'] + : FastifyBaseLogger +> /** * FastifyPluginAsync with JSON Schema to Typescript automatic type inference * * @example * ```typescript - * import { FastifyPluginAsyncJsonSchemaToTs } fromg "@fastify/type-provider-json-schema-to-ts" + * import { FastifyPluginAsyncJsonSchemaToTs } from "@fastify/type-provider-json-schema-to-ts" * - * const plugin: FastifyPluginAsyncJsonSchemaToTs = async (fastify, options) => { + * const plugin: FastifyPluginAsyncJsonSchemaToTs<{ + * ValidatorSchemaOptions: { + * references: [ SchemaWrite ], + * }, + * SerializerSchemaOptions: { + * references: [ SchemaRead ], + * deserialize: [{ pattern: { type: 'string'; format: 'date-time'; }; output: Date; }] + * } + * }> = async (fastify, options) => { * } * ``` */ export type FastifyPluginAsyncJsonSchemaToTs< - Options extends FastifyPluginOptions = Record, - Server extends RawServerBase = RawServerDefault, - Logger extends FastifyBaseLogger = FastifyBaseLogger -> = FastifyPluginAsync + Options extends FastifyPluginJsonSchemaToTsOptions = {} +> = FastifyPluginAsync< + Options['Options'] extends FastifyPluginOptions + ? Options['Options'] + : Record, + Options['Server'] extends RawServerBase + ? Options['Server'] + : RawServerDefault, + JsonSchemaToTsProvider<{ + ValidatorSchemaOptions: Options['ValidatorSchemaOptions'] extends FromSchemaOptions + ? Options['ValidatorSchemaOptions'] + : FromSchemaDefaultOptions, + SerializerSchemaOptions: Options['SerializerSchemaOptions'] extends FromSchemaOptions + ? Options['SerializerSchemaOptions'] + : FromSchemaDefaultOptions + }>, + Options['Logger'] extends FastifyBaseLogger + ? Options['Logger'] + : FastifyBaseLogger +> diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 6322dbf..a7da6fa 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -16,7 +16,7 @@ fastify.get('/', { z: { type: 'boolean' } }, required: ['x', 'y', 'z'] - } as const + } } }, (req) => { expectType(req.body.z) diff --git a/types/plugin.test-d.ts b/types/plugin.test-d.ts index 174c305..afe3bc8 100644 --- a/types/plugin.test-d.ts +++ b/types/plugin.test-d.ts @@ -7,6 +7,8 @@ import { import { Http2Server } from 'node:http2' +import type { JSONSchema } from 'json-schema-to-ts' + // Ensure the defaults of FastifyPluginAsyncJsonSchemaToTs are the same as FastifyPluginAsync export const pluginAsyncDefaults: FastifyPluginAsync = async ( fastify, @@ -41,10 +43,10 @@ export const pluginCallbackDefaults: FastifyPluginCallback = ( done() } -const asyncPlugin: FastifyPluginAsyncJsonSchemaToTs< - { optionA: string }, - Http2Server -> = async (fastify, options) => { +const asyncPlugin: FastifyPluginAsyncJsonSchemaToTs<{ + Options: { optionA: string }, + Server: Http2Server +}> = async (fastify, options) => { expectType(fastify.server) expectType(options.optionA) @@ -61,7 +63,7 @@ const asyncPlugin: FastifyPluginAsyncJsonSchemaToTs< z: { type: 'boolean' } }, required: ['x', 'y', 'z'] - } as const + } } }, (req) => { @@ -72,10 +74,83 @@ const asyncPlugin: FastifyPluginAsyncJsonSchemaToTs< ) } -const callbackPlugin: FastifyPluginCallbackJsonSchemaToTs< - { optionA: string }, - Http2Server -> = (fastify, options, done) => { +const userSchema = { + $id: 'UserSchema', + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name', 'age'] +} as const satisfies JSONSchema + +const serializedUserSchema = { + $id: 'SerializedUserSchema', + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + registeredAt: { type: 'string', format: 'date-time' } + }, + required: ['name', 'age', 'registeredAt'] +} as const satisfies JSONSchema + +const asyncPluginWithSchemaOptions: FastifyPluginAsyncJsonSchemaToTs<{ + Options: { optionA: string }, + ValidatorSchemaOptions: { + references: [typeof userSchema] + }, + SerializerSchemaOptions: { + references: [typeof serializedUserSchema], + deserialize: [{ pattern: { type: 'string'; format: 'date-time' }; output: Date }] + } +}> = async (fastify, options) => { + expectType(options.optionA) + + // Register schemas + fastify.addSchema(userSchema) + fastify.addSchema(serializedUserSchema) + + fastify.get( + '/option', + { + schema: { + querystring: { + type: 'object', + properties: { + foo: { + type: 'boolean', + default: true + } + } + }, + body: { + type: 'object', + $ref: 'UserSchema#' + }, + response: { + 200: { $ref: 'SerializedUserSchema#' } + } + } + }, + async (req, reply) => { + expectType(req.query.foo) + expectType(req.body.name) + expectType(req.body.age) + + reply.send({ + name: req.body.name, + age: req.body.age, + registeredAt: new Date() + }) + } + ) +} + +const callbackPlugin: FastifyPluginCallbackJsonSchemaToTs<{ + Options: { optionA: string }, + Server: Http2Server +}> = (fastify, options, done) => { expectType(fastify.server) expectType(options.optionA) @@ -92,7 +167,7 @@ const callbackPlugin: FastifyPluginCallbackJsonSchemaToTs< z: { type: 'boolean' } }, required: ['x', 'y', 'z'] - } as const + } } }, (req) => { @@ -104,7 +179,50 @@ const callbackPlugin: FastifyPluginCallbackJsonSchemaToTs< done() } +const callbackPluginWithSchemaOptions: FastifyPluginCallbackJsonSchemaToTs<{ + Options: { optionA: string }, + ValidatorSchemaOptions: { + references: [typeof userSchema] + }, + SerializerSchemaOptions: { + references: [typeof serializedUserSchema], + deserialize: [{ pattern: { type: 'string'; format: 'date-time' }; output: Date }] + } +}> = (fastify, options, done) => { + expectType(options.optionA) + + // Register schemas + fastify.addSchema(userSchema) + fastify.addSchema(serializedUserSchema) + + fastify.get( + '/callback-option', + { + schema: { + body: { $ref: 'UserSchema#' }, + response: { + 200: { $ref: 'SerializedUserSchema#' } + } + } + }, + (req, reply) => { + expectType(req.body.name) + expectType(req.body.age) + + reply.send({ + name: req.body.name, + age: req.body.age, + registeredAt: new Date() + }) + } + ) + + done() +} + const fastify = Fastify() fastify.register(asyncPlugin, { optionA: 'a' }) +fastify.register(asyncPluginWithSchemaOptions, { optionA: 'a' }) fastify.register(callbackPlugin, { optionA: 'a' }) +fastify.register(callbackPluginWithSchemaOptions, { optionA: 'a' }) diff --git a/types/references.test-d.ts b/types/references.test-d.ts index 1bb5f3a..c8c08f8 100644 --- a/types/references.test-d.ts +++ b/types/references.test-d.ts @@ -1,8 +1,11 @@ import { JsonSchemaToTsProvider } from '../index' import { expectAssignable, expectType } from 'tsd' -import Fastify, { FastifyInstance, FastifyLoggerInstance, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault } from 'fastify' +import Fastify, { FastifyInstance, FastifyBaseLogger, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault } from 'fastify' import { FromSchema } from 'json-schema-to-ts' +import type { JSONSchema } from 'json-schema-to-ts' + +// Define schemas const addressSchema = { type: 'object', additionalProperties: false, @@ -12,7 +15,7 @@ const addressSchema = { city: { type: 'string' } }, required: ['line1', 'city'] -} as const +} as const satisfies JSONSchema type Address = FromSchema const userSchema = { @@ -23,10 +26,9 @@ const userSchema = { familyName: { type: 'string' } }, required: ['givenName', 'familyName'] -} as const +} as const satisfies JSONSchema type User = FromSchema -// eslint-disable-next-line @typescript-eslint/no-unused-vars const sharedSchema = { $id: 'shared-schema', definitions: { @@ -35,29 +37,93 @@ const sharedSchema = { } } as const +const userProfileSchema = { + $id: 'userProfile', + type: 'object', + additionalProperties: false, + properties: { + user: { $ref: 'shared-schema#/definitions/user' }, + address: { $ref: 'shared-schema#/definitions/address' }, + joinedAt: { type: 'string', format: 'date-time' } + }, + required: ['user', 'address', 'joinedAt'] +} as const satisfies JSONSchema +type UserProfile = FromSchema + type JsonSchemaToTsProviderWithSharedSchema = JsonSchemaToTsProvider<{ - references: [typeof sharedSchema]; + ValidatorSchemaOptions: { + references: [typeof sharedSchema]; + }, + SerializerSchemaOptions: { + references: [typeof userProfileSchema, typeof sharedSchema]; + deserialize: [{ pattern: { type: 'string'; format: 'date-time' }; output: Date }]; + } }> + const fastify = Fastify().withTypeProvider() -expectAssignable>(fastify) +expectAssignable>(fastify) + +// Register schemas +fastify.addSchema(sharedSchema) +fastify.addSchema(userProfileSchema) +// Test validation schema fastify.get('/profile', { schema: { body: { type: 'object', properties: { - user: { - $ref: 'shared-schema#/definitions/user' - }, - address: { - $ref: 'shared-schema#/definitions/address' - } + user: { $ref: 'shared-schema#/definitions/user' }, + address: { $ref: 'shared-schema#/definitions/address' } }, required: ['user', 'address'] - } as const + } } }, (req) => { expectType(req.body.user) expectType
(req.body.address) }) + +// Test serialization and validation schemas +fastify.get('/profile-serialized', { + schema: { + body: { + type: 'object', + properties: { + user: { $ref: 'shared-schema#/definitions/user' }, + address: { $ref: 'shared-schema#/definitions/address' } + }, + required: ['user', 'address'] + }, + response: { + 200: { $ref: 'userProfile#' } + } + } +}, (req, reply) => { + // Ensure type correctness for request body + expectType(req.body.user) + expectType
(req.body.address) + + // Create response + const profile: UserProfile = { + user: { + givenName: 'John', + familyName: 'Doe' + }, + address: { + line1: '123 Main St', + line2: 'Apt 4B', + city: 'Springfield' + }, + joinedAt: new Date() // Returning a Date object + } + + // Ensure type correctness for response + expectType(profile) + expectType(profile.joinedAt) // Ensure joinedAt is typed as Date + reply.send(profile) +})