From 079fc258438f7ef451931d6858fc0ddb926b2a50 Mon Sep 17 00:00:00 2001 From: Markus Blomqvist Date: Tue, 24 Oct 2023 02:47:50 +0300 Subject: [PATCH] Add strong types for response content type headers (#92) --- .../src/types/route-handlers.ts | 107 ++++++++++++++---- .../src/types/typed-next-response.ts | 64 +++++++---- .../src/types/utility-types.ts | 12 +- 3 files changed, 130 insertions(+), 53 deletions(-) diff --git a/packages/next-rest-framework/src/types/route-handlers.ts b/packages/next-rest-framework/src/types/route-handlers.ts index 530992f..9f2b5ae 100644 --- a/packages/next-rest-framework/src/types/route-handlers.ts +++ b/packages/next-rest-framework/src/types/route-handlers.ts @@ -8,7 +8,7 @@ import { } from 'next/types'; import { type ValidMethod } from '../constants'; -import { type Modify } from './utility-types'; +import { type AnyCase, type Modify } from './utility-types'; import { type NextURL } from 'next/dist/server/web/next-url'; import { type OpenAPIV3_1 } from 'openapi-types'; @@ -17,13 +17,10 @@ import { type ZodSchema, type z } from 'zod'; import { type TypedNextResponse } from './typed-next-response'; export type BaseStatus = number; -type BaseContentType = AnyContentTypeWithAutocompleteForMostCommonOnes; +export type BaseContentType = AnyContentTypeWithAutocompleteForMostCommonOnes; export type BaseQuery = Record; -export interface InputObject< - Body = unknown, - Query extends BaseQuery = BaseQuery -> { +export interface InputObject { contentType?: BaseContentType; body?: ZodSchema; query?: ZodSchema; @@ -31,11 +28,12 @@ export interface InputObject< export interface OutputObject< Body = unknown, - Status extends BaseStatus = BaseStatus + Status extends BaseStatus = BaseStatus, + ContentType extends BaseContentType = BaseContentType > { schema: ZodSchema; status: Status; - contentType: BaseContentType; + contentType: ContentType; } type TypedNextRequest = Modify< @@ -63,13 +61,15 @@ type RouteHandler< Query extends BaseQuery = BaseQuery, ResponseBody = unknown, Status extends BaseStatus = BaseStatus, + ContentType extends BaseContentType = BaseContentType, Output extends ReadonlyArray< - OutputObject - > = ReadonlyArray>, + OutputObject + > = ReadonlyArray>, TypedResponse = | TypedNextResponse< z.infer, - Output[number]['status'] + Output[number]['status'], + Output[number]['contentType'] > | NextResponse> | void @@ -85,12 +85,20 @@ type RouteOutput< > = < ResponseBody, Status extends BaseStatus, - Output extends ReadonlyArray> + ContentType extends BaseContentType, + Output extends ReadonlyArray> >( params?: Output ) => { handler: ( - callback?: RouteHandler + callback?: RouteHandler< + Body, + Query, + ResponseBody, + Status, + ContentType, + Output + > ) => RouteOperationDefinition; } & (Middleware extends true ? { @@ -100,11 +108,19 @@ type RouteOutput< BaseQuery, ResponseBody, Status, + ContentType, Output > ) => { handler: ( - callback?: RouteHandler + callback?: RouteHandler< + Body, + Query, + ResponseBody, + Status, + ContentType, + Output + > ) => RouteOperationDefinition; }; } @@ -175,11 +191,38 @@ type TypedNextApiRequest = Modify< } >; -type TypedNextApiResponse = Modify< +type TypedNextApiResponse = Modify< NextApiResponse, { - status: (status: Status) => NextApiResponse; - redirect: (status: Status, url: string) => NextApiResponse; + status: (status: Status) => TypedNextApiResponse; + redirect: ( + status: Status, + url: string + ) => TypedNextApiResponse; + + setDraftMode: (options: { + enable: boolean; + }) => TypedNextApiResponse; + + setPreviewData: ( + data: object | string, + options?: { + maxAge?: number; + path?: string; + } + ) => TypedNextApiResponse; + + clearPreviewData: (options?: { + path?: string; + }) => TypedNextApiResponse; + + setHeader: < + K extends AnyCase<'Content-Type'> | string, + V extends number | string | readonly string[] + >( + name: K, + value: K extends AnyCase<'Content-Type'> ? ContentType : V + ) => void; } >; @@ -188,14 +231,16 @@ type ApiRouteHandler< Query extends BaseQuery = BaseQuery, ResponseBody = unknown, Status extends BaseStatus = BaseStatus, + ContentType extends BaseContentType = BaseContentType, Output extends ReadonlyArray< - OutputObject - > = ReadonlyArray> + OutputObject + > = ReadonlyArray> > = ( req: TypedNextApiRequest, res: TypedNextApiResponse< z.infer, - Output[number]['status'] + Output[number]['status'], + Output[number]['contentType'] > ) => Promise | void; @@ -206,12 +251,20 @@ type ApiRouteOutput< > = < ResponseBody, Status extends BaseStatus, - Output extends ReadonlyArray> + ContentType extends BaseContentType, + Output extends ReadonlyArray> >( params?: Output ) => { handler: ( - callback?: ApiRouteHandler + callback?: ApiRouteHandler< + Body, + Query, + ResponseBody, + Status, + ContentType, + Output + > ) => ApiRouteOperationDefinition; } & (Middleware extends true ? { @@ -221,11 +274,19 @@ type ApiRouteOutput< BaseQuery, ResponseBody, Status, + ContentType, Output > ) => { handler: ( - callback?: ApiRouteHandler + callback?: ApiRouteHandler< + Body, + Query, + ResponseBody, + Status, + ContentType, + Output + > ) => ApiRouteOperationDefinition; }; } diff --git a/packages/next-rest-framework/src/types/typed-next-response.ts b/packages/next-rest-framework/src/types/typed-next-response.ts index 338f9f3..f801101 100644 --- a/packages/next-rest-framework/src/types/typed-next-response.ts +++ b/packages/next-rest-framework/src/types/typed-next-response.ts @@ -1,10 +1,20 @@ import { type I18NConfig } from 'next/dist/server/config-shared'; import { type ResponseCookies } from 'next/dist/server/web/spec-extension/cookies'; -import { type BaseStatus } from './route-handlers'; +import { type BaseContentType, type BaseStatus } from './route-handlers'; import { type NextURL } from 'next/dist/server/web/next-url'; +import { type Modify, type AnyCase } from './utility-types'; -interface TypedResponseInit - extends globalThis.ResponseInit { +type TypedHeaders = Modify< + Record, + { + [K in AnyCase<'Content-Type'>]?: ContentType; + } +>; + +interface TypedResponseInit< + Status extends BaseStatus, + ContentType extends BaseContentType +> extends globalThis.ResponseInit { nextConfig?: { basePath?: string; i18n?: I18NConfig; @@ -12,6 +22,7 @@ interface TypedResponseInit }; url?: string; status?: Status; + headers?: TypedHeaders; } interface ModifiedRequest { @@ -26,38 +37,53 @@ interface TypedMiddlewareResponseInit declare const INTERNALS: unique symbol; -// A patched `NextResponse` that allows to strongly-typed status codes. +// A patched `NextResponse` that allows to strongly-typed status code and content-type. export declare class TypedNextResponse< Body, - Status extends BaseStatus + Status extends BaseStatus, + ContentType extends BaseContentType > extends Response { [INTERNALS]: { cookies: ResponseCookies; url?: NextURL; body?: Body; status?: Status; + contentType?: ContentType; }; - constructor(body?: BodyInit | null, init?: TypedResponseInit); + constructor( + body?: BodyInit | null, + init?: TypedResponseInit + ); get cookies(): ResponseCookies; - static json( - body: JsonBody, - init?: TypedResponseInit - ): TypedNextResponse; + static json< + Body, + Status extends BaseStatus, + ContentType extends BaseContentType + >( + body: Body, + init?: TypedResponseInit + ): TypedNextResponse; - static redirect( + static redirect< + Status extends BaseStatus, + ContentType extends BaseContentType + >( url: string | NextURL | URL, - init?: number | TypedResponseInit - ): TypedNextResponse; + init?: number | TypedResponseInit + ): TypedNextResponse; - static rewrite( + static rewrite< + Status extends BaseStatus, + ContentType extends BaseContentType + >( destination: string | NextURL | URL, - init?: TypedMiddlewareResponseInit - ): TypedNextResponse; + init?: TypedMiddlewareResponseInit + ): TypedNextResponse; - static next( - init?: TypedMiddlewareResponseInit - ): TypedNextResponse; + static next( + init?: TypedMiddlewareResponseInit + ): TypedNextResponse; } diff --git a/packages/next-rest-framework/src/types/utility-types.ts b/packages/next-rest-framework/src/types/utility-types.ts index 2bcff8b..e361b1e 100644 --- a/packages/next-rest-framework/src/types/utility-types.ts +++ b/packages/next-rest-framework/src/types/utility-types.ts @@ -1,13 +1,3 @@ export type Modify = Omit & R; -// Accept a string with any casing - used for validating headers. -// Ref: https://stackoverflow.com/a/64932909 -export type AnyCase = string extends T - ? string - : T extends `${infer F1}${infer F2}${infer R}` - ? `${Uppercase | Lowercase}${ - | Uppercase - | Lowercase}${AnyCase}` - : T extends `${infer F}${infer R}` - ? `${Uppercase | Lowercase}${AnyCase}` - : ''; +export type AnyCase = T | Uppercase | Lowercase;