Skip to content

Commit

Permalink
fix: πŸ› error handling improvements & docs
Browse files Browse the repository at this point in the history
  • Loading branch information
Evert De Spiegeleer committed Jan 13, 2024
1 parent 31fe678 commit c416cfc
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 17 deletions.
30 changes: 30 additions & 0 deletions examples/concepts-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { z } from 'zod'
import { controller, get } from '@zhttp/core'
import { NotFoundError } from '@zhttp/errors'

// Let's presume we're talking to some sort of database
const db: any = undefined

export const vegetablesController = controller('vegetables')

vegetablesController.endpoint(
get('/vegetables/:vegetableId', 'getVegetableDetails')
.input(z.object({
params: z.object({
vegetableId: z.string().uuid()
})
}))
.response(z.object({
message: z.string()
}))
.handler(async ({ params: { vegetableId } }) => {
const vegetableDetails = await db.getVegetableById(vegetableId)
if (vegetableDetails == null) {
// ✨✨✨✨✨✨✨✨✨
throw new NotFoundError(`Vegetable with id ${vegetableId} does not exist`)
// ⬆ This will result in a 404 response
// ✨✨✨✨✨✨✨✨✨
}
return vegetableDetails
})
)
42 changes: 42 additions & 0 deletions examples/validation-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { z } from 'zod'
import { controller, get } from '@zhttp/core'

export const validationExampleController = controller('validationExample')

validationExampleController.endpoint(
get('/hello', 'getGreeting')
.input(z.object({
query: z.object({
// If a name shorter than 5 characcters is provided, then the server will responde with a ValidationError.
name: z.string().min(5)
})
}))
.response(z.object({
message: z.string()
}))
.handler(async ({ query }) => {
return {
message: `Hello ${query.name ?? 'everyone'}!`
}
})
)

validationExampleController.endpoint(
get('/goodbye', 'getGoodbye')
.input(z.object({
query: z.object({
name: z.string().optional()
})
}))
.response(z.object({
message: z.string()
}))
.handler(async ({ query }) => {
return {
thisKeyShouldntBeHere: 'noBueno'
} as any
// ⬆ As zhttp is typesafe, you actually have to manually $x&! up the typing
// to provoke an output validation error :)
// This will result in an InternalServerError.
})
)
23 changes: 11 additions & 12 deletions packages/core/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import { type Controller, bindControllerToApp } from './util/controller.js'
import { type Middleware, MiddlewareTypes } from './util/middleware.js'
import { makeErrorHandlerMiddleware } from './middleware/errorHandler.js'
import { errorHandlerMiddleware } from './middleware/errorHandler.js'
import { metricMiddleware } from './middleware/metrics.js'
import { type OASInfo, Oas } from './oas.js'
import { BadRequestError } from '@zhttp/errors'
import { type ILogger, defaultLogger } from './util/logger.js'
import { type ILogger, defaultLogger, loggerInstance } from './util/logger.js'

