diff --git a/package-lock.json b/package-lock.json index 9ca4ad7..daa23dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "jest": "^29.0.0", "jest-openapi": "^0.14.2", "pinst": "^2.1.6", - "prettier": "^2.4.0", + "prettier": "^3.1.0", "rollup": "^3.25.1", "rollup-plugin-bundle-size": "^1.0.3", "rollup-plugin-copy": "^3.4.0", @@ -6234,15 +6234,18 @@ } }, "node_modules/prettier": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz", - "integrity": "sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/pretty-bytes": { @@ -12329,9 +12332,9 @@ "dev": true }, "prettier": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz", - "integrity": "sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true }, "pretty-bytes": { diff --git a/package.json b/package.json index 889699d..eae5da7 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "package": "npm run build && npm pack", "test:cov": "jest --coverage --no-cache --runInBand", "addscope": "node config/packagejson name @cloudflare/itty-router-openapi", - "prettify": "prettier --check src tests README.md || (prettier -w src tests README.md; exit 1)", + "prettify": "prettier --check src tests README.md || (prettier -w src tests README.md)", "lint": "npm run prettify", "prepare": "husky install", "test": "jest --no-cache --runInBand --config jestconfig.json --verbose", @@ -84,7 +84,7 @@ "jest": "^29.0.0", "jest-openapi": "^0.14.2", "pinst": "^2.1.6", - "prettier": "^2.4.0", + "prettier": "^3.1.0", "rollup": "^3.25.1", "rollup-plugin-bundle-size": "^1.0.3", "rollup-plugin-copy": "^3.4.0", diff --git a/src/openapi.ts b/src/openapi.ts index 02235e9..d3e8fe0 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -21,7 +21,7 @@ import yaml from 'js-yaml' export type Route = < RequestType = IRequest, Args extends any[] = any[], - RT = OpenAPIRouterType + RT = OpenAPIRouterType, >( path: string, ...handlers: (RouteHandler | OpenAPIRouterType | any)[] // TODO: fix this any to be instance of OpenAPIRoute @@ -34,18 +34,17 @@ export type OpenAPIRouterType = { } & RouterType // helper function to detect equality in types (used to detect custom Request on router) -type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y - ? 1 - : 2 - ? true - : false +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false export function OpenAPIRouter< RequestType = IRequest, Args extends any[] = any[], RouteType = Equal extends true ? Route - : UniversalRoute + : UniversalRoute, >(options?: RouterOptions): OpenAPIRouterType { const registry: OpenAPIRegistryMerger = new OpenAPIRegistryMerger() @@ -86,7 +85,7 @@ export function OpenAPIRouter< return ( route: string, ...handlers: RouteHandler[] & - typeof OpenAPIRoute[] & + (typeof OpenAPIRoute)[] & OpenAPIRouterType[] ) => { if (prop !== 'handle') { diff --git a/src/parameters.ts b/src/parameters.ts index c835154..934179f 100644 --- a/src/parameters.ts +++ b/src/parameters.ts @@ -3,20 +3,22 @@ import { ParameterLocation, ParameterType, RegexParameterType, - RouteParameter, + HeaderParameter, + QueryParameter, + PathParameter, StringParameterType, + LegacyParameter, } from './types' -import { z, ZodObject } from 'zod' +import { z } from 'zod' import { isSpecificZodType, legacyTypeIntoZod } from './zod/utils' import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi' -import { ZodType } from 'zod' if (z.string().openapi === undefined) { // console.log('zod extension applied') extendZodWithOpenApi(z) } -export function convertParams(field: any, params: any): ZodType { +export function convertParams(field: any, params: any): z.ZodType { params = params || {} if (params.required === false) // @ts-ignore @@ -60,7 +62,7 @@ export class Obj { } } -export class Num { +export const Num: LegacyParameter = class Num { static generator = true constructor(params?: ParameterType) { @@ -71,9 +73,9 @@ export class Num { type: 'number', }) } -} +} as unknown as LegacyParameter -export class Int { +export const Int: LegacyParameter = class Int { static generator = true constructor(params?: ParameterType) { @@ -84,17 +86,17 @@ export class Int { type: 'integer', }) } -} +} as unknown as LegacyParameter -export class Str { +export const Str: LegacyParameter = class Str { static generator = true constructor(params?: StringParameterType) { return convertParams(z.string(), params) } -} +} as unknown as LegacyParameter -export class DateTime { +export const DateTime: LegacyParameter = class DateTime { static generator = true constructor(params?: ParameterType) { @@ -105,9 +107,9 @@ export class DateTime { params ) } -} +} as unknown as LegacyParameter -export class Regex { +export const Regex: LegacyParameter = class Regex { static generator = true constructor(params: RegexParameterType) { @@ -117,25 +119,25 @@ export class Regex { params ) } -} +} as unknown as LegacyParameter -export class Email { +export const Email: LegacyParameter = class Email { static generator = true constructor(params?: ParameterType) { return convertParams(z.string().email(), params) } -} +} as unknown as LegacyParameter -export class Uuid { +export const Uuid: LegacyParameter = class Uuid { static generator = true constructor(params?: ParameterType) { return convertParams(z.string().uuid(), params) } -} +} as unknown as LegacyParameter -export class Hostname { +export const Hostname: LegacyParameter = class Hostname { static generator = true constructor(params?: ParameterType) { @@ -148,33 +150,33 @@ export class Hostname { params ) } -} +} as unknown as LegacyParameter -export class Ipv4 { +export const Ipv4: LegacyParameter = class Ipv4 { static generator = true constructor(params?: ParameterType) { return convertParams(z.coerce.string().ip({ version: 'v4' }), params) } -} +} as unknown as LegacyParameter -export class Ipv6 { +export const Ipv6: LegacyParameter = class Ipv6 { static generator = true constructor(params?: ParameterType) { return convertParams(z.string().ip({ version: 'v6' }), params) } -} +} as unknown as LegacyParameter -export class DateOnly { +export const DateOnly: LegacyParameter = class DateOnly { static generator = true constructor(params?: ParameterType) { return convertParams(z.coerce.date(), params) } -} +} as unknown as LegacyParameter -export class Bool { +export const Bool: LegacyParameter = class Bool { static generator = true constructor(params?: ParameterType) { @@ -188,7 +190,7 @@ export class Bool { type: 'boolean', }) } -} +} as unknown as LegacyParameter export class Enumeration { static generator = true @@ -202,7 +204,7 @@ export class Enumeration { const originalKeys: [string, ...string[]] = Object.keys(values) as [ string, - ...string[] + ...string[], ] if (params.enumCaseSensitive === false) { @@ -215,7 +217,7 @@ export class Enumeration { const keys: [string, ...string[]] = Object.keys(values) as [ string, - ...string[] + ...string[], ] let field @@ -239,10 +241,32 @@ export class Enumeration { } } +export function Query(type: Z): QueryParameter +export function Query( + type: Z, + params: ParameterLocation & { required: false } +): QueryParameter> +export function Query( + type: Z, + params: ParameterLocation +): QueryParameter +export function Query( + type: [Z] +): QueryParameter> +export function Query( + type: [Z], + params: ParameterLocation & { required: false } +): QueryParameter>> +export function Query( + type: [Z], + params: ParameterLocation +): QueryParameter> +export function Query(type: any): QueryParameter +export function Query(type: any, params: ParameterLocation): QueryParameter export function Query( type: any, params: ParameterLocation = {} -): RouteParameter { +): QueryParameter { return { name: params.name, location: 'query', @@ -250,10 +274,29 @@ export function Query( } } -export function Path( - type: any, - params: ParameterLocation = {} -): RouteParameter { +export function Path(type: Z): PathParameter +export function Path( + type: Z, + params: ParameterLocation & { required: false } +): PathParameter> +export function Path( + type: Z, + params: ParameterLocation +): PathParameter +export function Path( + type: [Z] +): PathParameter> +export function Path( + type: [Z], + params: ParameterLocation & { required: false } +): PathParameter>> +export function Path( + type: [Z], + params: ParameterLocation +): PathParameter> +export function Path(type: any): PathParameter +export function Path(type: any, params: ParameterLocation): PathParameter +export function Path(type: any, params: ParameterLocation = {}): PathParameter { return { name: params.name, location: 'params', @@ -261,10 +304,32 @@ export function Path( } } +export function Header(type: Z): HeaderParameter +export function Header( + type: Z, + params: ParameterLocation & { required: false } +): HeaderParameter> +export function Header( + type: Z, + params: ParameterLocation +): HeaderParameter +export function Header( + type: [Z] +): HeaderParameter> +export function Header( + type: [Z], + params: ParameterLocation & { required: false } +): HeaderParameter>> +export function Header( + type: [Z], + params: ParameterLocation +): HeaderParameter> +export function Header(type: any): HeaderParameter +export function Header(type: any, params: ParameterLocation): HeaderParameter export function Header( type: any, params: ParameterLocation = {} -): RouteParameter { +): HeaderParameter { return { name: params.name, location: 'headers', @@ -296,7 +361,7 @@ export function extractParameter( export function extractQueryParameters( request: Request, - schema?: ZodObject + schema?: z.ZodObject ): Record | null { const { searchParams } = new URL(request.url) diff --git a/src/types.ts b/src/types.ts index be99f4c..316e766 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import { RouteEntry } from 'itty-router' -import { AnyZodObject, ZodType } from 'zod' +import { z, AnyZodObject, ZodType } from 'zod' import { ResponseConfig, ZodMediaTypeObject, @@ -27,10 +27,10 @@ export interface RouterOptions { baseRouter?: any } -export declare type RouteParameter = { +export declare type RouteParameter = { name?: string location: string - type: ZodType + type: Z } export declare type MediaTypeObject = Omit & { @@ -41,6 +41,13 @@ export declare type ContentObject = { [mediaType: string]: MediaTypeObject } +export declare type QueryParameter = + RouteParameter & { location: 'query' } +export declare type PathParameter = + RouteParameter & { location: 'params' } +export declare type HeaderParameter = + RouteParameter & { location: 'headers' } + export declare type RouteResponse = Omit< ResponseConfig, 'headers' | 'content' @@ -159,3 +166,63 @@ export interface Auth { export interface VerificationTokens { openai: string } + +export type LegacyParameter = Z & + (new (params?: ParameterType) => Z) + +export type TypeOfQueryParameter = + T extends QueryParameter ? z.infer : never + +export type TypeOfPathParameter = + T extends PathParameter ? z.infer : never + +export type TypeOfHeaderParameter = + T extends HeaderParameter ? z.infer : never + +export type TypedOpenAPIRouteSchema< + P extends Record, + B extends z.ZodType = z.ZodUndefined, +> = Omit< + RouteConfig, + 'method' | 'path' | 'requestBody' | 'parameters' | 'responses' +> & { + parameters?: P + requestBody?: B + responses?: { + [statusCode: string]: RouteResponse + } +} + +export type DataOf = (S extends TypedOpenAPIRouteSchema< + infer P extends Record, + infer B +> + ? { + headers: { + [K in keyof P as P[K] extends HeaderParameter + ? K + : never]: TypeOfHeaderParameter + } + params: { + [K in keyof P as P[K] extends PathParameter + ? K + : never]: TypeOfPathParameter + } + query: { + [K in keyof P as P[K] extends QueryParameter + ? K + : never]: TypeOfQueryParameter + } + } + : { + headers: Record + params: Record + query: Record + }) & + (S extends TypedOpenAPIRouteSchema + ? {} + : S extends TypedOpenAPIRouteSchema + ? { body: z.infer } + : {}) + +export type inferData = DataOf diff --git a/src/zod/utils.ts b/src/zod/utils.ts index 9c63a76..5f759a2 100644 --- a/src/zod/utils.ts +++ b/src/zod/utils.ts @@ -1,4 +1,4 @@ -import type { z, ZodObject } from 'zod' +import type { z } from 'zod' import { Arr, Bool, @@ -8,8 +8,6 @@ import { Obj, Str, } from '../parameters' -import { ZodType } from 'zod' -import { ZodArray, ZodBoolean, ZodNumber, ZodString } from 'zod' export function isAnyZodType(schema: object): schema is z.ZodType { // @ts-ignore @@ -24,11 +22,11 @@ export function isSpecificZodType(field: any, typeName: string): boolean { ) } -export function legacyTypeIntoZod(type: any, params?: any): ZodType { +export function legacyTypeIntoZod(type: any, params?: any): z.ZodType { params = params || {} if (type === null) { - return new Str({ required: false, ...params }) as ZodType + return new Str({ required: false, ...params }) } if (isAnyZodType(type)) { @@ -41,35 +39,35 @@ export function legacyTypeIntoZod(type: any, params?: any): ZodType { // Legacy support if (type.generator === true) { - return new type(params) as ZodType + return new type(params) as z.ZodType } if (type === String) { - return new Str(params) as ZodType + return new Str(params) } if (typeof type === 'string') { - return new Str({ example: type }) as ZodType + return new Str({ example: type }) } if (type === Number) { - return new Num(params) as ZodType + return new Num(params) } if (typeof type === 'number') { - return new Num({ example: type }) as ZodType + return new Num({ example: type }) } if (type === Boolean) { - return new Bool(params) as ZodType + return new Bool(params) } if (typeof type === 'boolean') { - return new Bool({ example: type }) as ZodType + return new Bool({ example: type }) } if (type === Date) { - return new DateTime(params) as ZodType + return new DateTime(params) } if (Array.isArray(type)) { @@ -77,11 +75,11 @@ export function legacyTypeIntoZod(type: any, params?: any): ZodType { throw new Error('Arr must have a type') } - return new Arr(type[0], params) as ZodType> + return new Arr(type[0], params) as z.ZodArray } if (typeof type === 'object') { - return new Obj(type, params) as ZodType> + return new Obj(type, params) as z.ZodObject } throw new Error(`${type} not implemented`) diff --git a/tests/router.ts b/tests/router.ts index 7cfedb9..81db03c 100644 --- a/tests/router.ts +++ b/tests/router.ts @@ -16,8 +16,10 @@ import { Str, Uuid, Path, + Header, } from '../src/parameters' -import { OpenAPIRouteSchema } from '../src' +import { OpenAPIRouteSchema, DataOf } from '../src' +import { z } from 'zod' export class ToDoList extends OpenAPIRoute { static schema = { @@ -164,10 +166,88 @@ export class ToDoCreate extends OpenAPIRoute { } } +export class ToDoCreateTyped extends OpenAPIRoute { + static schema = { + tags: ['ToDo'], + summary: 'List all ToDos', + parameters: { + p_int: Query(Int), + p_num: Query(Num), + p_num2: Path(new Num()), + p_str: Query(Str), + p_arrstr: Query([Str]), + p_bool: Query(Bool), + p_enumeration: Query(Enumeration, { + values: { + json: 'ENUM_JSON', + csv: 'ENUM_CSV', + }, + }), + p_enumeration_insensitive: Query(Enumeration, { + values: { + json: 'json', + csv: 'csv', + }, + enumCaseSensitive: false, + }), + p_datetime: Query(DateTime), + p_dateonly: Path(DateOnly), + p_regex: Query(Regex, { + pattern: + /^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$/, + }), + p_email: Query(Email), + p_uuid: Query(Uuid), + p_hostname: Header(Hostname), + p_ipv4: Query(Ipv4), + p_ipv6: Query(Ipv6), + p_optional: Query(Int, { + required: false, + }), + }, + requestBody: z.object({ + title: z.string(), + description: z.string().optional(), + type: z.enum(['nextWeek', 'nextMoth']), + }), + responses: { + '200': { + description: 'example', + schema: { + params: {}, + results: ['lorem'], + }, + }, + }, + } + + async handle( + request: Request, + env: any, + context: any, + data: DataOf + ) { + data.query.p_num + data.query.p_arrstr + data.query.p_datetime + data.params.p_num2 + data.params.p_dateonly + data.headers.p_hostname + data.body.title + data.body.type + data.body.description + return { + params: data, + results: ['lorem', 'ipsum'], + } + } +} + export const todoRouter = OpenAPIRouter({ openapiVersion: '3' }) todoRouter.get('/todos', ToDoList) todoRouter.get('/todos/:id', ToDoGet) todoRouter.post('/todos', ToDoCreate) +todoRouter.post('/todos-typed', ToDoCreateTyped) todoRouter.get('/contenttype', ContentTypeGet) // 404 for everything else