Skip to content

Commit

Permalink
Merge pull request #92 from Coobaha/jsonify
Browse files Browse the repository at this point in the history
Better support for json-like input and outputs in the schema
  • Loading branch information
Coobaha authored Oct 31, 2023
2 parents 4eb1c50 + df4c9f6 commit 4d33197
Show file tree
Hide file tree
Showing 12 changed files with 590 additions and 26 deletions.
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

0 comments on commit 4d33197

Please sign in to comment.