interface RoutingOptions {
controllers?: Controller[]
Expand All @@ -31,16 +31,15 @@ export let oasInstance: Oas
export class Server {
private readonly app: Application
private httpServer: NodeHttpServer
private readonly logger: ILogger
private readonly loggerInstance
private readonly appLogger

constructor (
private readonly options: RoutingOptions = {},
private readonly httpOptions: IHTTPOptions = {},
private readonly application?: Application
private readonly externalApplication?: Application
) {
this.logger = httpOptions.logger ?? defaultLogger
this.loggerInstance = this.logger('zhttp')
loggerInstance.logger = httpOptions.logger ?? defaultLogger
this.appLogger = loggerInstance.logger('zhttp')

oasInstance = new Oas(httpOptions.oasInfo)

Expand All @@ -50,7 +49,7 @@ export class Server {
...this.httpOptions
}

this.app = application ?? express()
this.app = this.externalApplication ?? express()
this.httpServer = createServer(this.app)

this.app.set('trust proxy', this.httpOptions.trustProxy)
Expand All @@ -68,7 +67,7 @@ export class Server {
) {
callback(null, true)
} else {
this.loggerInstance.warn(`Origin ${origin} not allowed`)
this.appLogger.warn(`Origin ${origin} not allowed`)
callback(new BadRequestError('Not allowed by CORS'))
}
}
Expand All @@ -80,7 +79,7 @@ export class Server {
this.options.middlewares = [
...(this.options.middlewares ?? []),
metricMiddleware,
makeErrorHandlerMiddleware(this.logger)
errorHandlerMiddleware
]

// run all global before middlewares
Expand Down Expand Up @@ -119,7 +118,7 @@ export class Server {

async start () {
this.httpServer = this.httpServer.listen(this.httpOptions.port, () => {
this.loggerInstance.info(
this.appLogger.info(
`HTTP server listening on port ${this.httpOptions.port}`
)
})
Expand All @@ -128,7 +127,7 @@ export class Server {
async stop () {
if (this.httpServer != null) {
this.httpServer.close()
this.loggerInstance.info('HTTP server stopped')
this.appLogger.info('HTTP server stopped')
}
}
}
6 changes: 3 additions & 3 deletions packages/core/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { type Request, type Response, type NextFunction } from 'express'
import { MiddlewareTypes, middleware } from '../util/middleware.js'
import { apiResponse } from '../util/apiResponse.js'
import { ConflictError, ZHTTPError, InternalServerError } from '@zhttp/errors'
import { type ILogger } from '../util/logger.js'
import { loggerInstance } from '../util/logger.js'

export const makeErrorHandlerMiddleware = (logger: ILogger) => middleware({
export const errorHandlerMiddleware = middleware({
name: 'ErrorHandler',
type: MiddlewareTypes.AFTER,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -18,7 +18,7 @@ export const makeErrorHandlerMiddleware = (logger: ILogger) => middleware({
let status = 500
let parsedError = new InternalServerError()

const log = logger('errorHandler')
const log = loggerInstance.logger('errorHandler')

if (originalError.name === 'UniqueViolationError') {
status = 409
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/util/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { type ZodRawShape, type ZodString, type ZodObject, type ZodSchema } from
import type z from 'zod'
import type { NextFunction, Request, Response } from 'express'
import { type Middleware } from './middleware.js'
import { NotImplementedError, ValidationError } from '@zhttp/errors'
import { InternalServerError, NotImplementedError, ValidationError } from '@zhttp/errors'
import { type OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'
import { loggerInstance } from './logger.js'

export type EndpointOasInfo = Parameters<OpenAPIRegistry['registerPath']>['0']

Expand Down Expand Up @@ -282,7 +283,8 @@ export const endpointToExpressHandler = (endpoint: AnyEndpoint) => {
endpoint.getResponseValidationSchema()?.parse(responseObj)
} catch (error) {
const e = error as z.ZodError
next(new ValidationError(e.message, e.issues))
loggerInstance.logger('endpointOutputValidation').error(e)
next(new InternalServerError())
return
}

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/util/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ export const defaultLogger: ILogger = (context: string) => {
info: (message: string) => { console.info(`${prefix}${message}`) }
}
}

export const loggerInstance: {
logger: ILogger
} = {
logger: defaultLogger
}
100 changes: 100 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,106 @@ The package exports a special controller `openapiController`. When used, this co

# Errors

`zhttp` has a [built in error handler](./packages/core/src/middleware/errorHandler.ts*), which will catch any sort of error thrown in an endpoint or middleware.

## `@zhttp/errors`

Any type of unknown error will be logged and will result in a `InternalServerError` response (http status code 500).

If you want to throw a specific type of error which will be reflectced in the http response, you can use the `@zhttp/errors` library.

```ts
// ./examples/concepts-errors.ts

import { z } from 'zod'
import { controller, get } from '@zhttp/core'
import { NotFoundError } from '@zhttp/errors'

// Let's presume we're talking to some sort of database
const db: any = undefined

export const vegetablesController = controller('vegetables')

vegetablesController.endpoint(
get('/vegetables/:vegetableId', 'getVegetableDetails')
.input(z.object({
params: z.object({
vegetableId: z.string().uuid()
})
}))
.response(z.object({
message: z.string()
}))
.handler(async ({ params: { vegetableId } }) => {
const vegetableDetails = await db.getVegetableById(vegetableId)
if (vegetableDetails == null) {
// ✨✨✨✨✨✨✨✨✨
throw new NotFoundError(`Vegetable with id ${vegetableId} does not exist`)
// ⬆ This will result in a 404 response
// ✨✨✨✨✨✨✨✨✨
}
return vegetableDetails
})
)

```

## Validation errors

If an error is detected as part of the request input validation, the server will send a `ValidationError` response, including an error message explaining what's wrong.

If an error is detected as part of the request output validation, an `InternalServerError` is returned, and error message is logged.

Try it for yourself!

```ts
// ./examples/validation-errors.ts

import { z } from 'zod'
import { controller, get } from '@zhttp/core'

export const validationExampleController = controller('validationExample')

validationExampleController.endpoint(
get('/hello', 'getGreeting')
.input(z.object({
query: z.object({
// If a name shorter than 5 characcters is provided, then the server will responde with a ValidationError.
name: z.string().min(5)
})
}))
.response(z.object({
message: z.string()
}))
.handler(async ({ query }) => {
return {
message: `Hello ${query.name ?? 'everyone'}!`
}
})
)

validationExampleController.endpoint(
get('/goodbye', 'getGoodbye')
.input(z.object({
query: z.object({
name: z.string().optional()
})
}))
.response(z.object({
message: z.string()
}))
.handler(async ({ query }) => {
return {
thisKeyShouldntBeHere: 'noBueno'
} as any
// ⬆ As zhttp is typesafe, you actually have to manually $x&! up the typing
// to provoke an output validation error :)
// This will result in an InternalServerError.
})
)

```

# Order of execution
- Server 'BEFORE' middlewares
- Controller 'BEFORE' middlewares
Expand Down

0 comments on commit c416cfc

Please sign in to comment.