From f27e6f8e4ebe9a71ce04f9e91c36337b306824a1 Mon Sep 17 00:00:00 2001 From: Alexander Ryzhikov Date: Sun, 29 Oct 2023 06:28:34 +0200 Subject: [PATCH] feat!: Better support for json-like input and outputs in the schema --- generator/gen.ts | 5 ++ package.json | 4 +- pnpm-lock.yaml | 27 ++++++-- src/schema.ts | 20 +++++- src/type-utils.ts | 60 +++++++++++++++++ src/typed-fastify.ts | 17 ++--- .../test/integration.test.ts.test.cjs | 49 ++++++++++++-- test/fixtures.ts | 11 ++-- test/integration.test.ts | 17 ++++- test/test_schema.gen.json | 64 ++++++++++++++----- test/test_schema.ts | 12 +++- test/typed-fastify.test-d.ts | 50 +++++++++++++++ 12 files changed, 288 insertions(+), 48 deletions(-) create mode 100644 src/type-utils.ts diff --git a/generator/gen.ts b/generator/gen.ts index 83fb7e5..2e06adf 100755 --- a/generator/gen.ts +++ b/generator/gen.ts @@ -124,15 +124,20 @@ export default async (params: { files: string[] }) => { }; const PLACEHOLDER_ID = '@__PLACEHOLDER_ID__@' + Date.now(); + const defaultAgs = TJS.getDefaultArgs(); + const settings: TJS.PartialArgs = { required: true, ref: true, + noExtraProps: true, aliasRef: false, skipLibCheck: true, topRef: true, ignoreErrors: true, strictNullChecks: true, id: PLACEHOLDER_ID, + // add support for ajv-keywords + validationKeywords: [...defaultAgs.validationKeywords, 'instanceof', 'typeof'], }; let { files } = params; diff --git a/package.json b/package.json index 321f4ae..bc563d0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "glob": "^10.3.4", "json-schema-merge-allof": "^0.8.1", "json-schema-traverse": "^1.0.0", + "type-fest": "^4.6.0", "typescript-json-schema": "^0.61.0", "yargs": "^17.7.2" }, @@ -26,6 +27,8 @@ "@types/split2": "^4.2.0", "@types/tap": "^15.0.9", "@types/yargs": "^17.0.24", + "ajv-formats": "2.1.1", + "ajv-keywords": "5.1.0", "coveralls": "3.1.1", "fastify": "^4.23.2", "husky": "^8.0.3", @@ -38,7 +41,6 @@ "tap": "^16.3.8", "ts-node-dev": "^2.0.0", "tsd": "^0.29.0", - "type-fest": "^4.3.1", "typescript": "^5.2.2" }, "directories": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfe2fcc..dec562b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: json-schema-traverse: specifier: ^1.0.0 version: 1.0.0 + type-fest: + specifier: ^4.6.0 + version: 4.6.0 typescript-json-schema: specifier: ^0.61.0 version: 0.61.0 @@ -58,6 +61,12 @@ devDependencies: '@types/yargs': specifier: ^17.0.24 version: 17.0.24 + ajv-formats: + specifier: 2.1.1 + version: 2.1.1(ajv@8.12.0) + ajv-keywords: + specifier: 5.1.0 + version: 5.1.0(ajv@8.12.0) coveralls: specifier: 3.1.1 version: 3.1.1 @@ -94,9 +103,6 @@ devDependencies: tsd: specifier: ^0.29.0 version: 0.29.0 - type-fest: - specifier: ^4.3.1 - version: 4.3.1 typescript: specifier: ^5.2.2 version: 5.2.2 @@ -689,6 +695,15 @@ packages: ajv: 8.12.0 dev: true + /ajv-keywords@5.1.0(ajv@8.12.0): + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + dependencies: + ajv: 8.12.0 + fast-deep-equal: 3.1.3 + dev: true + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -3617,10 +3632,10 @@ packages: engines: {node: '>=10'} dev: true - /type-fest@4.3.1: - resolution: {integrity: sha512-pphNW/msgOUSkJbH58x8sqpq8uQj6b0ZKGxEsLKMUnGorRcDjrUaLS+39+/ub41JNTwrrMyJcUB8+YZs3mbwqw==} + /type-fest@4.6.0: + resolution: {integrity: sha512-rLjWJzQFOq4xw7MgJrCZ6T1jIOvvYElXT12r+y0CC6u67hegDHaxcPqb2fZHOGlqxugGQPNB1EnTezjBetkwkw==} engines: {node: '>=16'} - dev: true + dev: false /typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} diff --git a/src/schema.ts b/src/schema.ts index 9a43385..9cf209c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -6,6 +6,7 @@ import type { Stream } from 'stream'; export type StatusCode = | 100 | '100' // Continue | 101 | '101' // Switching Protocols + | 103 | '103' // Early Hints | 200 | '200' // OK | 201 | '201' // Created | 202 | '202' // Accepted @@ -13,6 +14,9 @@ export type StatusCode = | 204 | '204' // No Content | 205 | '205' // Reset Content | 206 | '206' // Partial Content + | 207 | '207' // Multi-Status + | 208 | '208' // Already Reported + | 226 | '226' // IM Used | 300 | '300' // Multiple Choices | 301 | '301' // Moved Permanently | 302 | '302' // Found @@ -20,6 +24,7 @@ export type StatusCode = | 304 | '304' // Not Modified | 305 | '305' // Use Proxy | 307 | '307' // Temporary Redirect + | 308 | '308' // Permanent Redirect | 400 | '400' // Bad Request | 401 | '401' // Unauthorized | 402 | '402' // Payment Required @@ -39,12 +44,22 @@ export type StatusCode = | 416 | '416' // Range Not Satisfiable | 417 | '417' // Expectation Failed | 426 | '426' // Upgrade Required + | 427 | '427' // Unassigned + | 428 | '428' // Precondition Required + | 429 | '429' // Too Many Requests + | 431 | '431' // Request Header Fields Too Large + | 451 | '451' // Unavailable For Legal Reasons | 500 | '500' // Internal Server Error | 501 | '501' // Not Implemented | 502 | '502' // Bad Gateway | 503 | '503' // Service Unavailable | 504 | '504' // Gateway Timeout - | 505 | '505'; // HTTP Version Not Supported; + | 505 | '505' // HTTP Version Not Supported; + | 506 | '506' // Variant Also Negotiates + | 507 | '507' // Insufficient Storage + | 508 | '508' // Loop Detected + | 510 | '510' // Not Extended + | 511 | '511' // Network Authentication Required export interface FastifyError extends Error { code: string; @@ -71,7 +86,8 @@ export interface Operation { }; }; } + export interface Schema { readonly __SCHEMA_TAG__?: 'BETTER-FASTIFY-SCHEMA'; - paths: Record<`${HTTPMethods} ${string}`, Partial>; + paths: Record<`${HTTPMethods} ${string}`, Operation>; } diff --git a/src/type-utils.ts b/src/type-utils.ts new file mode 100644 index 0000000..a946a1f --- /dev/null +++ b/src/type-utils.ts @@ -0,0 +1,60 @@ +import { PositiveInfinity, NegativeInfinity } from 'type-fest/source/numeric'; +import { JsonPrimitive, JsonValue } from 'type-fest/source/basic'; +import { EmptyObject } from 'type-fest/source/empty-object'; +import { TypedArray } from 'type-fest/source/typed-array'; +import { JsonifyList } from 'type-fest/source/jsonify'; +import { WritableDeep } from 'type-fest/source/writable-deep'; + +export type Id = T extends infer U ? { [K in keyof U]: U[K] } : never; +export type Get = P extends keyof T ? T[P] : never; +export type Get2 = Get, P2>; +export type IsEqual = (() => G extends T ? 1 : 2) extends () => G extends U ? 1 : 2 ? true : false; + +type IsNotJsonableError = Invalid<`${Extract} is not Json-like`> & {}; + +type NotJsonable = ((...arguments_: any[]) => any) | undefined | symbol | RegExp | Function; + +// tweaked version of Jsonify from type-fest +export type Jsonlike = T extends PositiveInfinity | NegativeInfinity + ? null + : T extends NotJsonable + ? IsNotJsonableError<'Passed value'> + : T extends JsonPrimitive + ? T + : // Any object with toJSON is special case + T extends { + toJSON(): infer J; + } + ? (() => J) extends () => JsonValue // Is J assignable to JsonValue? + ? DoNotCastToPrimitive extends true + ? T + : J // Then T is Jsonable and its Jsonable value is J + : Jsonlike // Maybe if we look a level deeper we'll find a JsonValue + : // Instanced primitives are objects + T extends Number + ? number + : T extends String + ? string + : T extends Boolean + ? boolean + : T extends Map | Set + ? EmptyObject + : T extends TypedArray + ? Record // Non-JSONable type union was found not empty + : T extends [] + ? [] + : T extends unknown[] + ? JsonifyList + : T extends readonly unknown[] + ? JsonifyList> + : T extends object + ? { + [K in keyof T]: [T[K]] extends [NotJsonable] | [never] + ? IsNotJsonableError + : Jsonlike; + } // JsonifyObject recursive call for its children + : IsNotJsonableError<'Passed value'>; + +export interface Invalid { + readonly __INVALID__: unique symbol; +} diff --git a/src/typed-fastify.ts b/src/typed-fastify.ts index 84e29ab..d7bdcca 100644 --- a/src/typed-fastify.ts +++ b/src/typed-fastify.ts @@ -1,5 +1,4 @@ import type * as F from 'fastify'; -import type { Jsonify } from 'type-fest'; import { RouteGenericInterface } from 'fastify/types/route'; import { RequestRouteOptions } from 'fastify/types/request'; @@ -10,6 +9,7 @@ import { ResolveFastifyRequestType, } from 'fastify/types/type-provider'; import { Operation, Schema } from './schema'; +import { Jsonlike, Id, Get2, Get, IsEqual, Invalid } from './type-utils'; const addSchema = < ServiceSchema extends Schema, @@ -225,7 +225,7 @@ interface Reply< ] : [Get2] extends [never] ? [] - : [Get2 | Jsonify>] + : [Jsonlike, true>] ): AsReply; readonly request: Request; @@ -283,10 +283,6 @@ type OpaqueReply< Opaque = Reply, > = Status extends unknown ? Opaque : Content extends unknown ? Opaque : Headers extends unknown ? Opaque : never; -interface Invalid { - readonly __INVALID__: unique symbol; -} - interface AsReply { readonly __REPLY_SYMBOL__: unique symbol; then(fulfilled: () => void, rejected: (err: Error) => void): void; @@ -296,8 +292,6 @@ export const asReply = (any: any) => { assertAsReply(any); return any; }; -type Get = P extends keyof T ? T[P] : never; -type Get2 = Get, P2>; interface Router { Querystring: Get; @@ -307,7 +301,6 @@ interface Router { // force reply to be never, as we expose it via custom reply interface Reply: never; } -type Id = T extends infer U ? { [K in keyof U]: U[K] } : never; interface Request< ServiceSchema extends Schema, @@ -330,9 +323,11 @@ interface Request< ROptions extends Omit, 'method' | 'url'> & { method: MP[0]; url: MP[1]; + body: Get; } = Omit, 'method' | 'url'> & { method: MP[0]; url: MP[1]; + body: Get; }, > extends Omit< F.FastifyRequest< @@ -350,15 +345,13 @@ interface Request< readonly operationPath: Path; readonly method: ROptions['method']; // A payload within a GET request message has no defined semantics; sending a payload body on a GET request might cause some existing implementations to reject the request. - readonly body: ROptions['method'] extends 'GET' ? never : Jsonify>; + readonly body: ROptions['method'] extends 'GET' ? never : Jsonlike; readonly routeOptions: Id>; readonly routerMethod: ROptions['method']; readonly headers: Get; readonly routerPath: ROptions['url']; } -type IsEqual = (() => G extends T ? 1 : 2) extends () => G extends U ? 1 : 2 ? true : false; - type GetInvalidParamsValidation< Op extends Operation, Path extends keyof ServiceSchema['paths'], diff --git a/tap-snapshots/test/integration.test.ts.test.cjs b/tap-snapshots/test/integration.test.ts.test.cjs index 4cc6686..d00f562 100644 --- a/tap-snapshots/test/integration.test.ts.test.cjs +++ b/tap-snapshots/test/integration.test.ts.test.cjs @@ -1063,7 +1063,7 @@ exports[`test/integration.test.ts TAP it works with /jsonify > request path:POST Object { "Body": undefined, "Headers": Object { - "content-length": "35", + "content-length": "53", "content-type": "application/json", "host": "localhost:80", "user-agent": "lightMyRequest", @@ -1078,9 +1078,14 @@ Object { "format": "date-time", "type": "string", }, + "regexp": Object { + "format": "regex", + "type": "string", + }, }, "required": Array [ "date", + "regexp", ], "type": "object", }, @@ -1092,9 +1097,17 @@ Object { "format": "date-time", "type": "string", }, + "regexpType": Object { + "type": "string", + }, + "type": Object { + "type": "string", + }, }, "required": Array [ "date", + "regexpType", + "type", ], "type": "object", }, @@ -1108,13 +1121,15 @@ Object { "Headers": Array [ "HTTP/1.1 200 OK", "content-type: application/json; charset=utf-8", - "content-length: 35", + "content-length: 73", "Date: dateString", "Connection: keep-alive", ], "Payload": Array [ Object { - "date": "2023-10-28T13:31:57.949Z", + "date": "1970-01-01T00:00:00.000Z", + "regexpType": "string", + "type": "string", }, ], } @@ -1318,7 +1333,7 @@ Object { "Headers": Array [ "HTTP/1.1 200 OK", "content-type: application/json; charset=utf-8", - "content-length: 4316", + "content-length: 4445", "Date: dateString", "Connection: keep-alive", ], @@ -1592,9 +1607,14 @@ Object { "format": "date-time", "type": "string", }, + "regexp": Object { + "format": "regex", + "type": "string", + }, }, "required": Array [ "date", + "regexp", ], "type": "object", }, @@ -1610,9 +1630,17 @@ Object { "format": "date-time", "type": "string", }, + "regexpType": Object { + "type": "string", + }, + "type": Object { + "type": "string", + }, }, "required": Array [ "date", + "regexpType", + "type", ], "type": "object", }, @@ -2033,9 +2061,14 @@ Object { "format": "date-time", "type": "string", }, + "regexp": Object { + "format": "regex", + "type": "string", + }, }, "required": Array [ "date", + "regexp", ], "type": "object", }, @@ -2051,9 +2084,17 @@ Object { "format": "date-time", "type": "string", }, + "regexpType": Object { + "type": "string", + }, + "type": Object { + "type": "string", + }, }, "required": Array [ "date", + "regexpType", + "type", ], "type": "object", }, diff --git a/test/fixtures.ts b/test/fixtures.ts index 30c5245..350a5a5 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -14,7 +14,6 @@ class MyObjectId extends String implements ObjectId { return this.id; } } - export const defaultService: Service = { 'GET /': (req, reply) => { if (req.operationPath !== 'GET /') { @@ -29,9 +28,13 @@ export const defaultService: Service = { return reply.status(200).send([{ name: 'user1' }, { id: '1', type: 'TEST' }, { any: 'thing' }]); }, 'POST /jsonify': (req, reply) => { - const { date } = req.body; - date.charAt; // ok for string, not ok for Date - return reply.status(200).send({ date: new Date(date).toJSON() }); + const { date, regexp } = req.body; + + return reply.status(200).send({ + date: new Date(date), + type: typeof date, + regexpType: typeof regexp, + }); }, 'POST /': (req, reply) => { if (req.operationPath !== 'POST /') { diff --git a/test/integration.test.ts b/test/integration.test.ts index fefeb96..fda7298 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -6,7 +6,8 @@ import t from 'tap'; import addSchema from '../src'; import { defaultJsonSchema, defaultService } from './fixtures'; import fastifySwaggerUi from '@fastify/swagger-ui'; - +import formatsPlugin from 'ajv-formats'; +import keywordsPlugin from 'ajv-keywords'; type Test = (typeof tap)['Test']['prototype']; t.cleanSnapshot = (s) => { @@ -27,6 +28,9 @@ const buildApp = async ({ let stream = split(() => {}); const app = fastify({ + ajv: { + plugins: [formatsPlugin, [keywordsPlugin, ['typeof', 'instanceof']]], + }, logger: { stream, serializers: { @@ -419,3 +423,14 @@ t.test('it does not interfere with prefixed plugin', async (t) => { const res = await app.inject({ url: '/prefixed' }); t.same(res.body, 'true'); }); + +t.test('it works with /jsonify', async (t) => { + const app = await buildApp({ t }); + const res = await app.inject({ + url: '/jsonify', + method: 'POST', + payload: { date: new Date(0), regexp: /test/.toString() }, + }); + t.equal(res.statusCode, 200); + t.same(res.json(), { date: new Date(0).toJSON(), type: 'string', regexpType: 'string' }); +}); diff --git a/test/test_schema.gen.json b/test/test_schema.gen.json index ba4d29d..8ed1841 100644 --- a/test/test_schema.gen.json +++ b/test/test_schema.gen.json @@ -8,9 +8,11 @@ "required": [ "headers" ], + "additionalProperties": false, "properties": { "headers": { "type": "object", + "additionalProperties": false, "properties": { "authorization": { "type": "string" @@ -24,6 +26,7 @@ "required": [ "name" ], + "additionalProperties": false, "properties": { "name": { "type": "string" @@ -36,6 +39,7 @@ "id", "type" ], + "additionalProperties": false, "properties": { "type": { "type": "string" @@ -51,11 +55,16 @@ }, "TestObj": { "type": "object", - "$ref": "test_schema#/properties/Omit__Obj,\"type\"__", "required": [ + "id", "type" ], + "additionalProperties": false, "properties": { + "id": { + "format": "uuid", + "type": "string" + }, "type": { "type": "string", "const": "TEST" @@ -102,18 +111,6 @@ } } ] - }, - "Omit__Obj,\"type\"__": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "format": "uuid", - "type": "string" - } - } } }, "type": "object" @@ -121,15 +118,16 @@ "fastify": { "GET /": { "request": { - "$ref": "test_schema#/properties/SharedRequest", "type": "object", "required": [ "headers", "querystring" ], + "additionalProperties": false, "properties": { "querystring": { "type": "object", + "additionalProperties": false, "properties": { "getQueryParam": { "type": "boolean" @@ -142,6 +140,7 @@ "authorization", "getHeader" ], + "additionalProperties": false, "properties": { "authorization": { "type": "string" @@ -198,12 +197,14 @@ "required": [ "body" ], + "additionalProperties": false, "properties": { "body": { "type": "object", "required": [ "user" ], + "additionalProperties": false, "properties": { "user": { "$ref": "test_schema#/properties/User" @@ -219,6 +220,7 @@ "msg", "user" ], + "additionalProperties": false, "properties": { "user": { "$ref": "test_schema#/properties/User" @@ -236,16 +238,23 @@ "required": [ "body" ], + "additionalProperties": false, "properties": { "body": { "type": "object", "required": [ - "date" + "date", + "regexp" ], + "additionalProperties": false, "properties": { "date": { "type": "string", "format": "date-time" + }, + "regexp": { + "type": "string", + "format": "regex" } } } @@ -255,12 +264,21 @@ "200": { "type": "object", "required": [ - "date" + "date", + "regexpType", + "type" ], + "additionalProperties": false, "properties": { "date": { "type": "string", "format": "date-time" + }, + "type": { + "type": "string" + }, + "regexpType": { + "type": "string" } } } @@ -280,6 +298,7 @@ "required": [ "params" ], + "additionalProperties": false, "properties": { "params": { "type": "object", @@ -287,6 +306,7 @@ "id", "subid" ], + "additionalProperties": false, "properties": { "id": { "type": "number" @@ -308,6 +328,7 @@ "required": [ "frame" ], + "additionalProperties": false, "properties": { "frame": { "$ref": "test_schema#/properties/TestObj" @@ -322,12 +343,14 @@ "required": [ "params" ], + "additionalProperties": false, "properties": { "params": { "type": "object", "required": [ "castedToNumber" ], + "additionalProperties": true, "properties": { "castedToNumber": { "type": "number" @@ -348,12 +371,14 @@ "required": [ "querystring" ], + "additionalProperties": false, "properties": { "querystring": { "type": "object", "required": [ "match" ], + "additionalProperties": false, "properties": { "match": { "type": "string" @@ -368,6 +393,7 @@ "required": [ "value" ], + "additionalProperties": false, "properties": { "value": { "enum": [ @@ -386,12 +412,14 @@ "required": [ "querystring" ], + "additionalProperties": false, "properties": { "querystring": { "type": "object", "required": [ "reply" ], + "additionalProperties": false, "properties": { "reply": { "type": "string" @@ -406,6 +434,7 @@ "required": [ "value" ], + "additionalProperties": false, "properties": { "value": { "type": "string", @@ -423,6 +452,7 @@ "required": [ "id" ], + "additionalProperties": false, "properties": { "id": { "type": "string" @@ -432,5 +462,5 @@ } } }, - "$hash": "1252b062241bb72c9f02a27d8f3c6cee7f3bd8a1e7a8142a5a4b88038790d19f__v1.2.0" + "$hash": "38609094e259b0406fb6727e3c67af0ce5bad2bc2e945b8df8762d7f3bdf61c7__v1.2.0" } \ No newline at end of file diff --git a/test/test_schema.ts b/test/test_schema.ts index e2bb18c..07749ee 100644 --- a/test/test_schema.ts +++ b/test/test_schema.ts @@ -73,12 +73,21 @@ export interface TestSchema extends Schema { 'POST /jsonify': { request: { body: { + /** + * @type string + * @format date-time + */ date: Date; + /** + * @type string + * @format regex + */ + regexp: string; }; }; response: { 200: { - content: { date: Date }; + content: { date: Date; type: string; regexpType: string }; }; }; }; @@ -115,6 +124,7 @@ export interface TestSchema extends Schema { }; 'GET /inferredParams/:id/:castedToNumber': { request: { + /** @additionalProperties true */ params: { castedToNumber: number; }; diff --git a/test/typed-fastify.test-d.ts b/test/typed-fastify.test-d.ts index d88fa98..a9c8b6d 100644 --- a/test/typed-fastify.test-d.ts +++ b/test/typed-fastify.test-d.ts @@ -1,5 +1,6 @@ import { expectAssignable, expectType, expectNotType } from 'tsd'; import type { RequestHandler, Schema, Service } from '../src'; +import { Jsonlike, Invalid } from '../src/type-utils'; type User = { name: string; @@ -720,3 +721,52 @@ expectType>({ return reply.status(200).send(); }, }); + +function verifyJsonlike< + Input, + Expected extends Jsonlike, + DoNotCastToPrimitive extends boolean = false, +>() {} + +verifyJsonlike< + { + a: string; + b: { + toJSON(): number; + }; + c: { + toJSON(): { + toJSON(): string; + }; + }; + d: Date; + A: RegExp; + B: Function; + C: () => 1; + D: undefined; + }, + { + a: string; + b: number; + c: string; + d: string; + A: Invalid<'A is not Json-like'>; + B: Invalid<'B is not Json-like'>; + C: Invalid<'C is not Json-like'>; + D: Invalid<'D is not Json-like'>; + } +>(); + +verifyJsonlike< + { + a: Date; + b: Number; + A: RegExp; + }, + { + a: Date; + b: number; + A: Invalid<'A is not Json-like'>; + }, + true +>();