Skip to content

Instantly share code, notes, and snippets.

@rawnly
Created March 27, 2022 16:50
Show Gist options
  • Save rawnly/5171adc138067e165bb56ffd860c7491 to your computer and use it in GitHub Desktop.
Save rawnly/5171adc138067e165bb56ffd860c7491 to your computer and use it in GitHub Desktop.
An easy way to handle multiple request methods in NextJS Api
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' )
}
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 )
}
}
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