Skip to content

Commit

Permalink
feat!: Better support for json-like input and outputs in the schema
Browse files Browse the repository at this point in the history
  • Loading branch information
Coobaha committed Oct 29, 2023
1 parent bd72d57 commit f27e6f8
Show file tree
Hide file tree
Showing 12 changed files with 288 additions and 48 deletions.
5 changes: 5 additions & 0 deletions generator/gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
27 changes: 21 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,25 @@ 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
| 203 | '203' // Non-Authoritative Information
| 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
| 303 | '303' // See Other
| 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
Expand All @@ -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;
Expand All @@ -71,7 +86,8 @@ export interface Operation {
};
};
}

export interface Schema<SecurityId extends string = string> {
readonly __SCHEMA_TAG__?: 'BETTER-FASTIFY-SCHEMA';
paths: Record<`${HTTPMethods} ${string}`, Partial<Operation>>;
paths: Record<`${HTTPMethods} ${string}`, Operation>;
}
60 changes: 60 additions & 0 deletions src/type-utils.ts
Original file line number Diff line number Diff line change
@@ -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> = T extends infer U ? { [K in keyof U]: U[K] } : never;
export type Get<T, P> = P extends keyof T ? T[P] : never;
export type Get2<T, P, P2> = Get<Get<T, P>, P2>;
export type IsEqual<T, U> = (<G>() => G extends T ? 1 : 2) extends <G>() => G extends U ? 1 : 2 ? true : false;

type IsNotJsonableError<T> = Invalid<`${Extract<T, string>} is not Json-like`> & {};

type NotJsonable = ((...arguments_: any[]) => any) | undefined | symbol | RegExp | Function;

// tweaked version of Jsonify from type-fest
export type Jsonlike<T, DoNotCastToPrimitive extends boolean = false> = 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<J, DoNotCastToPrimitive> // 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<any, any> | Set<any>
? EmptyObject
: T extends TypedArray
? Record<string, number> // Non-JSONable type union was found not empty
: T extends []
? []
: T extends unknown[]
? JsonifyList<T>
: T extends readonly unknown[]
? JsonifyList<WritableDeep<T>>
: T extends object
? {
[K in keyof T]: [T[K]] extends [NotJsonable] | [never]
? IsNotJsonableError<K>
: Jsonlike<T[K], DoNotCastToPrimitive>;
} // JsonifyObject recursive call for its children
: IsNotJsonableError<'Passed value'>;

export interface Invalid<msg = any> {
readonly __INVALID__: unique symbol;
}
17 changes: 5 additions & 12 deletions src/typed-fastify.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand Down Expand Up @@ -225,7 +225,7 @@ interface Reply<
]
: [Get2<Op['response'], Status, 'content'>] extends [never]
? []
: [Get2<Op['response'], Status, 'content'> | Jsonify<Get2<Op['response'], Status, 'content'>>]
: [Jsonlike<Get2<Op['response'], Status, 'content'>, true>]
): AsReply;

readonly request: Request<ServiceSchema, Op, Path, RawServer, RawRequest>;
Expand Down Expand Up @@ -283,10 +283,6 @@ type OpaqueReply<
Opaque = Reply<Op, Status, Content, Headers, Path, ServiceSchema, RawServer, RawRequest, RawReply, ContextConfig>,
> = Status extends unknown ? Opaque : Content extends unknown ? Opaque : Headers extends unknown ? Opaque : never;

interface Invalid<msg = any> {
readonly __INVALID__: unique symbol;
}

interface AsReply {
readonly __REPLY_SYMBOL__: unique symbol;
then(fulfilled: () => void, rejected: (err: Error) => void): void;
Expand All @@ -296,8 +292,6 @@ export const asReply = (any: any) => {
assertAsReply(any);
return any;
};
type Get<T, P> = P extends keyof T ? T[P] : never;
type Get2<T, P, P2> = Get<Get<T, P>, P2>;

interface Router<Op extends Operation> {
Querystring: Get<Op['request'], 'querystring'>;
Expand All @@ -307,7 +301,6 @@ interface Router<Op extends Operation> {
// force reply to be never, as we expose it via custom reply interface
Reply: never;
}
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;

interface Request<
ServiceSchema extends Schema,
Expand All @@ -330,9 +323,11 @@ interface Request<
ROptions extends Omit<RequestRouteOptions<ContextConfig, SchemaCompiler>, 'method' | 'url'> & {
method: MP<Path>[0];
url: MP<Path>[1];
body: Get<Op['request'], 'body'>;
} = Omit<RequestRouteOptions<ContextConfig, SchemaCompiler>, 'method' | 'url'> & {
method: MP<Path>[0];
url: MP<Path>[1];
body: Get<Op['request'], 'body'>;
},
> extends Omit<
F.FastifyRequest<
Expand All @@ -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<Get<Op['request'], 'body'>>;
readonly body: ROptions['method'] extends 'GET' ? never : Jsonlike<ROptions['body']>;
readonly routeOptions: Id<Readonly<ROptions>>;
readonly routerMethod: ROptions['method'];
readonly headers: Get<Op['request'], 'headers'>;
readonly routerPath: ROptions['url'];
}

type IsEqual<T, U> = (<G>() => G extends T ? 1 : 2) extends <G>() => G extends U ? 1 : 2 ? true : false;

type GetInvalidParamsValidation<
Op extends Operation,
Path extends keyof ServiceSchema['paths'],
Expand Down
Loading

0 comments on commit f27e6f8

Please sign in to comment.