Created
March 27, 2022 16:50
-
-
Save rawnly/5171adc138067e165bb56ffd860c7491 to your computer and use it in GitHub Desktop.
An easy way to handle multiple request methods in NextJS Api
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { NextApiResponse } from 'next/types' | |
export default class HTTPError extends Error { | |
status: number | |
_tag: 'HTTPError' = 'HTTPError' as const | |
constructor( status: number, message: string ) { | |
super( message ) | |
this.status = status | |
} | |
public toJSON() { | |
return { | |
status: this.status, | |
message: this.message, | |
ok: false | |
} | |
} | |
public toString() { | |
return `HTTPError: (${this.status}) - "${this.message}"` | |
} | |
public static is( error: any ): error is HTTPError { | |
return error.constructor.name === 'HTTPError' | |
} | |
public toResponse = ( res: NextApiResponse ) => res | |
.status( this.status ) | |
.send( this.toJSON() ); | |
/** 422 - Unrpocessable Entity */ | |
static unprocessableEntity = ( message?: string ) => new HTTPError( 422, message ?? 'Unprocessable Entity' ) | |
/** 401 - Unauthorized */ | |
static unauthorized = ( message?: string ) => new HTTPError( 401, message ?? 'Unauthorized access to a restricted resource.' ) | |
/** 400 - Bad Request */ | |
static badRequest = ( message?: string ) => new HTTPError( 400, message ?? 'Bad Request' ) | |
/** 404 - Not Found */ | |
static notFound = ( message?: string ) => new HTTPError( 404, message ?? 'Not Found' ) | |
/** 500 - Internal Server Error */ | |
static internalServerError = ( message?: string ) => new HTTPError( 500, message ?? 'Internal Server Error' ) | |
/** 405 - Method not allowed */ | |
static methodNotAllowed = ( message?: string ) => new HTTPError( 405, message ?? 'Method Not Allowed' ) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Route, isCombinedResponse } from './types' | |
import HTTPError from './HttpError' | |
import { z, ZodError, ZodType } from 'zod' | |
interface Options { | |
auth?: { | |
enabled?: boolean | |
check?: ( req: NextApiRequest ) => boolean | Promise<boolean> | |
methods?: HTTPMethod[] | |
ignore?: HTTPMethod[] | |
}, | |
decoders?: { [method in HTTPMethod]?: { | |
query?: ZodType<any>; | |
body?: ZodType<any>; | |
response?: ZodType<any> | |
} } | |
} | |
const defaultMethodsToCheck: HTTPMethod[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] | |
const defaultIgnoredMethods: HTTPMethod[] = [] | |
// Change this with a custom fn | |
const defaultAuthCheck = async (request) => true | |
const formatZodError = ( error: z.ZodError<any> ) => { | |
const flatError = error.flatten() | |
let errorMessage: string[] = [] | |
for ( const err of flatError.formErrors ) { | |
errorMessage.push( err ) | |
} | |
for ( const [path, message] of Object.entries( flatError.fieldErrors ) ) { | |
errorMessage.push( `${path} is ${message.map( s => s.toLowerCase() ).join( ',' )}` ) | |
} | |
return errorMessage.join( '\n' ) | |
} | |
// Here I use zod to handle Response, Query and RequestBody validation. | |
export const requestHandler = ( handlers: Partial<Record<HTTPMethod, Route>>, options: Options = { auth: { enabled: true } } ): NextApiHandler => async ( req, res ) => { | |
const method = req.method!.toUpperCase() as HTTPMethod | |
const shouldCheck = ( options.auth?.check ?? true ) | |
if ( shouldCheck ) { | |
const methodsToCheck = options.auth?.methods ?? defaultMethodsToCheck | |
const methodsToIgnore = options.auth?.ignore ?? defaultIgnoredMethods | |
if ( !methodsToIgnore.includes( method ) && methodsToCheck.includes( method ) ) { | |
const isLoggedIn = await ( options.auth?.check ?? defaultAuthCheck )( req ) | |
if ( !isLoggedIn ) { | |
throw HTTPError | |
.unauthorized( 'You must be authorized to access this resource.' ) | |
} | |
} | |
} | |
try { | |
const endpoint = handlers[method] | |
if ( !endpoint ) { | |
throw HTTPError.methodNotAllowed() | |
} | |
const decoders = options.decoders?.[method] | |
if ( decoders?.body ) { | |
const result = await decoders.body.safeParseAsync( req.body ) | |
if ( !result.success ) { | |
throw result.error | |
} | |
} | |
if ( decoders?.query ) { | |
const result = await decoders.query.safeParseAsync( req.body ) | |
if ( !result.success ) { | |
throw result.error | |
} | |
} | |
const response = await endpoint( req, res ) | |
if ( !isCombinedResponse( response ) ) { | |
return res | |
.status( 200 ) | |
.send( response ) | |
} | |
const responseDecoder = response.serializer ?? options.decoders?.[method]?.response | |
if ( !responseDecoder ) { | |
return res | |
.status( response.status ?? 200 ) | |
.send( response.content ) | |
} | |
const decodedResponse = await responseDecoder.safeParseAsync( response ) | |
if ( decodedResponse.success === false ) { | |
console.error( decodedResponse.error ) | |
return HTTPError | |
.internalServerError( 'Could not serialize response' ) | |
.toResponse( res ) | |
} | |
return res | |
.status( response.status ?? 200 ) | |
.send( decodedResponse.data ) | |
} catch ( error ) { | |
if ( error instanceof ZodError ) { | |
return HTTPError | |
.badRequest( formatZodError( error ) ) | |
.toResponse( res ) | |
} | |
if ( HTTPError.is( error ) ) { | |
return error.toResponse( res ) | |
} | |
return HTTPError | |
.internalServerError( ( error as Error ).message ) | |
.toResponse( res ) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { ZodType } from 'zod' | |
import type { NextApiRequest, NextApiResponse } from 'next' | |
export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | |
type HandlerOptions = { | |
Body: NextApiRequest['body'] | |
Query: any | |
Headers: NextApiRequest['headers'] | |
} | |
export type NextTypedRequest<Q = NextApiRequest['query'], B = NextApiRequest['body'], H = NextApiRequest['headers']> = Omit<NextApiRequest, 'body' | 'query' | 'headers'> & { | |
body: B; | |
query: Q; | |
headers: H & NextApiRequest['headers']; | |
} | |
type IfFalsy<T, B> = T extends undefined | |
? B | |
: ( T extends never | |
? B | |
: T ) | |
interface FreeRouteResponse { } | |
type CombinedRouteResponse<T> = { | |
content: T | |
status: number | |
serializer: ZodType<T> | |
} | { | |
content: T | |
status: number | |
serializer?: ZodType<T> | |
} | { | |
content: T | |
status?: number | |
serializer: ZodType<T> | |
} | |
export type Route<T extends Partial<HandlerOptions> = HandlerOptions> = ( | |
req: NextTypedRequest< | |
IfFalsy<T['Query'], NextApiRequest['query']>, | |
IfFalsy<T['Body'], NextApiRequest['body']>, | |
IfFalsy<T['Headers'], NextApiRequest['headers']> | |
>, | |
res: NextApiResponse | |
) => Promise<CombinedRouteResponse<any> | FreeRouteResponse> | |
export const isCombinedResponse = <T>( val: any ): val is CombinedRouteResponse<T> => 'content' in val && ( 'status' in val || 'serializer' in val ) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment