Skip to content

Commit

Permalink
Add strong types for response content type headers (#92)
Browse files Browse the repository at this point in the history
  • Loading branch information
blomqma authored Oct 23, 2023
1 parent 4a3a5ec commit 079fc25
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 53 deletions.
107 changes: 84 additions & 23 deletions packages/next-rest-framework/src/types/route-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,25 +17,23 @@ 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<string, string | string[]>;

export interface InputObject<
Body = unknown,
Query extends BaseQuery = BaseQuery
> {
export interface InputObject<Body = unknown, Query = BaseQuery> {
contentType?: BaseContentType;
body?: ZodSchema<Body>;
query?: ZodSchema<Query>;
}

export interface OutputObject<
Body = unknown,
Status extends BaseStatus = BaseStatus
Status extends BaseStatus = BaseStatus,
ContentType extends BaseContentType = BaseContentType
> {
schema: ZodSchema<Body>;
status: Status;
contentType: BaseContentType;
contentType: ContentType;
}

type TypedNextRequest<Body, Query extends BaseQuery> = Modify<
Expand Down Expand Up @@ -63,13 +61,15 @@ type RouteHandler<
Query extends BaseQuery = BaseQuery,
ResponseBody = unknown,
Status extends BaseStatus = BaseStatus,
ContentType extends BaseContentType = BaseContentType,
Output extends ReadonlyArray<
OutputObject<ResponseBody, Status>
> = ReadonlyArray<OutputObject<ResponseBody, Status>>,
OutputObject<ResponseBody, Status, ContentType>
> = ReadonlyArray<OutputObject<ResponseBody, Status, ContentType>>,
TypedResponse =
| TypedNextResponse<
z.infer<Output[number]['schema']>,
Output[number]['status']
Output[number]['status'],
Output[number]['contentType']
>
| NextResponse<z.infer<Output[number]['schema']>>
| void
Expand All @@ -85,12 +85,20 @@ type RouteOutput<
> = <
ResponseBody,
Status extends BaseStatus,
Output extends ReadonlyArray<OutputObject<ResponseBody, Status>>
ContentType extends BaseContentType,
Output extends ReadonlyArray<OutputObject<ResponseBody, Status, ContentType>>
>(
params?: Output
) => {
handler: (
callback?: RouteHandler<Body, Query, ResponseBody, Status, Output>
callback?: RouteHandler<
Body,
Query,
ResponseBody,
Status,
ContentType,
Output
>
) => RouteOperationDefinition;
} & (Middleware extends true
? {
Expand All @@ -100,11 +108,19 @@ type RouteOutput<
BaseQuery,
ResponseBody,
Status,
ContentType,
Output
>
) => {
handler: (
callback?: RouteHandler<Body, Query, ResponseBody, Status, Output>
callback?: RouteHandler<
Body,
Query,
ResponseBody,
Status,
ContentType,
Output
>
) => RouteOperationDefinition;
};
}
Expand Down Expand Up @@ -175,11 +191,38 @@ type TypedNextApiRequest<Body, Query> = Modify<
}
>;

type TypedNextApiResponse<Body, Status> = Modify<
type TypedNextApiResponse<Body, Status, ContentType> = Modify<
NextApiResponse<Body>,
{
status: (status: Status) => NextApiResponse<Body>;
redirect: (status: Status, url: string) => NextApiResponse<Body>;
status: (status: Status) => TypedNextApiResponse<Body, Status, ContentType>;
redirect: (
status: Status,
url: string
) => TypedNextApiResponse<Body, Status, ContentType>;

setDraftMode: (options: {
enable: boolean;
}) => TypedNextApiResponse<Body, Status, ContentType>;

setPreviewData: (
data: object | string,
options?: {
maxAge?: number;
path?: string;
}
) => TypedNextApiResponse<Body, Status, ContentType>;

clearPreviewData: (options?: {
path?: string;
}) => TypedNextApiResponse<Body, Status, ContentType>;

setHeader: <
K extends AnyCase<'Content-Type'> | string,
V extends number | string | readonly string[]
>(
name: K,
value: K extends AnyCase<'Content-Type'> ? ContentType : V
) => void;
}
>;

Expand All @@ -188,14 +231,16 @@ type ApiRouteHandler<
Query extends BaseQuery = BaseQuery,
ResponseBody = unknown,
Status extends BaseStatus = BaseStatus,
ContentType extends BaseContentType = BaseContentType,
Output extends ReadonlyArray<
OutputObject<ResponseBody, Status>
> = ReadonlyArray<OutputObject<ResponseBody, Status>>
OutputObject<ResponseBody, Status, ContentType>
> = ReadonlyArray<OutputObject<ResponseBody, Status, ContentType>>
> = (
req: TypedNextApiRequest<Body, Query>,
res: TypedNextApiResponse<
z.infer<Output[number]['schema']>,
Output[number]['status']
Output[number]['status'],
Output[number]['contentType']
>
) => Promise<void> | void;

Expand All @@ -206,12 +251,20 @@ type ApiRouteOutput<
> = <
ResponseBody,
Status extends BaseStatus,
Output extends ReadonlyArray<OutputObject<ResponseBody, Status>>
ContentType extends BaseContentType,
Output extends ReadonlyArray<OutputObject<ResponseBody, Status, ContentType>>
>(
params?: Output
) => {
handler: (
callback?: ApiRouteHandler<Body, Query, ResponseBody, Status, Output>
callback?: ApiRouteHandler<
Body,
Query,
ResponseBody,
Status,
ContentType,
Output
>
) => ApiRouteOperationDefinition;
} & (Middleware extends true
? {
Expand All @@ -221,11 +274,19 @@ type ApiRouteOutput<
BaseQuery,
ResponseBody,
Status,
ContentType,
Output
>
) => {
handler: (
callback?: ApiRouteHandler<Body, Query, ResponseBody, Status, Output>
callback?: ApiRouteHandler<
Body,
Query,
ResponseBody,
Status,
ContentType,
Output
>
) => ApiRouteOperationDefinition;
};
}
Expand Down
64 changes: 45 additions & 19 deletions packages/next-rest-framework/src/types/typed-next-response.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
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<Status extends BaseStatus>
extends globalThis.ResponseInit {
type TypedHeaders<ContentType extends BaseContentType> = Modify<
Record<string, string>,
{
[K in AnyCase<'Content-Type'>]?: ContentType;
}
>;

interface TypedResponseInit<
Status extends BaseStatus,
ContentType extends BaseContentType
> extends globalThis.ResponseInit {
nextConfig?: {
basePath?: string;
i18n?: I18NConfig;
trailingSlash?: boolean;
};
url?: string;
status?: Status;
headers?: TypedHeaders<ContentType>;
}

interface ModifiedRequest {
Expand All @@ -26,38 +37,53 @@ interface TypedMiddlewareResponseInit<Status extends BaseStatus>

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<Status>);
constructor(
body?: BodyInit | null,
init?: TypedResponseInit<Status, ContentType>
);

get cookies(): ResponseCookies;

static json<JsonBody, StatusCode extends BaseStatus>(
body: JsonBody,
init?: TypedResponseInit<StatusCode>
): TypedNextResponse<JsonBody, StatusCode>;
static json<
Body,
Status extends BaseStatus,
ContentType extends BaseContentType
>(
body: Body,
init?: TypedResponseInit<Status, ContentType>
): TypedNextResponse<Body, Status, ContentType>;

static redirect<StatusCode extends BaseStatus>(
static redirect<
Status extends BaseStatus,
ContentType extends BaseContentType
>(
url: string | NextURL | URL,
init?: number | TypedResponseInit<StatusCode>
): TypedNextResponse<unknown, StatusCode>;
init?: number | TypedResponseInit<Status, ContentType>
): TypedNextResponse<unknown, Status, ContentType>;

static rewrite<StatusCode extends BaseStatus>(
static rewrite<
Status extends BaseStatus,
ContentType extends BaseContentType
>(
destination: string | NextURL | URL,
init?: TypedMiddlewareResponseInit<StatusCode>
): TypedNextResponse<unknown, StatusCode>;
init?: TypedMiddlewareResponseInit<Status>
): TypedNextResponse<unknown, Status, ContentType>;

static next<StatusCode extends BaseStatus>(
init?: TypedMiddlewareResponseInit<StatusCode>
): TypedNextResponse<unknown, StatusCode>;
static next<Status extends BaseStatus, ContentType extends BaseContentType>(
init?: TypedMiddlewareResponseInit<Status>
): TypedNextResponse<unknown, Status, ContentType>;
}
12 changes: 1 addition & 11 deletions packages/next-rest-framework/src/types/utility-types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
export type Modify<T, R> = Omit<T, keyof R> & R;

// Accept a string with any casing - used for validating headers.
// Ref: https://stackoverflow.com/a/64932909
export type AnyCase<T extends string> = string extends T
? string
: T extends `${infer F1}${infer F2}${infer R}`
? `${Uppercase<F1> | Lowercase<F1>}${
| Uppercase<F2>
| Lowercase<F2>}${AnyCase<R>}`
: T extends `${infer F}${infer R}`
? `${Uppercase<F> | Lowercase<F>}${AnyCase<R>}`
: '';
export type AnyCase<T extends string> = T | Uppercase<T> | Lowercase<T>;

2 comments on commit 079fc25

@vercel
Copy link

@vercel vercel bot commented on 079fc25 Oct 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

next-rest-framework – ./docs

next-rest-framework-blomqma.vercel.app
next-rest-framework-git-main-blomqma.vercel.app
next-rest-framework.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 079fc25 Oct 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

next-rest-framework-demo – ./apps/example

next-rest-framework-demo-blomqma.vercel.app
next-rest-framework-demo-git-main-blomqma.vercel.app
next-rest-framework-demo.vercel.app

Please sign in to comment.