Skip to content

Commit

Permalink
fix: handle HttpError correctly in resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
eykrehbein committed Mar 12, 2024
1 parent 67a8a5a commit 6e7464a
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 11 deletions.
21 changes: 14 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
ZodRawShape,
ZodTypeAny,
} from 'zod'
import { HTTPError, HTTPStatus, HTTPStatusText } from './utils/error.js'
import { ErrorReturnType, HTTPError, HTTPStatus, HTTPStatusText } from './utils/error.js'
export { HTTPError, HTTPStatus, HTTPStatusText } from './utils/error.js'

export type CustomRequest<
Expand Down Expand Up @@ -43,7 +43,7 @@ type Middleware<

class ValidationError extends Error {
public statusCode: number
public errors: { message: string; path: string[] }[]
public errors: ErrorReturnType['errors']
public location: string

constructor(zodError: ZodError, location: string) {
Expand Down Expand Up @@ -315,8 +315,15 @@ export class Handler<
return
}
} catch (err) {
next(err)
return
if (err instanceof HTTPError) {
return res.status(err.status).json({
errors: [{ message: err.message }],
type: HTTPStatusText[err.status as HTTPStatus],
} satisfies ErrorReturnType)
} else {
next(err)
return
}
}
}

Expand Down Expand Up @@ -354,12 +361,12 @@ export class Handler<
errors: err.errors,
location: err.location,
type: HTTPStatusText[err.statusCode as HTTPStatus],
})
} satisfies ErrorReturnType)
} else if (err instanceof HTTPError) {
return res.status(err.status).json({
errors: [err.message],
errors: [{ message: err.message }],
type: HTTPStatusText[err.status as HTTPStatus],
})
} satisfies ErrorReturnType)
} else {
// call the native next express error handler
next(err)
Expand Down
126 changes: 122 additions & 4 deletions src/utils/error.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,144 @@
export enum HTTPStatus {
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203,
NO_CONTENT = 204,
RESET_CONTENT = 205,
PARTIAL_CONTENT = 206,
MULTI_STATUS = 207,
ALREADY_REPORTED = 208,
IM_USED = 226,

MULTIPLE_CHOICES = 300,
MOVED_PERMANENTLY = 301,
FOUND = 302,
SEE_OTHER = 303,
NOT_MODIFIED = 304,
USE_PROXY = 305,
SWITCH_PROXY = 306,
TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308,

BAD_REQUEST = 400,
UNAUTHORIZED = 401,
PAYMENT_REQUIRED = 402,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
NOT_ACCEPTABLE = 406,
PROXY_AUTHENTICATION_REQUIRED = 407,
REQUEST_TIMEOUT = 408,
CONFLICT = 409,
GONE = 410,
LENGTH_REQUIRED = 411,
PRECONDITION_FAILED = 412,
PAYLOAD_TOO_LARGE = 413,
URI_TOO_LONG = 414,
UNSUPPORTED_MEDIA_TYPE = 415,
RANGE_NOT_SATISFIABLE = 416,
EXPECTATION_FAILED = 417,
I_AM_A_TEAPOT = 418,
MISDIRECTED_REQUEST = 421,
UNPROCESSABLE_ENTITY = 422,
LOCKED = 423,
FAILED_DEPENDENCY = 424,
TOO_EARLY = 425,
UPGRADE_REQUIRED = 426,
PRECONDITION_REQUIRED = 428,
TOO_MANY_REQUESTS = 429,
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
UNAVAILABLE_FOR_LEGAL_REASONS = 451,

INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
HTTP_VERSION_NOT_SUPPORTED = 505,
VARIANT_ALSO_NEGOTIATES = 506,
INSUFFICIENT_STORAGE = 507,
LOOP_DETECTED = 508,
NOT_EXTENDED = 510,
NETWORK_AUTHENTICATION_REQUIRED = 511,
}

export const HTTPStatusText: Record<HTTPStatus, string> = {
[HTTPStatus.OK]: 'OK',
[HTTPStatus.CREATED]: 'Created',
[HTTPStatus.ACCEPTED]: 'Accepted',
[HTTPStatus.NON_AUTHORITATIVE_INFORMATION]: 'Non-Authoritative Information',
[HTTPStatus.NO_CONTENT]: 'No Content',
[HTTPStatus.RESET_CONTENT]: 'Reset Content',
[HTTPStatus.PARTIAL_CONTENT]: 'Partial Content',
[HTTPStatus.MULTI_STATUS]: 'Multi-Status',
[HTTPStatus.ALREADY_REPORTED]: 'Already Reported',
[HTTPStatus.IM_USED]: 'IM Used',

[HTTPStatus.MULTIPLE_CHOICES]: 'Multiple Choices',
[HTTPStatus.MOVED_PERMANENTLY]: 'Moved Permanently',
[HTTPStatus.FOUND]: 'Found',
[HTTPStatus.SEE_OTHER]: 'See Other',
[HTTPStatus.NOT_MODIFIED]: 'Not Modified',
[HTTPStatus.USE_PROXY]: 'Use Proxy',
[HTTPStatus.SWITCH_PROXY]: 'Switch Proxy',
[HTTPStatus.TEMPORARY_REDIRECT]: 'Temporary Redirect',
[HTTPStatus.PERMANENT_REDIRECT]: 'Permanent Redirect',

[HTTPStatus.BAD_REQUEST]: 'Bad Request',
[HTTPStatus.UNAUTHORIZED]: 'Unauthorized',
[HTTPStatus.PAYMENT_REQUIRED]: 'Payment Required',
[HTTPStatus.FORBIDDEN]: 'Forbidden',
[HTTPStatus.NOT_FOUND]: 'Not Found',
[HTTPStatus.METHOD_NOT_ALLOWED]: 'Method Not Allowed',
[HTTPStatus.NOT_ACCEPTABLE]: 'Not Acceptable',
[HTTPStatus.PROXY_AUTHENTICATION_REQUIRED]: 'Proxy Authentication Required',
[HTTPStatus.REQUEST_TIMEOUT]: 'Request Timeout',
[HTTPStatus.CONFLICT]: 'Conflict',
[HTTPStatus.GONE]: 'Gone',
[HTTPStatus.LENGTH_REQUIRED]: 'Length Required',
[HTTPStatus.PRECONDITION_FAILED]: 'Precondition Failed',
[HTTPStatus.PAYLOAD_TOO_LARGE]: 'Payload Too Large',
[HTTPStatus.URI_TOO_LONG]: 'URI Too Long',
[HTTPStatus.UNSUPPORTED_MEDIA_TYPE]: 'Unsupported Media Type',
[HTTPStatus.RANGE_NOT_SATISFIABLE]: 'Range Not Satisfiable',
[HTTPStatus.EXPECTATION_FAILED]: 'Expectation Failed',
[HTTPStatus.I_AM_A_TEAPOT]: "I'm a teapot",
[HTTPStatus.MISDIRECTED_REQUEST]: 'Misdirected Request',
[HTTPStatus.UNPROCESSABLE_ENTITY]: 'Unprocessable Entity',
[HTTPStatus.LOCKED]: 'Locked',
[HTTPStatus.FAILED_DEPENDENCY]: 'Failed Dependency',
[HTTPStatus.TOO_EARLY]: 'Too Early',
[HTTPStatus.UPGRADE_REQUIRED]: 'Upgrade Required',
[HTTPStatus.PRECONDITION_REQUIRED]: 'Precondition Required',
[HTTPStatus.TOO_MANY_REQUESTS]: 'Too Many Requests',
[HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE]: 'Request Header Fields Too Large',
[HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS]: 'Unavailable For Legal Reasons',

[HTTPStatus.INTERNAL_SERVER_ERROR]: 'Internal Server Error',
[HTTPStatus.NOT_IMPLEMENTED]: 'Not Implemented',
[HTTPStatus.BAD_GATEWAY]: 'Bad Gateway',
[HTTPStatus.SERVICE_UNAVAILABLE]: 'Service Unavailable',
[HTTPStatus.GATEWAY_TIMEOUT]: 'Gateway Timeout',
[HTTPStatus.HTTP_VERSION_NOT_SUPPORTED]: 'HTTP Version Not Supported',
[HTTPStatus.VARIANT_ALSO_NEGOTIATES]: 'Variant Also Negotiates',
[HTTPStatus.INSUFFICIENT_STORAGE]: 'Insufficient Storage',
[HTTPStatus.LOOP_DETECTED]: 'Loop Detected',
[HTTPStatus.NOT_EXTENDED]: 'Not Extended',
[HTTPStatus.NETWORK_AUTHENTICATION_REQUIRED]: 'Network Authentication Required',
}

export class HTTPError extends Error {
constructor(
public readonly status: number,
message: string
) {
constructor(public readonly status: number, message: string) {
super(message)
}
}

export type ErrorReturnType = {
errors: {
message: string
path?: string[]
}[]
location?: string
type: string
}
55 changes: 55 additions & 0 deletions tests/errors/0_errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { z } from 'zod'

import request from 'supertest'
import express from 'express'
import { Handler } from '../../src/index.js'
import { HTTPError } from '../../src/utils/error.js'

const app = express()

const resolverErrorHandler = new Handler()
.validate('params', { userId: z.string() })
.resolve(async (req, res, context) => {
throw new HTTPError(400, 'This should fail')
})
.transform((data) => {
return {
data: {
name: 'Hello World',
},
meta: {},
}
})
.express()

app.get('/user/:userId', resolverErrorHandler)

// ALWAYS APPEND ERROR HANDLER AFTER ROUTES
app.use((err: any, req: any, res: any, next: any) => {
res.status(500).send('Something broke!')
})

// TESTS

describe('Error Tests', () => {
describe('Resolver Error', () => {
it('should return 400 ', (done) => {
request(app)
.get('/user/1')
.expect(400)
.then((res) => {
expect(res.body).toEqual({
errors: [
{
message: 'This should fail',
},
],
type: 'Bad Request',
})

done()
})
.catch(done)
})
})
})

0 comments on commit 6e7464a

Please sign in to comment.