Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better support for json-like input and outputs in the schema #92

Merged
merged 12 commits into from
Oct 31, 2023
4 changes: 4 additions & 0 deletions generator/gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ 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,
Expand All @@ -134,6 +136,8 @@ export default async (params: { files: string[] }) => {
ignoreErrors: true,
strictNullChecks: true,
id: PLACEHOLDER_ID,
// add support for ajv-keywords
validationKeywords: [...defaultAgs.validationKeywords, 'instanceof', 'typeof'],
};

let { files } = params;
Expand Down
6 changes: 4 additions & 2 deletions 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 Expand Up @@ -79,7 +81,7 @@
"pretest": "tsnd generator/gen.bin.ts gen 'test/test_schema.ts'",
"server": "pnpm preserver && tsnd test/server.ts",
"test": "pnpm pretest && pnpm test:types && pnpm test:integration",
"test:integration": "TAP_TS=1 tap test/*.test.ts -R terse",
"test:integration": "TAP_TS=1 tap test/*.test.ts -R dot",
"test:types": "tsc -p test/tsconfig.test.json"
}
}
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>;
}
75 changes: 75 additions & 0 deletions src/type-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type {
PositiveInfinity,
NegativeInfinity,
JsonPrimitive,
JsonValue,
EmptyObject,
TypedArray,
WritableDeep,
IsNever,
IsUnknown,
} from 'type-fest';
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;

type NeverToNull<T> = IsNever<T> extends true ? null : T;

type JsonCastBehavior = 'cast' | 'combine';

// Handles tuples and arrays
type JsonlikeList<T extends unknown[], DoNotCastToPrimitive extends JsonCastBehavior> = T extends []
? []
: T extends [infer F, ...infer R]
? [NeverToNull<Jsonlike<F, DoNotCastToPrimitive>>, ...JsonlikeList<R, DoNotCastToPrimitive>]
: IsUnknown<T[number]> extends true
? []
: Array<T[number] extends NotJsonable ? null : Jsonlike<T[number], DoNotCastToPrimitive>>;

// tweaked version of Jsonify from type-fest
export type Jsonlike<T, CastBehavior extends JsonCastBehavior> = 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?
? CastBehavior extends 'combine'
? T | J
: J // Then T is Jsonable and its Jsonable value is J
: Jsonlike<J, CastBehavior> // 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[]
? JsonlikeList<T, CastBehavior>
: T extends readonly unknown[]
? JsonlikeList<WritableDeep<T>, CastBehavior>
: T extends object
? {
[K in keyof T]: [T[K]] extends [NotJsonable] | [never] ? IsNotJsonableError<K> : Jsonlike<T[K], CastBehavior>;
} // JsonifyObject recursive call for its children
: IsNotJsonableError<'Passed value'>;

export interface Invalid<msg = any> {
readonly __INVALID__: unique symbol;
}
16 changes: 5 additions & 11 deletions src/typed-fastify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,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 @@ -224,7 +225,7 @@ interface Reply<
]
: [Get2<Op['response'], Status, 'content'>] extends [never]
? []
: [Get2<Op['response'], Status, 'content'>]
: [Jsonlike<Get2<Op['response'], Status, 'content'>, 'combine'>]
): AsReply;

readonly request: Request<ServiceSchema, Op, Path, RawServer, RawRequest>;
Expand Down Expand Up @@ -282,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 @@ -295,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 @@ -306,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 @@ -329,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 @@ -349,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 : Get<Op['request'], 'body'>;
readonly body: ROptions['method'] extends 'GET' ? never : Jsonlike<ROptions['body'], 'cast'>;
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