├── packages
├── core
│ ├── .gitignore
│ ├── tsconfig.json
│ ├── tsconfig.build.esm.json
│ ├── src
│ │ ├── main.ts
│ │ ├── util
│ │ │ ├── logger.ts
│ │ │ ├── apiResponse.ts
│ │ │ ├── controller.ts
│ │ │ ├── middleware.ts
│ │ │ ├── controller.unit.test.ts
│ │ │ ├── endpoint.unit.test.ts
│ │ │ └── endpoint.ts
│ │ ├── middleware
│ │ │ ├── metrics.ts
│ │ │ ├── errorHandler.ts
│ │ │ └── errorHandler.unit.test.ts
│ │ ├── controllers
│ │ │ ├── openapi.integration.test.ts
│ │ │ └── openapi.ts
│ │ ├── app.integration.test.ts
│ │ ├── oas.ts
│ │ └── app.ts
│ └── package.json
└── errors
│ ├── readme.md
│ ├── tsconfig.json
│ ├── tsconfig.build.esm.json
│ ├── package.json
│ └── src
│ └── main.ts
├── .readme-assets
├── .gitignore
├── header.png
└── social-header.png
├── .vscode
├── settings.json
└── extensions.json
├── .husky
├── commit-msg
└── pre-commit
├── docs
├── README.md
├── pages
│ ├── docs
│ │ ├── concepts.mdx
│ │ ├── concepts
│ │ │ ├── _meta.json
│ │ │ ├── servers.mdx
│ │ │ ├── controllers.mdx
│ │ │ ├── middlewares.mdx
│ │ │ └── endpoints.mdx
│ │ ├── installation.mdx
│ │ ├── order-of-execution.mdx
│ │ ├── _meta.json
│ │ ├── openapi.mdx
│ │ ├── index.mdx
│ │ ├── cjs.mdx
│ │ ├── basic-usage-example.mdx
│ │ └── errors.mdx
│ ├── _meta.json
│ └── index.mdx
├── components
│ ├── counters.module.css
│ └── counters.tsx
├── next-env.d.ts
├── next.config.mjs
├── tsconfig.json
├── package.json
└── theme.config.tsx
├── examples
├── direct-openapi.ts
├── concept-server.ts
├── tsconfig.json
├── concept-controller.ts
├── concept-middleware.ts
├── concept-endpoint.ts
├── concepts-errors.ts
├── validation-errors.ts
└── basic-usage.ts
├── .github
├── dependabot.yml
└── workflows
│ ├── docs.yml
│ ├── release.yml
│ └── push.yml
├── compose.yaml
├── tsconfig.build.json
├── Dockerfile.dev
├── scripts
└── version.sh
├── .dockerignore
├── .eslintrc.json
├── tsconfig.json
├── package.json
├── .gitignore
└── readme.md
/packages/core/.gitignore:
--------------------------------------------------------------------------------
1 | readme.md
--------------------------------------------------------------------------------
/.readme-assets/.gitignore:
--------------------------------------------------------------------------------
1 | *.ai
2 | *.curve
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.enablePromptUseWorkspaceTsdk": true
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx devmoji -e --lint
5 |
--------------------------------------------------------------------------------
/.readme-assets/header.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evertdespiegeleer/zhttp/HEAD/.readme-assets/header.png
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # zhttp docs built with Nextra
2 |
3 | Start dev server:
4 |
5 | ```sh
6 | npm run dev
7 | ```
--------------------------------------------------------------------------------
/.readme-assets/social-header.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evertdespiegeleer/zhttp/HEAD/.readme-assets/social-header.png
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npm run sync:readme
5 | git add ./*.md
6 |
7 |
--------------------------------------------------------------------------------
/packages/errors/readme.md:
--------------------------------------------------------------------------------
1 | # @zhttp/errors
2 |
3 | Error library extending [@zhttp/core](https://github.com/evertdespiegeleer/zhttp).
4 |
--------------------------------------------------------------------------------
/examples/direct-openapi.ts:
--------------------------------------------------------------------------------
1 | import { server } from './concept-server.js'
2 |
3 | console.log(
4 | server.oasInstance.getJsonSpec()
5 | )
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: 'daily'
--------------------------------------------------------------------------------
/docs/pages/docs/concepts.mdx:
--------------------------------------------------------------------------------
1 | # Concepts
2 |
3 | This section will go over the library concepts you need to be aware of when using zhttp in your application.
--------------------------------------------------------------------------------
/docs/components/counters.module.css:
--------------------------------------------------------------------------------
1 | .counter {
2 | border: 1px solid #ccc;
3 | border-radius: 5px;
4 | padding: 2px 6px;
5 | margin: 12px 0 0;
6 | }
7 |
--------------------------------------------------------------------------------
/compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | dev:
3 | build:
4 | context: '.'
5 | dockerfile: Dockerfile.dev
6 | volumes:
7 | - ./packages:/usr/src/app/packages
--------------------------------------------------------------------------------
/docs/pages/docs/concepts/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "endpoints": "Endpoints",
3 | "controllers": "Controllers",
4 | "middlewares": "Middlewares",
5 | "servers": "Servers"
6 | }
--------------------------------------------------------------------------------
/docs/pages/docs/installation.mdx:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | Install zhttp into your JavaScript or TypeScript project:
4 |
5 | ```sh npm2yarn
6 | npm install @zhttp/core @zhttp/errors zod
7 | ```
--------------------------------------------------------------------------------
/docs/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist"
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["src/**/*.test.ts"]
9 | }
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "declarationDir": "./dist/types"
6 | },
7 | "include": [
8 | "./src/**/*.ts"
9 | ]
10 | }
--------------------------------------------------------------------------------
/packages/errors/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "declarationDir": "./dist/types"
6 | },
7 | "include": [
8 | "./src/**/*.ts"
9 | ]
10 | }
--------------------------------------------------------------------------------
/packages/core/tsconfig.build.esm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist/esm",
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["src/**/*.test.ts"]
9 | }
--------------------------------------------------------------------------------
/packages/errors/tsconfig.build.esm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist/esm"
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["src/**/*.test.ts"]
9 | }
--------------------------------------------------------------------------------
/docs/pages/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "index": {
3 | "type": "page",
4 | "title": "Home",
5 | "display": "hidden",
6 | "theme": {
7 | "layout": "raw"
8 | }
9 | },
10 | "docs": {
11 | "type": "page",
12 | "title": "Documentation"
13 | }
14 | }
--------------------------------------------------------------------------------
/docs/next.config.mjs:
--------------------------------------------------------------------------------
1 | import withNextra from 'nextra'
2 |
3 | export default withNextra({
4 | theme: 'nextra-theme-docs',
5 | themeConfig: './theme.config.tsx'
6 | })({
7 | reactStrictMode: true,
8 | output: "export",
9 | images: {
10 | unoptimized: true,
11 | },
12 | })
13 |
--------------------------------------------------------------------------------
/docs/pages/docs/order-of-execution.mdx:
--------------------------------------------------------------------------------
1 | # Order of execution
2 |
3 | - Server 'BEFORE' middlewares
4 | - Controller 'BEFORE' middlewares
5 | - Endpoint 'BEFORE' middlewares
6 | - **Endpoint handler**
7 | - Endpoint 'AFTER' middlewares
8 | - Controller 'AFTER' middlewares
9 | - Server 'AFTER' middlewares
--------------------------------------------------------------------------------
/docs/pages/docs/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "index": "Introduction",
3 | "installation": "Installation",
4 | "basic-usage-example": "Basic usage example",
5 | "concepts": "Concepts",
6 | "openapi": "OpenAPI",
7 | "errors": "Errors",
8 | "order-of-execution": "Order of execution",
9 | "cjs": "CommonJS support"
10 | }
--------------------------------------------------------------------------------
/examples/concept-server.ts:
--------------------------------------------------------------------------------
1 | import { Server } from '@zhttp/core'
2 | import { greetingController } from './concept-controller.js'
3 | import { lastVisitMiddleware } from './concept-middleware.js'
4 |
5 | export const server = new Server({
6 | controllers: [greetingController],
7 | middlewares: [lastVisitMiddleware]
8 | }, {
9 | port: 8080
10 | })
11 |
12 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
13 | server.start()
14 |
--------------------------------------------------------------------------------
/docs/pages/index.mdx:
--------------------------------------------------------------------------------
1 | {/* Splash page should come here */}
2 | {/* Redirect to /docs for now */}
3 |
4 | {/* TODO */}
5 |
6 | import { useEffect } from "react"
7 |
8 | export default function Home() {
9 | useEffect(() => {
10 | window.location.href = "/docs";
11 | }, [])
12 | return (
13 |
14 |
15 | Redirecting to docs...
16 |
17 |
18 | )
19 | }
20 |
21 |
22 |
--------------------------------------------------------------------------------
/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | ARG NODE_VERSION=20.10.0
2 | FROM node:${NODE_VERSION}-alpine as base
3 |
4 | WORKDIR /usr/src/app
5 |
6 | FROM base as deps
7 |
8 | RUN --mount=type=bind,source=package.json,target=package.json \
9 | --mount=type=bind,source=package-lock.json,target=package-lock.json \
10 | --mount=type=bind,source=packages,target=packages \
11 | --mount=type=cache,target=/root/.npm \
12 | npm ci
13 |
14 | COPY ./package*.json ./
15 | COPY ./tsconfig*.json ./
16 |
17 | CMD ["sh"]
--------------------------------------------------------------------------------
/examples/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "rootDir": "..",
6 | "paths": {
7 | "@zhttp/core*": ["../packages/core/src", "../packages/core/src/main.ts", "../packages/core/src/*"],
8 | "@zhttp/errors*": ["../packages/errors/src", "../packages/errors/src/main.ts", "../packages/errors/src/*"],
9 | }
10 | },
11 | "include": [
12 | "./*.ts",
13 | "../**/*.ts"
14 | ],
15 | }
--------------------------------------------------------------------------------
/docs/components/counters.tsx:
--------------------------------------------------------------------------------
1 | // Example from https://beta.reactjs.org/learn
2 |
3 | import { useState } from 'react'
4 | import styles from './counters.module.css'
5 |
6 | function MyButton () {
7 | const [count, setCount] = useState(0)
8 |
9 | function handleClick () {
10 | setCount(count + 1)
11 | }
12 |
13 | return (
14 |
15 |
18 |
19 | )
20 | }
21 |
22 | export default function MyApp () {
23 | return
24 | }
25 |
--------------------------------------------------------------------------------
/packages/core/src/main.ts:
--------------------------------------------------------------------------------
1 | export { Server } from './app.js'
2 | export {
3 | endpoint,
4 | type Method,
5 | get,
6 | put,
7 | post,
8 | del,
9 | type InputValidationSchema,
10 | type ResponseValidationSchema
11 | } from './util/endpoint.js'
12 |
13 | export { openapiController } from './controllers/openapi.js'
14 |
15 | export { controller } from './util/controller.js'
16 |
17 | export * from './util/middleware.js'
18 |
19 | export { apiResponse, zApiOutput } from './util/apiResponse.js'
20 | export { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'
21 |
--------------------------------------------------------------------------------
/docs/pages/docs/concepts/servers.mdx:
--------------------------------------------------------------------------------
1 | # Servers
2 |
3 | ## Basic server example
4 |
5 | ```ts
6 | // ../../../../examples/concept-server.ts
7 |
8 | import { Server } from '@zhttp/core'
9 | import { greetingController } from './concept-controller.js'
10 | import { lastVisitMiddleware } from './concept-middleware.js'
11 |
12 | export const server = new Server({
13 | controllers: [greetingController],
14 | middlewares: [lastVisitMiddleware]
15 | }, {
16 | port: 8080
17 | })
18 |
19 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
20 | server.start()
21 |
22 | ```
--------------------------------------------------------------------------------
/docs/pages/docs/openapi.mdx:
--------------------------------------------------------------------------------
1 | # OpenAPI
2 |
3 | ## `openapiController`
4 |
5 | The package exports a special controller `openapiController`. When used, this controller exposes routes `/openapi.json` (the OpenAPI json spec) and `/api.html` (a [RapiDoc](https://rapidocweb.com/) api interface).
6 |
7 | ## Programmatic access
8 |
9 | The openapi definition can be directly from the server object.
10 |
11 | ```ts
12 | // ../../../examples/direct-openapi.ts
13 |
14 | import { server } from './concept-server.js'
15 |
16 | console.log(
17 | server.oasInstance.getJsonSpec()
18 | )
19 |
20 | ```
--------------------------------------------------------------------------------
/docs/pages/docs/index.mdx:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | zhttp is a minimal, typesafe, [OpenAPI](https://www.openapis.org/) compatible HTTP library. It's build around [express](https://github.com/expressjs/express) and [Zod](https://github.com/colinhacks/zod).
4 |
5 | It solves some of the major pains of building an API with express (handler typing, error handling, input/output validation, openapi...) while attempting to stay as flexible (read: _as close to plain express_) as possible.
6 |
7 | [🧪 Try out zhttp on Stackblitz!](https://stackblitz.com/~/github.com/evertdespiegeleer/zhttp-example-app?initialPath=%2Fapi.html)
--------------------------------------------------------------------------------
/docs/pages/docs/cjs.mdx:
--------------------------------------------------------------------------------
1 | # CommonJS support
2 |
3 | [📰 CommonJS is hurting JavaScript](https://deno.com/blog/commonjs-is-hurting-javascript)
4 |
5 | The JavaScript ecosystem is (slowly but steadily) moving towards ESM and away from CommonJS. zhttp is build as an ESM module. It's strongly encouraged to use it like that.
6 |
7 | CommonJS is currently supported; the packages include both builds for ESM and CommonJS. You can use zhttp both ways.
8 |
9 | If major issues with supporting CommonJS were to come up, or if we'd notice that the package would become too big (by essentially having to ship the build code twice), CommonJS support might be dropped in the future.
--------------------------------------------------------------------------------
/examples/concept-controller.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { controller, get } from '@zhttp/core'
3 |
4 | export const greetingController = controller('greeting')
5 | .description('A controller that greets the world.')
6 |
7 | greetingController.endpoint(
8 | get('/hello', 'getGreeting')
9 | .description('Say hello to everyone')
10 | .input({
11 | query: z.object({
12 | name: z.string().optional()
13 | })
14 | })
15 | .response(z.object({
16 | message: z.string()
17 | }))
18 | .handler(async ({ query }) => {
19 | return {
20 | message: `Hello ${query.name ?? 'everyone'}!`
21 | }
22 | })
23 | )
24 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "target": "es5",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": false,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "incremental": true,
12 | "esModuleInterop": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve",
18 | "strictNullChecks": true
19 | },
20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "*.config.*"],
21 | "exclude": ["node_modules", "*.config.*"]
22 | }
23 |
--------------------------------------------------------------------------------
/scripts/version.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | NPM_VERSION_COMMAND=$@
4 | NPM_VERSION_ARGS="--no-commit-hooks --no-git-tag-version --no-workspaces-update"
5 |
6 | # # Update the root version
7 | npm version --quiet $NPM_VERSION_ARGS $NPM_VERSION_COMMAND
8 |
9 | export NEW_VERSION=$(cat package.json | jq -r '.version')
10 |
11 | # Update all package versions
12 | npm version --quiet --workspaces $NPM_VERSION_ARGS $NEW_VERSION
13 |
14 | # Update all dependencies
15 | find . -name 'package.json' -not -path "**/node_modules/*" -type f | while read -r file; do
16 | cat $file | perl -pe 's/(\"\@zhttp\/.+?\")\: \".+?\"/$1\: \"$ENV{NEW_VERSION}\"/g' > "$file.new"
17 | mv "$file.new" $file
18 | done
19 |
20 | npm i
21 |
22 | echo $NEW_VERSION
23 | exit 0
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Include any files or directories that you don't want to be copied to your
2 | # container here (e.g., local build artifacts, temporary files, etc.).
3 | #
4 | # For more help, visit the .dockerignore file reference guide at
5 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file
6 |
7 | **/.classpath
8 | **/.dockerignore
9 | **/.env
10 | **/.git
11 | **/.gitignore
12 | **/.project
13 | **/.settings
14 | **/.toolstarget
15 | **/.vs
16 | **/.vscode
17 | **/.next
18 | **/.cache
19 | **/*.*proj.user
20 | **/*.dbmdl
21 | **/*.jfm
22 | **/charts
23 | **/docker-compose*
24 | **/compose*
25 | **/Dockerfile*
26 | **/node_modules
27 | **/npm-debug.log
28 | **/obj
29 | **/secrets.dev.yaml
30 | **/values.dev.yaml
31 | **/build
32 | **/dist
33 | LICENSE
34 | README.md
35 |
--------------------------------------------------------------------------------
/packages/core/src/util/logger.ts:
--------------------------------------------------------------------------------
1 | export type ILogger = (context: string) => {
2 | log: (...message: any[]) => unknown
3 | warn: (...message: any[]) => unknown
4 | error: (...message: any[]) => unknown
5 | info: (...message: any[]) => unknown
6 | }
7 |
8 | export const defaultLogger: ILogger = (context: string) => {
9 | const prefix = `[${context}]: `
10 | return {
11 | log: (message: string) => { console.log(`${prefix}${message}`) },
12 | warn: (message: string) => { console.warn(`${prefix}${message}`) },
13 | error: (message: string) => { console.error(`${prefix}${message}`) },
14 | info: (message: string) => { console.info(`${prefix}${message}`) }
15 | }
16 | }
17 |
18 | export const loggerInstance: {
19 | logger: ILogger
20 | } = {
21 | logger: defaultLogger
22 | }
23 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zhttp/docs",
3 | "private": true,
4 | "version": "1.1.3",
5 | "scripts": {
6 | "fill-code-snippets": "npx embedme ./pages/**/*.*",
7 | "prebuild": "npm run fill-code-snippets",
8 | "dev": "next dev",
9 | "build": "next build",
10 | "start": "next start"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/evertdespiegeleer/zhttp"
15 | },
16 | "bugs": {
17 | "url": "https://github.com/evertdespiegeleer/zhttp"
18 | },
19 | "dependencies": {
20 | "next": "^13.0.6",
21 | "nextra": "latest",
22 | "nextra-theme-docs": "latest",
23 | "react": "^18.2.0",
24 | "react-dom": "^18.2.0"
25 | },
26 | "devDependencies": {
27 | "@types/node": "18.11.10",
28 | "typescript": "^4.9.3"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "es2021": true
6 | },
7 | "extends": "standard-with-typescript",
8 | "parserOptions": {
9 | "ecmaVersion": "latest",
10 | "sourceType": "module",
11 | "project": [
12 | "./tsconfig.json",
13 | "./packages/*/tsconfig.json",
14 | "./examples/tsconfig.json"
15 | ]
16 | },
17 | "rules": {
18 | "@typescript-eslint/explicit-function-return-type": "off"
19 | },
20 | "overrides": [
21 | {
22 | "files": ["*.test.ts"],
23 | "rules": {
24 | // Disable on test files so we can use chai `...to.be.undefined` style syntax
25 | "@typescript-eslint/no-unused-expressions": "off"
26 | }
27 | }
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/packages/core/src/middleware/metrics.ts:
--------------------------------------------------------------------------------
1 | import { type NextFunction, type Request, type Response } from 'express'
2 | import { Counter } from 'prom-client'
3 | import { MiddlewareTypes, middleware } from '../util/middleware.js'
4 |
5 | const metrics = {
6 | httpRequests: new Counter({
7 | name: 'http_requests_total',
8 | help: 'Total number of HTTP requests',
9 | labelNames: ['method', 'path', 'status']
10 | })
11 | }
12 |
13 | export const metricMiddleware = middleware({
14 | name: 'metricMiddleware',
15 | type: MiddlewareTypes.BEFORE,
16 | handler (req: Request, res: Response, next: NextFunction) {
17 | res.once('finish', () => {
18 | metrics.httpRequests.inc({
19 | method: req.method,
20 | path: req.originalUrl,
21 | status: res.statusCode
22 | })
23 | })
24 | next()
25 | }
26 | })
27 |
--------------------------------------------------------------------------------
/packages/core/src/controllers/openapi.integration.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | import { Server } from '../app.js'
3 | import supertest from 'supertest'
4 | import { describe, it, before, after } from 'node:test'
5 | import { openapiController } from './openapi.js'
6 |
7 | describe('openapiController', () => {
8 | let http: Server
9 | before(async () => {
10 | http = new Server({
11 | controllers: [openapiController]
12 | }, { port: undefined })
13 | await http.start()
14 | })
15 |
16 | after(async () => {
17 | await http.stop()
18 | })
19 |
20 | it('Serves a open api spec', (t, done) => {
21 | supertest(http.expressInstance).get('/openapi.json').expect(200, done)
22 | })
23 |
24 | it('Serves api docs', (t, done) => {
25 | supertest(http.expressInstance).get('/api.html').expect(200, done)
26 | })
27 | })
28 |
--------------------------------------------------------------------------------
/docs/theme.config.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { DocsThemeConfig } from 'nextra-theme-docs'
3 |
4 | const config: DocsThemeConfig = {
5 | logo: zhttp,
6 | project: {
7 | link: 'https://github.com/evertdespiegeleer/zhttp',
8 | },
9 | docsRepositoryBase: 'https://github.com/evertdespiegeleer/zhttp/docs',
10 | feedback: {
11 | useLink: () => 'https://github.com/evertdespiegeleer/zhttp/issues',
12 | },
13 | editLink: {
14 | text: 'Edit this page on GitHub →',
15 | },
16 | footer: {
17 | text:
25 | },
26 | toc: {
27 | backToTop: true
28 | }
29 | }
30 |
31 | export default config
32 |
--------------------------------------------------------------------------------
/examples/concept-middleware.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response, type NextFunction } from 'express'
2 | import { middleware, MiddlewareTypes } from '@zhttp/core'
3 |
4 | export const lastVisitMiddleware = middleware({
5 | name: 'lastVisitMiddleware',
6 | type: MiddlewareTypes.BEFORE,
7 | handler (req: Request, res: Response, next: NextFunction) {
8 | const now = new Date()
9 | const lastVisitCookieValue = req.cookies.beenHereBefore
10 | const lastVisitTime = lastVisitCookieValue != null ? new Date(String(lastVisitCookieValue)) : undefined
11 | res.cookie('beenHereBefore', now.toISOString())
12 | if (lastVisitTime == null) {
13 | console.log('Seems like we\'ve got a new user 👀')
14 | next(); return
15 | }
16 | const daysSinceLastVisit = (now.getTime() - lastVisitTime.getTime()) / (1000 * 60 * 60 * 24)
17 | console.log(`It's been ${daysSinceLastVisit} days since this user last visited.`)
18 | next()
19 | }
20 | })
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "esModuleInterop": true,
4 | "skipLibCheck": true,
5 | "target": "es2022",
6 | "allowJs": true,
7 | "resolveJsonModule": true,
8 | "moduleDetection": "force",
9 | "isolatedModules": true,
10 |
11 | "strict": true,
12 | "noUncheckedIndexedAccess": true,
13 |
14 | "moduleResolution": "NodeNext",
15 | "module": "NodeNext",
16 | "sourceMap": true,
17 | "declaration": true,
18 |
19 | // "composite": true,
20 | "declarationMap": true,
21 |
22 | "lib": [
23 | "es2022"
24 | ],
25 |
26 | "baseUrl": ".",
27 | "paths": {
28 | "*": ["node_modules/*"],
29 | }
30 | },
31 | "include": [
32 | "packages/*/src/**/*.ts",
33 | ],
34 | "ts-node": {
35 | "experimentalSpecifierResolution": "node",
36 | "transpileOnly": true,
37 | "esm": true,
38 | },
39 | }
--------------------------------------------------------------------------------
/examples/concept-endpoint.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { endpoint, get } from '@zhttp/core'
3 |
4 | const zGreetingOutput = z.object({
5 | message: z.string()
6 | })
7 |
8 | const zGreetingInput = {
9 | query: z.object({
10 | name: z.string().optional()
11 | })
12 | }
13 |
14 | // ⬇ For common http methods (get, post, put, del), utility functions are available:
15 | get('/hello', 'getGreeting')
16 | .description('Say hello to everyone')
17 | .input(zGreetingInput)
18 | .response(zGreetingOutput)
19 | .handler(async ({ query }) => {
20 | return {
21 | message: `Hello ${query.name ?? 'everyone'}!`
22 | }
23 | })
24 |
25 | // `endpoint` is a generic function which supports every http method.
26 | endpoint('get', '/goodbye', 'getGoodbye')
27 | .description('Say goodbye to everyone')
28 | .input(zGreetingInput)
29 | .response(zGreetingOutput)
30 | .handler(async ({ query }) => {
31 | return {
32 | message: `Goodbye ${query.name ?? 'everyone'}!`
33 | }
34 | })
35 |
--------------------------------------------------------------------------------
/examples/concepts-errors.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { controller, get } from '@zhttp/core'
3 | import { NotFoundError } from '@zhttp/errors'
4 |
5 | // Let's presume we're talking to some sort of database
6 | const db: any = undefined
7 |
8 | export const vegetablesController = controller('vegetables')
9 |
10 | vegetablesController.endpoint(
11 | get('/vegetables/:vegetableId', 'getVegetableDetails')
12 | .input({
13 | params: z.object({
14 | vegetableId: z.string().uuid()
15 | })
16 | })
17 | .response(z.object({
18 | message: z.string()
19 | }))
20 | .handler(async ({ params: { vegetableId } }) => {
21 | const vegetableDetails = await db.getVegetableById(vegetableId)
22 | if (vegetableDetails == null) {
23 | // ✨✨✨✨✨✨✨✨✨
24 | throw new NotFoundError(`Vegetable with id ${vegetableId} does not exist`)
25 | // ⬆ This will result in a 404 response
26 | // ✨✨✨✨✨✨✨✨✨
27 | }
28 | return vegetableDetails
29 | })
30 | )
31 |
--------------------------------------------------------------------------------
/docs/pages/docs/concepts/controllers.mdx:
--------------------------------------------------------------------------------
1 | # Controllers
2 |
3 | An controller, essentially, is nothing but a group of endpoints.
4 | Just like individual endpoints, controllers can be assigned middlewares.
5 | Controllers do **not** serve as routers. Every endpoint path should be a _complete_ path.
6 |
7 | # Basic controller example
8 |
9 | ```ts
10 | // ../../../../examples/concept-controller.ts
11 |
12 | import { z } from 'zod'
13 | import { controller, get } from '@zhttp/core'
14 |
15 | export const greetingController = controller('greeting')
16 | .description('A controller that greets the world.')
17 |
18 | greetingController.endpoint(
19 | get('/hello', 'getGreeting')
20 | .description('Say hello to everyone')
21 | .input({
22 | query: z.object({
23 | name: z.string().optional()
24 | })
25 | })
26 | .response(z.object({
27 | message: z.string()
28 | }))
29 | .handler(async ({ query }) => {
30 | return {
31 | message: `Hello ${query.name ?? 'everyone'}!`
32 | }
33 | })
34 | )
35 |
36 | ```
37 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: docs
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | docs:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: read
13 | pages: write
14 | id-token: write
15 | concurrency:
16 | group: "pages"
17 | cancel-in-progress: false
18 | environment:
19 | name: github-pages
20 | url: ${{ steps.deployment.outputs.page_url }}
21 | steps:
22 | - name: Checkout code
23 | uses: actions/checkout@v4
24 | - name: Set Node.js 20.11.0
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: 20.11.0
28 | - name: Install Dependencies
29 | run: npm ci
30 | - name: Setup Pages
31 | uses: actions/configure-pages@v4
32 | - name: Build docs
33 | run: npm run -w './docs' build
34 | - name: Upload artifact
35 | uses: actions/upload-pages-artifact@v3
36 | with:
37 | path: './docs/out'
38 | - name: Deploy to GitHub Pages
39 | id: deployment
40 | uses: actions/deploy-pages@v4
--------------------------------------------------------------------------------
/examples/validation-errors.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { controller, get } from '@zhttp/core'
3 |
4 | export const validationExampleController = controller('validationExample')
5 |
6 | validationExampleController.endpoint(
7 | get('/hello', 'getGreeting')
8 | .input({
9 | query: z.object({
10 | // If a name shorter than 5 characcters is provided, then the server will responde with a ValidationError.
11 | name: z.string().min(5)
12 | })
13 | })
14 | .response(z.object({
15 | message: z.string()
16 | }))
17 | .handler(async ({ query }) => {
18 | return {
19 | message: `Hello ${query.name ?? 'everyone'}!`
20 | }
21 | })
22 | )
23 |
24 | validationExampleController.endpoint(
25 | get('/goodbye', 'getGoodbye')
26 | .input({
27 | query: z.object({
28 | name: z.string().optional()
29 | })
30 | })
31 | .response(z.object({
32 | message: z.string()
33 | }))
34 | .handler(async ({ query }) => {
35 | return {
36 | thisKeyShouldntBeHere: 'noBueno'
37 | } as any
38 | // ⬆ As zhttp is typesafe, you actually have to manually $x&! up the typing
39 | // to provoke an output validation error :)
40 | // This will result in an InternalServerError.
41 | })
42 | )
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zhttp/monorepo",
3 | "version": "1.1.3",
4 | "private": true,
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/evertdespiegeleer/zhttp.git"
8 | },
9 | "license": "MIT",
10 | "author": "Evert De Spiegeleer",
11 | "type": "module",
12 | "workspaces": [
13 | "packages/errors",
14 | "packages/*",
15 | "docs"
16 | ],
17 | "scripts": {
18 | "build": "npm run build --workspaces --if-present",
19 | "lint": "eslint 'packages/*/src/**/*.ts' 'examples/*.ts'",
20 | "lint:fix": "npm run lint -- --fix",
21 | "prepare": "npx husky install && cp ./readme.md ./packages/core || true",
22 | "sync:readme": "npx embedme ./readme.md",
23 | "test": "npm run test --workspaces --if-present"
24 | },
25 | "devDependencies": {
26 | "@typescript-eslint/eslint-plugin": "^6.4.0",
27 | "@typescript-eslint/parser": "^6.18.1",
28 | "devmoji": "^2.3.0",
29 | "eslint": "^8.0.1",
30 | "eslint-config-standard-with-typescript": "^43.0.0",
31 | "eslint-plugin-import": "^2.25.2",
32 | "eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
33 | "eslint-plugin-promise": "^6.0.0",
34 | "husky": "^8.0.0",
35 | "ts-node": "^10.9.2",
36 | "typescript": "^4.4.4"
37 | },
38 | "engines": {
39 | "node": "^20.10.x",
40 | "npm": "^10.2.3"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/errors/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zhttp/errors",
3 | "version": "1.1.3",
4 | "description": "Error library extending @zhttp/core",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/evertdespiegeleer/zhttp.git"
8 | },
9 | "license": "MIT",
10 | "author": "Evert De Spiegeleer",
11 | "type": "module",
12 | "exports": {
13 | "import": {
14 | "types": "./dist/types/main.d.ts",
15 | "default": "./dist/esm/main.js"
16 | },
17 | "require": {
18 | "types": "./dist/types/main.d.ts",
19 | "default": "./dist/cjs/main.cjs"
20 | }
21 | },
22 | "types": "./dist/types/main.d.ts",
23 | "files": [
24 | "./dist",
25 | "./readme.md"
26 | ],
27 | "scripts": {
28 | "build": "npm run build:esm && npm run build:cjs",
29 | "build:cjs": "npx rollup ./dist/esm/main.js --file ./dist/cjs/main.cjs --format cjs",
30 | "build:esm": "tsc -p tsconfig.build.esm.json",
31 | "test": "echo \"No tests here :)\""
32 | },
33 | "dependencies": {
34 | "zod": "^3.22.4"
35 | },
36 | "devDependencies": {
37 | "@types/node": "^20.11.0",
38 | "ts-node": "^10.9.2",
39 | "typescript": "^4.4.4"
40 | },
41 | "peerDependencies": {
42 | "zod": "^3.22.4"
43 | },
44 | "engines": {
45 | "node": "^20.10.x",
46 | "npm": "^10.2.3"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/core/src/controllers/openapi.ts:
--------------------------------------------------------------------------------
1 | import { controller } from '../util/controller.js'
2 | import { get } from '../util/endpoint.js'
3 | import { oasInstance } from '../app.js'
4 |
5 | export const openapiController = controller('OpenAPI')
6 | .description('Exposes an OpenAPI spec and Rapidoc page')
7 | .endpoints([
8 | get('/openapi.json', 'getOpenAPISpec')
9 | .responseContentType('application/json')
10 | .handler(async () => {
11 | return oasInstance.getJsonSpec()
12 | }),
13 |
14 | get('/api.html', 'API usage')
15 | .responseContentType('text/html')
16 | .handler(
17 | async () => `
18 |
19 |
20 |
21 |
25 |
26 |
27 |
37 |
38 |
39 | `
40 | )
41 | ])
42 |
--------------------------------------------------------------------------------
/packages/core/src/middleware/errorHandler.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response, type NextFunction } from 'express'
2 | import { MiddlewareTypes, middleware } from '../util/middleware.js'
3 | import { apiResponse } from '../util/apiResponse.js'
4 | import { ConflictError, ZHTTPError, InternalServerError } from '@zhttp/errors'
5 | import { loggerInstance } from '../util/logger.js'
6 |
7 | export const errorHandlerMiddleware = middleware({
8 | name: 'ErrorHandler',
9 | type: MiddlewareTypes.AFTER,
10 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
11 | handler (
12 | originalError: Error,
13 | req: Request,
14 | res: Response,
15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
16 | next: NextFunction
17 | ) {
18 | let status = 500
19 | let parsedError = new InternalServerError()
20 |
21 | const log = loggerInstance.logger('errorHandler')
22 |
23 | if (originalError.name === 'UniqueViolationError') {
24 | status = 409
25 | parsedError = new ConflictError(parsedError.message)
26 | }
27 |
28 | if (originalError instanceof ZHTTPError) {
29 | status = originalError.http
30 | parsedError = originalError
31 | }
32 |
33 | // log.error(originalError);
34 | if (status >= 500) {
35 | log.error(`🔴 FAIL ${req.method} ${req.originalUrl}`, parsedError)
36 | } else {
37 | log.warn(`⚠️ FAIL ${req.method} ${req.originalUrl}`, parsedError)
38 | }
39 |
40 | res.status(status).json(apiResponse({}, { error: parsedError }))
41 | return res.end()
42 | }
43 | })
44 |
--------------------------------------------------------------------------------
/examples/basic-usage.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import {
3 | Server,
4 | controller,
5 | get,
6 | extendZodWithOpenApi,
7 | zApiOutput,
8 | apiResponse,
9 | openapiController
10 | } from '@zhttp/core'
11 |
12 | extendZodWithOpenApi(z)
13 | // ⬆ What this allows you to do is to optionally add OAS info
14 | // to a Zod validation schema using zodSchema.openapi(...)
15 | // If this Zod schema is used in the input or output of an endpoint,
16 | // the info provided will be included in the generated openapi spec.
17 | //
18 | // Exmaple:
19 |
20 | const zHelloResponse = zApiOutput(z.object({
21 | greeting: z.string().openapi({ example: 'Hello Joske!' })
22 | })).openapi('HelloResponse')
23 |
24 | const helloController = controller('Hello')
25 | .description('This controller says hello to everyone')
26 |
27 | helloController.endpoint(
28 | get('/hello')
29 | .input({
30 | params: z.object({
31 | name: z.string().optional()
32 | })
33 | })
34 | .response(zHelloResponse)
35 | .handler(async (input) => {
36 | return apiResponse({
37 | // Both the input object ⬇ and the handler response are strongly typed :)
38 | greeting: `Hello ${input.params.name ?? 'everybody'}!`
39 | })
40 | })
41 | )
42 |
43 | const server = new Server({
44 | controllers: [
45 | helloController,
46 | openapiController
47 | ],
48 | middlewares: []
49 | }, {
50 | port: 3000,
51 | oasInfo: {
52 | title: 'A very cool api',
53 | version: '1.0.0'
54 | }
55 | })
56 |
57 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
58 | server.start()
59 |
--------------------------------------------------------------------------------
/docs/pages/docs/concepts/middlewares.mdx:
--------------------------------------------------------------------------------
1 | # Middlewares
2 |
3 | A middleware is a function that operates between an incoming request and the corresponding outgoing response. It serves as a processing layer before or after an endpoint handler, carrying out tasks like logging, authentication, and other sorts of data manipulation.
4 |
5 | Middlewares in `zhttp` are essentially just express middlewares, with two extra properties: their type ([indicating when to run them](../order-of-execution)), and an optional name.
6 | Middlewares can be bound on multiple levels:
7 | - The server
8 | - A controller
9 | - An endpoint
10 |
11 | ## Basic middleware example
12 |
13 | ```ts
14 | // ../../../../examples/concept-middleware.ts
15 |
16 | import { type Request, type Response, type NextFunction } from 'express'
17 | import { middleware, MiddlewareTypes } from '@zhttp/core'
18 |
19 | export const lastVisitMiddleware = middleware({
20 | name: 'lastVisitMiddleware',
21 | type: MiddlewareTypes.BEFORE,
22 | handler (req: Request, res: Response, next: NextFunction) {
23 | const now = new Date()
24 | const lastVisitCookieValue = req.cookies.beenHereBefore
25 | const lastVisitTime = lastVisitCookieValue != null ? new Date(String(lastVisitCookieValue)) : undefined
26 | res.cookie('beenHereBefore', now.toISOString())
27 | if (lastVisitTime == null) {
28 | console.log('Seems like we\'ve got a new user 👀')
29 | next(); return
30 | }
31 | const daysSinceLastVisit = (now.getTime() - lastVisitTime.getTime()) / (1000 * 60 * 60 * 24)
32 | console.log(`It's been ${daysSinceLastVisit} days since this user last visited.`)
33 | next()
34 | }
35 | })
36 |
37 | ```
38 |
--------------------------------------------------------------------------------
/docs/pages/docs/basic-usage-example.mdx:
--------------------------------------------------------------------------------
1 | # Basic usage example
2 |
3 | ```ts
4 | // ../../../examples/basic-usage.ts
5 |
6 | import { z } from 'zod'
7 | import {
8 | Server,
9 | controller,
10 | get,
11 | extendZodWithOpenApi,
12 | zApiOutput,
13 | apiResponse,
14 | openapiController
15 | } from '@zhttp/core'
16 |
17 | extendZodWithOpenApi(z)
18 | // ⬆ What this allows you to do is to optionally add OAS info
19 | // to a Zod validation schema using zodSchema.openapi(...)
20 | // If this Zod schema is used in the input or output of an endpoint,
21 | // the info provided will be included in the generated openapi spec.
22 | //
23 | // Exmaple:
24 |
25 | const zHelloResponse = zApiOutput(z.object({
26 | greeting: z.string().openapi({ example: 'Hello Joske!' })
27 | })).openapi('HelloResponse')
28 |
29 | const helloController = controller('Hello')
30 | .description('This controller says hello to everyone')
31 |
32 | helloController.endpoint(
33 | get('/hello')
34 | .input({
35 | params: z.object({
36 | name: z.string().optional()
37 | })
38 | })
39 | .response(zHelloResponse)
40 | .handler(async (input) => {
41 | return apiResponse({
42 | // Both the input object ⬇ and the handler response are strongly typed :)
43 | greeting: `Hello ${input.params.name ?? 'everybody'}!`
44 | })
45 | })
46 | )
47 |
48 | const server = new Server({
49 | controllers: [
50 | helloController,
51 | openapiController
52 | ],
53 | middlewares: []
54 | }, {
55 | port: 3000,
56 | oasInfo: {
57 | title: 'A very cool api',
58 | version: '1.0.0'
59 | }
60 | })
61 |
62 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
63 | server.start()
64 |
65 | ```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | release-type:
7 | type: choice
8 | description: Semver release type
9 | required: true
10 | options:
11 | - patch
12 | - minor
13 | - major
14 |
15 | jobs:
16 | release:
17 | if: github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch'
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: Checkout code
21 | uses: actions/checkout@v3
22 | with:
23 | fetch-depth: 0
24 | ref: ${{ github.ref }}
25 |
26 | - name: Set Node.js 20.10
27 | uses: actions/setup-node@v3
28 | with:
29 | node-version: 20.10
30 |
31 | - name: Install Dependencies
32 | run: npm ci
33 |
34 | - name: Build
35 | run: npm run build
36 |
37 | - name: Configure Git
38 | uses: fregante/setup-git-user@v1
39 |
40 | - name: Version the packages
41 | id: version
42 | run: |
43 | ./scripts/version.sh ${{ github.event.inputs.release-type }}
44 | export VERSION=$(cat package.json | jq -r '.version')
45 | echo "version=$VERSION" >> $GITHUB_OUTPUT
46 |
47 | - name: Publish to npm
48 | run: |
49 | echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_PUBLISH_TOKEN}}" >> ~/.npmrc
50 | echo "registry=https://registry.npmjs.org" >> ~/.npmrc
51 | npm publish --workspaces --access=public
52 | rm -f ~/.npmrc
53 |
54 | - name: Commit and push changes
55 | run: |
56 | git commit -am "chore(version): 📦 ${{ github.event.inputs.release-type }} version bump to ${{ steps.version.outputs.version }}"
57 | git push
58 |
--------------------------------------------------------------------------------
/packages/core/src/util/apiResponse.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | interface IApiResponseOptions {
4 | error?: any
5 | meta?: Record
6 | }
7 |
8 | export function apiResponse (
9 | data: DataType,
10 | opts?: IApiResponseOptions
11 | ) {
12 | if (opts?.error != null) {
13 | const errorDetails = {
14 | code: opts.error?.name ?? 'InternalServerError',
15 |
16 | details: opts.error?.details != null
17 | ? opts.error?.details
18 | : {}
19 | }
20 |
21 | return {
22 | meta: {
23 | serverTime: new Date().toISOString(),
24 | error: errorDetails,
25 | ...opts?.meta
26 | },
27 | data
28 | } satisfies z.output
29 | }
30 |
31 | return {
32 | meta: {
33 | serverTime: new Date().toISOString(),
34 | ...opts?.meta
35 | },
36 | data
37 | } satisfies z.output
38 | }
39 |
40 | const zErrorOutput = z.object({
41 | code: z.string(),
42 | details: z.any()
43 | })
44 |
45 | const zMetaDataOutput = z.object({
46 | serverTime: z.string().datetime(),
47 | error: zErrorOutput.optional()
48 | })
49 |
50 | /**
51 | * Schema wrapper for a default api output schema
52 | *
53 | * @example
54 | * ```ts
55 | * zApiOutput(
56 | * z.object({
57 | * greeting: z.string()
58 | * })
59 | * )
60 | * ```
61 | * @returns
62 | * ```ts
63 | * z.object({
64 | * meta: z.object({
65 | * serverTime: z.string().datetime(),
66 | * ...
67 | * }),
68 | * data: z.object({
69 | * greeting: z.string()
70 | * })
71 | * })
72 | * ```
73 | */
74 | export const zApiOutput = (
75 | dataSchema: OutputSchema
76 | ) =>
77 | z.object({
78 | meta: zMetaDataOutput,
79 | data: dataSchema
80 | })
81 |
82 | const zGenericApiOutput = zApiOutput(z.any())
83 |
--------------------------------------------------------------------------------
/packages/core/src/app.integration.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | /* eslint-disable @typescript-eslint/no-explicit-any */
3 | import { describe, it, before, after } from 'node:test'
4 | import { Server } from './app.js'
5 | import { get } from './util/endpoint.js'
6 | import z from 'zod'
7 | import { zApiOutput, apiResponse } from './util/apiResponse.js'
8 | import { controller } from './util/controller.js'
9 | import sinon from 'sinon'
10 | import supertest from 'supertest'
11 | import express, { type Express } from 'express'
12 | import { expect } from 'chai'
13 |
14 | describe('app', () => {
15 | // Servertime is typically included in the api response, so we have to make sure the clock doesn't tick when checking responses
16 | let clock: sinon.SinonFakeTimers
17 | before(function () {
18 | clock = sinon.useFakeTimers()
19 | })
20 |
21 | after(function () {
22 | clock.restore()
23 | })
24 |
25 | let app: Express
26 | before(async () => {
27 | app = express()
28 | })
29 |
30 | it('Can bind to existing express app', async () => {
31 | const greetingController = controller('greetingController')
32 | .description('A controller which is responsible for greetings')
33 |
34 | greetingController.endpoint(
35 | get('/hello')
36 | .description('Say hello to everyone')
37 | .input({
38 | query: z.object({
39 | name: z.string().optional()
40 | })
41 | })
42 | .response(zApiOutput(z.string()))
43 | .handler(async ({ query }) => {
44 | return apiResponse(`Hello ${query.name ?? 'everyone'}!`)
45 | })
46 | )
47 |
48 | const server = new Server({
49 | controllers: [greetingController]
50 | },
51 | undefined,
52 | app
53 | )
54 |
55 | // server.start()
56 |
57 | const helloRes = await supertest(server.expressInstance).get('/hello?name=Evert') as any
58 |
59 | expect(helloRes.status).to.be.equal(200)
60 | expect(helloRes.body).to.deep.eq(apiResponse('Hello Evert!'))
61 | expect(helloRes.body.meta).to.not.have.key('error')
62 | })
63 | })
64 |
--------------------------------------------------------------------------------
/packages/core/src/middleware/errorHandler.unit.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | import { Server } from '../app.js'
3 | import supertest, { type Response } from 'supertest'
4 | import { expect } from 'chai'
5 | import { describe, it, before, after } from 'node:test'
6 | import { controller, get } from '../main.js'
7 | import { BadRequestError, ConflictError } from '@zhttp/errors'
8 |
9 | describe('errorHandler', () => {
10 | let http: Server
11 | before(async () => {
12 | const testController = controller('test-controller').endpoints([
13 | get('/unhandled-error').handler(() => {
14 | throw new Error('Unhandled error')
15 | }),
16 |
17 | get('/bad-request').handler(() => {
18 | throw new BadRequestError('Something went wrong :(')
19 | }),
20 |
21 | get('/unique-violation').handler(() => {
22 | throw new ConflictError('Something went wrong :(')
23 | })
24 | ])
25 |
26 | http = new Server(
27 | {
28 | controllers: [testController]
29 | },
30 | { port: undefined }
31 | )
32 |
33 | await http.start()
34 | })
35 |
36 | after(async () => {
37 | await http.stop()
38 | })
39 |
40 | it('Can handle unhandled errors', (t, done) => {
41 | supertest(http.expressInstance).get('/unhandled-error').expect(500, done)
42 | })
43 |
44 | it('Can handle bad requests', (t, done) => {
45 | supertest(http.expressInstance)
46 | .get('/bad-request')
47 | .expect(400)
48 | .end((err: any, res: Response) => {
49 | if (err != null) { done(err); return }
50 | expect(res.body).to.have.property('meta')
51 | expect(res.body.meta.error).to.have.property('code', 'BadRequestError')
52 | done()
53 | })
54 | })
55 |
56 | it('Can handle unique violations', (t, done) => {
57 | supertest(http.expressInstance)
58 | .get('/unique-violation')
59 | .expect(409)
60 | .end((err: any, res: Response) => {
61 | if (err != null) { done(err); return }
62 | expect(res.body.meta.error).to.have.property('code', 'ConflictError')
63 | done()
64 | })
65 | })
66 | })
67 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zhttp/core",
3 | "version": "1.1.3",
4 | "description": "A minimal, strongly typed HTTP library with Zod validation",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/evertdespiegeleer/zhttp.git"
8 | },
9 | "license": "MIT",
10 | "author": "Evert De Spiegeleer",
11 | "type": "module",
12 | "exports": {
13 | "import": {
14 | "types": "./dist/types/main.d.ts",
15 | "default": "./dist/esm/main.js"
16 | },
17 | "require": {
18 | "types": "./dist/types/main.d.ts",
19 | "default": "./dist/cjs/main.cjs"
20 | }
21 | },
22 | "types": "./dist/types/main.d.ts",
23 | "files": [
24 | "./dist",
25 | "./readme.md"
26 | ],
27 | "scripts": {
28 | "build": "npm run build:esm && npm run build:cjs",
29 | "build:cjs": "npx rollup ./dist/esm/main.js --file ./dist/cjs/main.cjs --format cjs",
30 | "build:esm": "tsc -p tsconfig.build.esm.json",
31 | "test": "node --test --test-reporter spec --test-reporter-destination stdout --test-reporter junit --test-reporter-destination=./test-report.xml --loader ts-node/esm ./src/**/*.test.ts"
32 | },
33 | "dependencies": {
34 | "@asteasolutions/zod-to-openapi": "^6.3.1",
35 | "@zhttp/errors": "1.1.3",
36 | "body-parser": "^1.20.2",
37 | "cookie-parser": "^1.4.6",
38 | "cors": "^2.8.5",
39 | "express": "^4.18.2",
40 | "prom-client": "^15.1.0",
41 | "uuid": "^9.0.1",
42 | "zod": "^3.22.4"
43 | },
44 | "devDependencies": {
45 | "@types/body-parser": "^1",
46 | "@types/chai": "^4.3.11",
47 | "@types/cookie-parser": "^1.4.7",
48 | "@types/cors": "^2.8.17",
49 | "@types/express": "^4.17.21",
50 | "@types/node": "^20.11.0",
51 | "@types/sinon": "^17.0.3",
52 | "@types/supertest": "^6.0.2",
53 | "@types/uuid": "^9.0.7",
54 | "chai": "^5.1.0",
55 | "sinon": "^17.0.1",
56 | "supertest": "^6.3.4",
57 | "ts-node": "^10.9.2",
58 | "typescript": "^4.4.4"
59 | },
60 | "peerDependencies": {
61 | "@zhttp/errors": "1.1.3",
62 | "zod": "^3.22.4"
63 | },
64 | "engines": {
65 | "node": "^20.10.x",
66 | "npm": "^10.2.3"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/packages/errors/src/main.ts:
--------------------------------------------------------------------------------
1 | import { type ZodIssue as ZValidationError } from 'zod'
2 |
3 | export class ZHTTPError extends Error {
4 | public http: number
5 | constructor (message: string) {
6 | super(message)
7 | this.http = 500
8 | this.name = this.constructor.name
9 | }
10 | }
11 |
12 | export class InternalServerError extends ZHTTPError {
13 | constructor () {
14 | super('Internal server error')
15 | this.http = 500
16 | }
17 | }
18 |
19 | export class ConfigError extends ZHTTPError {
20 | constructor (message: string) {
21 | super(`ConfigError: ${message}`)
22 | this.http = 500
23 | }
24 | }
25 |
26 | export class NotImplementedError extends ZHTTPError {
27 | constructor () {
28 | super('Not implemented')
29 | this.http = 500
30 | }
31 | }
32 |
33 | export class ValidationError extends ZHTTPError {
34 | constructor (
35 | message: string,
36 | public details?: ZValidationError[]
37 | ) {
38 | super(message)
39 |
40 | if (details != null && details.length > 0) {
41 | this.message = `${message}: ${details[0]?.message}`
42 | }
43 |
44 | this.http = 400
45 | }
46 | }
47 |
48 | export class BadRequestError extends ZHTTPError {
49 | constructor (message: string = 'Bad request') {
50 | super(message)
51 | this.http = 400
52 | }
53 | }
54 |
55 | /**
56 | * Intentionally does not accept metadata.
57 | * log additional info and do not leak info to the client
58 | */
59 | export class UnauthorizedError extends ZHTTPError {
60 | constructor () {
61 | super('Not authorized')
62 | this.http = 401
63 | }
64 | }
65 |
66 | /**
67 | * Intentionally does not accept metadata.
68 | * log additional info and do not leak info to the client
69 | */
70 | export class ForbiddenError extends ZHTTPError {
71 | constructor () {
72 | super('Forbidden')
73 | this.http = 403
74 | }
75 | }
76 |
77 | export class NotFoundError extends ZHTTPError {
78 | constructor (message = 'Not found') {
79 | super(message)
80 | this.http = 404
81 | }
82 | }
83 |
84 | export class ConflictError extends ZHTTPError {
85 | constructor (message = 'Conflict') {
86 | super(message)
87 | this.http = 409
88 | }
89 | }
90 |
91 | export class TooManyRequestsError extends ZHTTPError {
92 | constructor (message = 'Too many requests') {
93 | super(message)
94 | this.http = 429
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/docs/pages/docs/concepts/endpoints.mdx:
--------------------------------------------------------------------------------
1 | # Endpoints
2 |
3 | Endpoints are the building blocks of your API. They define the HTTP methods, paths, inputs, outputs, and behavior for each API call.
4 | As can be seen in the example below, endpoints are defined using a function chaining approach.
5 | These functions are further refered to as 'modulators'.
6 |
7 | ## Handlers and input/output typing
8 |
9 | Endpoint handlers are similar to plain express handlers, with some extra feastures for strict input and output typing. Rather than the typical 2 express handler parameters (`request` and `response`), there's three: `inputs`, `request`, `response`.
10 | `inputs` is a typed object containing everything defined in the input schema. `request` and `response` are simply the express `request` and `response` objects.
11 |
12 | Sending out a response is as simle as returning a value in the handler function. Handler responses, just like inputs, are strictly typed. When an output schema is specified and the handler function doesn't return a value that corresponds to said schema, typescript will comlain.
13 |
14 | By default, every endpoint will send its response with a `application/json` content type. This is typial for APIs, but there might be exceptions. You can override this content type using the `responseContentType` modulator.
15 |
16 | ### Using req/res directly
17 |
18 | You _can_ take input via the `request` object and use the `response` object to send a response in typical express manner, so migrating from express should be fairly trivial.
19 | However, beware that where you do this, you'll lose:
20 | - strict typing
21 | - output validation
22 | - AFTER-type middlewares
23 |
24 | ## Routing
25 | An important distinction between plain express and zhttp is that zhttp – consciously – doesn't really support routing. The paths you see in your endpoint definitions are the paths that can be called; No hidden prefixes.
26 |
27 | ## Basic endpoint example
28 |
29 | ```ts
30 | // ../../../../examples/concept-endpoint.ts
31 |
32 | import { z } from 'zod'
33 | import { endpoint, get } from '@zhttp/core'
34 |
35 | const zGreetingOutput = z.object({
36 | message: z.string()
37 | })
38 |
39 | const zGreetingInput = {
40 | query: z.object({
41 | name: z.string().optional()
42 | })
43 | }
44 |
45 | // ⬇ For common http methods (get, post, put, del), utility functions are available:
46 | get('/hello', 'getGreeting')
47 | .description('Say hello to everyone')
48 | .input(zGreetingInput)
49 | .response(zGreetingOutput)
50 | .handler(async ({ query }) => {
51 | return {
52 | message: `Hello ${query.name ?? 'everyone'}!`
53 | }
54 | })
55 |
56 | // `endpoint` is a generic function which supports every http method.
57 | endpoint('get', '/goodbye', 'getGoodbye')
58 | .description('Say goodbye to everyone')
59 | .input(zGreetingInput)
60 | .response(zGreetingOutput)
61 | .handler(async ({ query }) => {
62 | return {
63 | message: `Goodbye ${query.name ?? 'everyone'}!`
64 | }
65 | })
66 |
67 | ```
68 |
--------------------------------------------------------------------------------
/packages/core/src/util/controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | endpointToExpressHandler,
3 | type AnyEndpoint
4 | } from './endpoint.js'
5 | import { type Application } from 'express'
6 | import { type Middleware, MiddlewareTypes } from './middleware.js'
7 |
8 | interface ControllerOptions {
9 | name?: string
10 | description?: string
11 | middlewares: Middleware[]
12 | endpoints: AnyEndpoint[]
13 | }
14 |
15 | export class Controller {
16 | constructor (private readonly options: ControllerOptions) {}
17 |
18 | /** Add a description to the controller */
19 | description (description: typeof this.options.description) {
20 | this.options.description = description
21 | return this
22 | }
23 |
24 | /** Add an endpoint to the controller */
25 | endpoint (endpoint: AnyEndpoint) {
26 | this.options.endpoints.push(endpoint)
27 | return this
28 | }
29 |
30 | /** Add an array of endpoints to the controller */
31 | endpoints (endpoints: AnyEndpoint[]) {
32 | this.options.endpoints.push(...endpoints)
33 | return this
34 | }
35 |
36 | /** Add a middleware to the controller */
37 | middleware (middleware: Middleware) {
38 | this.options.middlewares.push(middleware)
39 | return this
40 | }
41 |
42 | /** Add an array of middlewares to the controller */
43 | middlewares (middlewares: Middleware[]) {
44 | this.options.middlewares.push(...middlewares)
45 | return this
46 | }
47 |
48 | getEndpoints () {
49 | return this.options.endpoints
50 | }
51 |
52 | getMiddlewares () {
53 | return this.options.middlewares
54 | }
55 |
56 | getName () {
57 | return this.options.name
58 | }
59 |
60 | getDescription () {
61 | return this.options.description
62 | }
63 | }
64 |
65 | export const controller = (name: string) =>
66 | new Controller({
67 | name,
68 | endpoints: [],
69 | middlewares: []
70 | })
71 |
72 | export const bindControllerToApp = (
73 | controller: Controller,
74 | app: Application
75 | ) => {
76 | const controllerBeforeMiddlewares = controller
77 | .getMiddlewares()
78 | .filter((m) => m.type === MiddlewareTypes.BEFORE)
79 | const controllerAfterMiddlewares = controller
80 | .getMiddlewares()
81 | .filter((m) => m.type === MiddlewareTypes.AFTER)
82 |
83 | controller.getEndpoints().forEach((endpoint) => {
84 | app[endpoint.getMethod()](
85 | endpoint.getPath(),
86 | // Controller before middleware
87 | ...controllerBeforeMiddlewares.map((m) => m.handler),
88 |
89 | // Endpoint before middleware
90 | ...(endpoint.getMiddlewares() ?? [])
91 | .filter((m) => m.type === MiddlewareTypes.BEFORE)
92 | .map((m) => m.handler),
93 |
94 | // Endpoint handler
95 | endpointToExpressHandler(endpoint),
96 |
97 | // Endpoint after middleware
98 | ...(endpoint.getMiddlewares() ?? [])
99 | .filter((m) => m.type === MiddlewareTypes.AFTER)
100 | .map((m) => m.handler),
101 |
102 | // Controller after middleware
103 | ...controllerAfterMiddlewares.map((m) => m.handler)
104 | )
105 | })
106 | }
107 |
--------------------------------------------------------------------------------
/docs/pages/docs/errors.mdx:
--------------------------------------------------------------------------------
1 | # Errors
2 |
3 | `zhttp` has a [built in error handler](https://github.com/evertdespiegeleer/zhttp/packages/core/src/middleware/errorHandler.ts), which will catch any sort of error thrown in an endpoint or middleware.
4 |
5 | ## `@zhttp/errors`
6 |
7 | Any type of unknown error will be logged and will result in a `InternalServerError` response (http status code 500).
8 |
9 | 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.
10 |
11 | ```ts
12 | // ../../../examples/concepts-errors.ts
13 |
14 | import { z } from 'zod'
15 | import { controller, get } from '@zhttp/core'
16 | import { NotFoundError } from '@zhttp/errors'
17 |
18 | // Let's presume we're talking to some sort of database
19 | const db: any = undefined
20 |
21 | export const vegetablesController = controller('vegetables')
22 |
23 | vegetablesController.endpoint(
24 | get('/vegetables/:vegetableId', 'getVegetableDetails')
25 | .input({
26 | params: z.object({
27 | vegetableId: z.string().uuid()
28 | })
29 | })
30 | .response(z.object({
31 | message: z.string()
32 | }))
33 | .handler(async ({ params: { vegetableId } }) => {
34 | const vegetableDetails = await db.getVegetableById(vegetableId)
35 | if (vegetableDetails == null) {
36 | // ✨✨✨✨✨✨✨✨✨
37 | throw new NotFoundError(`Vegetable with id ${vegetableId} does not exist`)
38 | // ⬆ This will result in a 404 response
39 | // ✨✨✨✨✨✨✨✨✨
40 | }
41 | return vegetableDetails
42 | })
43 | )
44 |
45 | ```
46 |
47 | ## Validation errors
48 |
49 | 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.
50 |
51 | If an error is detected as part of the request output validation, an `InternalServerError` is returned, and error message is logged.
52 |
53 | ```ts
54 | // ../../../examples/validation-errors.ts
55 |
56 | import { z } from 'zod'
57 | import { controller, get } from '@zhttp/core'
58 |
59 | export const validationExampleController = controller('validationExample')
60 |
61 | validationExampleController.endpoint(
62 | get('/hello', 'getGreeting')
63 | .input({
64 | query: z.object({
65 | // If a name shorter than 5 characcters is provided, then the server will responde with a ValidationError.
66 | name: z.string().min(5)
67 | })
68 | })
69 | .response(z.object({
70 | message: z.string()
71 | }))
72 | .handler(async ({ query }) => {
73 | return {
74 | message: `Hello ${query.name ?? 'everyone'}!`
75 | }
76 | })
77 | )
78 |
79 | validationExampleController.endpoint(
80 | get('/goodbye', 'getGoodbye')
81 | .input({
82 | query: z.object({
83 | name: z.string().optional()
84 | })
85 | })
86 | .response(z.object({
87 | message: z.string()
88 | }))
89 | .handler(async ({ query }) => {
90 | return {
91 | thisKeyShouldntBeHere: 'noBueno'
92 | } as any
93 | // ⬆ As zhttp is typesafe, you actually have to manually $x&! up the typing
94 | // to provoke an output validation error :)
95 | // This will result in an InternalServerError.
96 | })
97 | )
98 |
99 | ```
--------------------------------------------------------------------------------
/packages/core/src/util/middleware.ts:
--------------------------------------------------------------------------------
1 | import { type RequestHandler, type ErrorRequestHandler, type Request, type Response, type NextFunction } from 'express'
2 | import { isPromise } from 'node:util/types'
3 | import { loggerInstance } from './logger.js'
4 |
5 | export enum MiddlewareTypes {
6 | BEFORE,
7 | AFTER,
8 | }
9 |
10 | export type AsyncRequestHandler = (
11 | ...args: Parameters
12 | ) => Promise>
13 |
14 | export type AsyncErrorRequestHandler = (
15 | ...args: Parameters
16 | ) => Promise>
17 |
18 | export type MiddlewareHandler =
19 | | RequestHandler
20 | | ErrorRequestHandler
21 | | AsyncRequestHandler
22 | | AsyncErrorRequestHandler
23 |
24 | interface MiddlewareProps {
25 | name?: string
26 | handler: MiddlewareHandler
27 | type: MiddlewareTypes
28 | }
29 |
30 | const log = loggerInstance.logger('zhttp:middlewareHandler')
31 |
32 | function middlewareWrapper (
33 | middlewareProps: MiddlewareProps
34 | ) {
35 | const middlewareHandler = middlewareProps.handler
36 | if (middlewareHandler.length === 3) {
37 | return async function (req: Request, res: Response, next: NextFunction) {
38 | if (res.headersSent) {
39 | log.info(
40 | `Exiting middleware ${middlewareProps.name} early, headers already sent`
41 | )
42 | next(); return
43 | }
44 | try {
45 | if (isPromise(middlewareHandler)) {
46 | await new Promise((resolve, reject) => {
47 | const localNext: NextFunction = (...params) => {
48 | resolve(...params)
49 | }
50 | const m = middlewareHandler as AsyncRequestHandler
51 | m(req, res, localNext).then(resolve).catch(reject)
52 | })
53 | .then(next)
54 | .catch(next); return
55 | }
56 | const m = middlewareHandler as RequestHandler
57 | m(req, res, next)
58 | } catch (err) {
59 | next(err)
60 | }
61 | } as RequestHandler
62 | }
63 |
64 | return async function (
65 | prevError: Error,
66 | req: Request,
67 | res: Response,
68 | next: NextFunction
69 | ) {
70 | if (res.headersSent) {
71 | log.info(
72 | `Exiting middleware ${middlewareProps.name} early, headers already sent`
73 | )
74 | next(); return
75 | }
76 | try {
77 | if (isPromise(middlewareHandler)) {
78 | await new Promise((resolve, reject) => {
79 | const localNext: NextFunction = (...params) => {
80 | resolve(...params)
81 | }
82 | const m = middlewareHandler as AsyncErrorRequestHandler
83 | m(prevError, req, res, localNext).then(resolve).catch(reject)
84 | })
85 | .then(next)
86 | .catch(next); return
87 | }
88 |
89 | const m = middlewareHandler as ErrorRequestHandler
90 | m(prevError, req, res, next)
91 | } catch (err) {
92 | next(err)
93 | }
94 | } as ErrorRequestHandler
95 | }
96 |
97 | export class Middleware {
98 | constructor (private readonly options: MiddlewareProps) {}
99 |
100 | get type () {
101 | return this.options.type
102 | }
103 |
104 | get handler () {
105 | return middlewareWrapper(this.options)
106 | }
107 | }
108 |
109 | export const middleware = (
110 | options: MiddlewareProps
111 | ) => new Middleware(options)
112 |
--------------------------------------------------------------------------------
/packages/core/src/util/controller.unit.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | /* eslint-disable @typescript-eslint/no-explicit-any */
3 | import { describe, it, before, after } from 'node:test'
4 | import { get } from './endpoint.js'
5 | import z from 'zod'
6 | import { zApiOutput, apiResponse } from './apiResponse.js'
7 | import { bindControllerToApp, controller } from './controller.js'
8 | import sinon from 'sinon'
9 | import supertest from 'supertest'
10 | import express, { type Express } from 'express'
11 | import { expect } from 'chai'
12 |
13 | describe('controller', () => {
14 | // Servertime is typically included in the api response, so we have to make sure the clock doesn't tick when checking responses
15 | let clock: sinon.SinonFakeTimers
16 | before(function () {
17 | clock = sinon.useFakeTimers()
18 | })
19 |
20 | after(function () {
21 | clock.restore()
22 | })
23 |
24 | let app: Express
25 | before(async () => {
26 | app = express()
27 | })
28 |
29 | // This test doesn't actually expect anything, it's about the typing of the test itself and not running into errors when defining it
30 | it('Can be defined with correct typing', async () => {
31 | controller('greetingController')
32 | .description('A controller which is responsible for greetings')
33 | .endpoints([
34 | get('/hello')
35 | .description('Say hello to everyone')
36 | .input({
37 | query: z.object({
38 | name: z.string().optional()
39 | })
40 | })
41 | .response(zApiOutput(z.string()))
42 | .handler(async ({ query }) => {
43 | return apiResponse(`Hello ${query.name ?? 'everyone'}!`)
44 | })
45 | ])
46 | })
47 |
48 | it('Can correctly bind to an express app', async () => {
49 | const greetingController = controller('greetingController')
50 | .description('A controller which is responsible for greetings')
51 | .endpoints([
52 | get('/hello')
53 | .description('Say hello to everyone')
54 | .input({
55 | query: z.object({
56 | name: z.string().min(5).optional()
57 | })
58 | })
59 | .response(zApiOutput(z.string()))
60 | .handler(async ({ query }) => {
61 | return apiResponse(`Hello ${query.name ?? 'everyone'}!`)
62 | }),
63 |
64 | get('/goodbye')
65 | .description('Say goodbye to everyone')
66 | .input({
67 | query: z.object({
68 | name: z.string().min(5).optional()
69 | })
70 | })
71 | .response(zApiOutput(z.string()))
72 | .handler(async ({ query }) => {
73 | return apiResponse(`Goodbye ${query.name ?? 'everyone'}!`)
74 | })
75 | ])
76 |
77 | bindControllerToApp(greetingController, app)
78 |
79 | // eslint-disable-next-line @typescript-eslint/await-thenable
80 | const helloRes = await supertest(app).get('/hello?name=Evert') as any
81 |
82 | expect(helloRes.status).to.be.equal(200)
83 | expect(helloRes.body).to.deep.eq(apiResponse('Hello Evert!'))
84 | expect(helloRes.body.meta).to.not.have.key('error')
85 |
86 | // eslint-disable-next-line @typescript-eslint/await-thenable
87 | const goodbyeRes = await supertest(app).get('/goodbye?name=Evert') as any
88 |
89 | expect(goodbyeRes.status).to.be.equal(200)
90 | expect(goodbyeRes.body).to.deep.eq(apiResponse('Goodbye Evert!'))
91 | expect(goodbyeRes.body.meta).to.not.have.key('error')
92 | })
93 | })
94 |
--------------------------------------------------------------------------------
/packages/core/src/oas.ts:
--------------------------------------------------------------------------------
1 | import {
2 | OpenAPIRegistry,
3 | OpenApiGeneratorV3,
4 | extendZodWithOpenApi
5 | } from '@asteasolutions/zod-to-openapi'
6 | import { type AnyEndpoint } from './util/endpoint.js'
7 | import z from 'zod'
8 | import { type Controller } from './util/controller.js'
9 |
10 | extendZodWithOpenApi(z)
11 |
12 | function pathToTitle (input: string): string {
13 | return (
14 | input
15 | // Split the string on non-alphanumeric characters.
16 | .split(/[^a-zA-Z0-9]/)
17 | // Map each word, capitalizing the first letter of each word except the first.
18 | .map((word) => {
19 | return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
20 | })
21 | // Join the words back together.
22 | .join('')
23 | )
24 | }
25 |
26 | export type OASInfo = Partial<
27 | Parameters[0]['info']
28 | >
29 | export type EndpointOasInfo = Parameters['0']
30 | interface TagObject {
31 | name: string
32 | description?: string
33 | }
34 |
35 | const zAnyResponse = z.any().openapi('UntypedResponse')
36 |
37 | export class Oas {
38 | private readonly registry: OpenAPIRegistry
39 | private document:
40 | | ReturnType
41 | | undefined
42 |
43 | private readonly tags: TagObject[]
44 |
45 | constructor (private readonly oasInfo: OASInfo | undefined) {
46 | this.registry = new OpenAPIRegistry()
47 | this.tags = []
48 | }
49 |
50 | addController (controller: Controller) {
51 | controller.getEndpoints().forEach((endpoint) => {
52 | this.addEndpoint(endpoint, controller.getName())
53 | })
54 |
55 | // Create tag and description
56 | const name = controller.getName()
57 | const description = controller.getDescription()
58 | if (name == null) return
59 | const tagObj = this.tags.find((tagObj) => tagObj.name === name)
60 | if (tagObj == null) {
61 | this.tags.push({
62 | name,
63 | description
64 | })
65 | } else {
66 | tagObj.description = description
67 | }
68 | }
69 |
70 | private addEndpoint (endpoint: AnyEndpoint, controllerName?: string) {
71 | const name = endpoint.getName()
72 | const backupName = `${endpoint.getMethod().toLowerCase()}${pathToTitle(
73 | endpoint.getPath()
74 | )}`
75 | const bodyValidationSchema = endpoint.getInputValidationSchema()?.shape.body
76 | this.registry.registerPath({
77 | operationId: `${controllerName ?? ''}${
78 | name ?? backupName
79 | }`,
80 | summary: name,
81 | description: endpoint.getDescription(),
82 | method: endpoint.getMethod(),
83 | path: endpoint.getPath().replace(/:(\w+)/g, '{$1}'),
84 | tags: controllerName != null ? [controllerName] : undefined,
85 | request: {
86 | params: endpoint.getInputValidationSchema()?.shape.params,
87 | query: endpoint.getInputValidationSchema()?.shape.query,
88 | body:
89 | bodyValidationSchema != null
90 | ? {
91 | content: {
92 | 'application/json': {
93 | schema: bodyValidationSchema
94 | }
95 | }
96 | }
97 | : undefined
98 | },
99 | responses: {
100 | 200: {
101 | description: 'Response body',
102 | content: {
103 | [endpoint.getResponseContentType()]: {
104 | schema: endpoint.getResponseValidationSchema() ?? zAnyResponse
105 | }
106 | }
107 | }
108 | },
109 | ...endpoint.getOasInfo()
110 | })
111 | }
112 |
113 | getJsonSpec () {
114 | if (this.document == null) {
115 | const generator = new OpenApiGeneratorV3(this.registry.definitions)
116 | this.document = generator.generateDocument({
117 | openapi: '3.0.0',
118 | tags: this.tags,
119 | info: {
120 | title: 'API',
121 | version: '1.0.0',
122 | ...this.oasInfo
123 | },
124 | servers: [{ url: '/' }]
125 | })
126 | }
127 |
128 | return JSON.stringify(this.document)
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
2 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node
3 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node
4 |
5 | ### macOS ###
6 | # General
7 | .DS_Store
8 | .AppleDouble
9 | .LSOverride
10 |
11 | # Icon must end with two \r
12 | Icon
13 |
14 |
15 | # Thumbnails
16 | ._*
17 |
18 | # Files that might appear in the root of a volume
19 | .DocumentRevisions-V100
20 | .fseventsd
21 | .Spotlight-V100
22 | .TemporaryItems
23 | .Trashes
24 | .VolumeIcon.icns
25 | .com.apple.timemachine.donotpresent
26 |
27 | # Directories potentially created on remote AFP share
28 | .AppleDB
29 | .AppleDesktop
30 | Network Trash Folder
31 | Temporary Items
32 | .apdisk
33 |
34 | ### macOS Patch ###
35 | # iCloud generated files
36 | *.icloud
37 |
38 | ### Node ###
39 | # Logs
40 | logs
41 | *.log
42 | npm-debug.log*
43 | yarn-debug.log*
44 | yarn-error.log*
45 | lerna-debug.log*
46 | .pnpm-debug.log*
47 |
48 | # Diagnostic reports (https://nodejs.org/api/report.html)
49 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
50 |
51 | # Runtime data
52 | pids
53 | *.pid
54 | *.seed
55 | *.pid.lock
56 |
57 | # Directory for instrumented libs generated by jscoverage/JSCover
58 | lib-cov
59 |
60 | # Coverage directory used by tools like istanbul
61 | coverage
62 | *.lcov
63 |
64 | # nyc test coverage
65 | .nyc_output
66 |
67 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
68 | .grunt
69 |
70 | # Bower dependency directory (https://bower.io/)
71 | bower_components
72 |
73 | # node-waf configuration
74 | .lock-wscript
75 |
76 | # Compiled binary addons (https://nodejs.org/api/addons.html)
77 | build/Release
78 |
79 | # Dependency directories
80 | node_modules/
81 | jspm_packages/
82 |
83 | # Snowpack dependency directory (https://snowpack.dev/)
84 | web_modules/
85 |
86 | # TypeScript cache
87 | *.tsbuildinfo
88 |
89 | # Optional npm cache directory
90 | .npm
91 |
92 | # Optional eslint cache
93 | .eslintcache
94 |
95 | # Optional stylelint cache
96 | .stylelintcache
97 |
98 | # Microbundle cache
99 | .rpt2_cache/
100 | .rts2_cache_cjs/
101 | .rts2_cache_es/
102 | .rts2_cache_umd/
103 |
104 | # Optional REPL history
105 | .node_repl_history
106 |
107 | # Output of 'npm pack'
108 | *.tgz
109 |
110 | # Yarn Integrity file
111 | .yarn-integrity
112 |
113 | # dotenv environment variable files
114 | .env
115 | .env.development.local
116 | .env.test.local
117 | .env.production.local
118 | .env.local
119 |
120 | # parcel-bundler cache (https://parceljs.org/)
121 | .cache
122 | .parcel-cache
123 |
124 | # Next.js build output
125 | .next
126 | out
127 |
128 | # Nuxt.js build / generate output
129 | .nuxt
130 | dist
131 |
132 | # Gatsby files
133 | .cache/
134 | # Comment in the public line in if your project uses Gatsby and not Next.js
135 | # https://nextjs.org/blog/next-9-1#public-directory-support
136 | # public
137 |
138 | # vuepress build output
139 | .vuepress/dist
140 |
141 | # vuepress v2.x temp and cache directory
142 | .temp
143 |
144 | # Docusaurus cache and generated files
145 | .docusaurus
146 |
147 | # Serverless directories
148 | .serverless/
149 |
150 | # FuseBox cache
151 | .fusebox/
152 |
153 | # DynamoDB Local files
154 | .dynamodb/
155 |
156 | # TernJS port file
157 | .tern-port
158 |
159 | # Stores VSCode versions used for testing VSCode extensions
160 | .vscode-test
161 |
162 | # yarn v2
163 | .yarn/cache
164 | .yarn/unplugged
165 | .yarn/build-state.yml
166 | .yarn/install-state.gz
167 | .pnp.*
168 |
169 | ### Node Patch ###
170 | # Serverless Webpack directories
171 | .webpack/
172 |
173 | # Optional stylelint cache
174 |
175 | # SvelteKit build / generate output
176 | .svelte-kit
177 |
178 | ### VisualStudioCode ###
179 | .vscode/*
180 | !.vscode/settings.json
181 | !.vscode/tasks.json
182 | !.vscode/launch.json
183 | !.vscode/extensions.json
184 | !.vscode/*.code-snippets
185 |
186 | # Local History for Visual Studio Code
187 | .history/
188 |
189 | # Built Visual Studio Code Extensions
190 | *.vsix
191 |
192 | ### VisualStudioCode Patch ###
193 | # Ignore all local history of files
194 | .history
195 | .ionide
196 |
197 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node
198 |
199 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
200 |
201 | test-report.xml
--------------------------------------------------------------------------------
/packages/core/src/app.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-types */
2 | import express, { type Application } from 'express'
3 | import { type Server as NodeHttpServer, createServer } from 'node:http'
4 | import cors from 'cors'
5 | import bodyParser from 'body-parser'
6 | import cookieParser from 'cookie-parser'
7 | import { type Controller, bindControllerToApp } from './util/controller.js'
8 | import { type Middleware, MiddlewareTypes } from './util/middleware.js'
9 | import { errorHandlerMiddleware } from './middleware/errorHandler.js'
10 | import { metricMiddleware } from './middleware/metrics.js'
11 | import { type OASInfo, Oas } from './oas.js'
12 | import { BadRequestError } from '@zhttp/errors'
13 | import { type ILogger, defaultLogger, loggerInstance } from './util/logger.js'
14 |
15 | interface RoutingOptions {
16 | controllers?: Controller[]
17 | middlewares?: Middleware[]
18 | }
19 |
20 | interface IHTTPOptions {
21 | port?: number
22 | allowedOrigins?: string[]
23 | bypassAllowedOrigins?: boolean
24 | trustProxy?: boolean
25 | oasInfo?: OASInfo
26 | logger?: ILogger
27 | bodyParserOptions?: bodyParser.OptionsJson
28 | }
29 |
30 | export let oasInstance: Oas
31 |
32 | export class Server {
33 | private readonly app: Application
34 | private httpServer: NodeHttpServer
35 | private readonly appLogger
36 |
37 | constructor (
38 | private readonly options: RoutingOptions = {},
39 | private readonly httpOptions: IHTTPOptions = {},
40 | private readonly externalApplication?: Application
41 | ) {
42 | loggerInstance.logger = httpOptions.logger ?? defaultLogger
43 | this.appLogger = loggerInstance.logger('zhttp')
44 |
45 | oasInstance = new Oas(httpOptions.oasInfo)
46 |
47 | // Apply defaults
48 | this.httpOptions = {
49 | trustProxy: true,
50 | ...this.httpOptions
51 | }
52 |
53 | this.app = this.externalApplication ?? express()
54 | this.httpServer = createServer(this.app)
55 |
56 | this.app.set('trust proxy', this.httpOptions.trustProxy)
57 | this.app.use(bodyParser.json(httpOptions.bodyParserOptions))
58 | this.app.use(
59 | cors({
60 | credentials: true,
61 | origin: (origin: string | undefined, callback: CallableFunction) => {
62 | if (origin == null || origin === 'null') return callback(null, true)
63 | const allowedOrigins = this.httpOptions.allowedOrigins ?? []
64 | if (
65 | origin == null ||
66 | allowedOrigins.includes(origin) ||
67 | this.httpOptions.bypassAllowedOrigins === true
68 | ) {
69 | callback(null, true)
70 | } else {
71 | this.appLogger.warn(`Origin ${origin} not allowed`)
72 | callback(new BadRequestError('Not allowed by CORS'))
73 | }
74 | }
75 | })
76 | )
77 | this.app.use(cookieParser())
78 |
79 | // Set default middlewares
80 | this.options.middlewares = [
81 | ...(this.options.middlewares ?? []),
82 | metricMiddleware,
83 | errorHandlerMiddleware
84 | ]
85 |
86 | // run all global before middlewares
87 | this.options.middlewares
88 | ?.filter((m) => m.type === MiddlewareTypes.BEFORE)
89 | .forEach((middleware) => {
90 | this.app.use(middleware.handler)
91 | })
92 |
93 | // Bind all controllers
94 | this.options.controllers?.forEach((controller) => {
95 | // Bind all controllers to the express app
96 | bindControllerToApp(controller, this.app)
97 | oasInstance.addController(controller)
98 | })
99 |
100 | // run all global after middlewares
101 | this.options.middlewares
102 | ?.filter((m) => m.type === MiddlewareTypes.AFTER)
103 | .forEach((middleware) => {
104 | this.app.use(middleware.handler)
105 | })
106 | }
107 |
108 | get expressInstance () {
109 | return this.app
110 | }
111 |
112 | get oasInstance () {
113 | return oasInstance
114 | }
115 |
116 | get server () {
117 | return this.httpServer
118 | }
119 |
120 | async start () {
121 | this.httpServer = this.httpServer.listen(this.httpOptions.port, () => {
122 | this.appLogger.info(
123 | `HTTP server listening on port ${this.httpOptions.port}`
124 | )
125 | })
126 | }
127 |
128 | async stop () {
129 | if (this.httpServer != null) {
130 | this.httpServer.close()
131 | this.appLogger.info('HTTP server stopped')
132 | }
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/packages/core/src/util/endpoint.unit.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | /* eslint-disable @typescript-eslint/no-explicit-any */
3 | import { describe, it, before, after } from 'node:test'
4 | import { endpoint, endpointToExpressHandler } from './endpoint.js'
5 | import z from 'zod'
6 | import { zApiOutput, apiResponse } from './apiResponse.js'
7 | import { type Response, type Request, type NextFunction } from 'express'
8 | import sinon from 'sinon'
9 | import { NotImplementedError, ValidationError } from '@zhttp/errors'
10 | import { expect } from 'chai'
11 |
12 | const promisifyExpressHandler = async (
13 | handler: (req: Request, res: Response, next: NextFunction) => unknown,
14 | req: Request
15 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
16 | ) =>
17 | await new Promise<{ response: any, error: any }>((resolve) => {
18 | let response: any
19 | let error: any
20 |
21 | const mockRes = {
22 | send: (resObject: unknown) => {
23 | response = resObject
24 | resolve({ response, error })
25 | },
26 | header: () => {}
27 | } as unknown as Response
28 |
29 | const mockNext = ((err: Error) => {
30 | error = err
31 | resolve({ response, error })
32 | }) as unknown as NextFunction
33 |
34 | handler(req, mockRes, mockNext)
35 | })
36 |
37 | describe('endpoint', () => {
38 | // Servertime is typically included in the api response, so we have to make sure the clock doesn't tick when checking responses
39 | let clock: sinon.SinonFakeTimers
40 | before(function () {
41 | clock = sinon.useFakeTimers()
42 | })
43 |
44 | after(function () {
45 | clock.restore()
46 | })
47 |
48 | // This test doesn't actually expect anything, it's about the typing of the test itself and not running into errors when defining it
49 | it('Can be defined with correct typing', async () => {
50 | endpoint('get', '/hello', 'hello')
51 | .description('Say hello to everyone')
52 | .input({
53 | query: z.object({
54 | name: z.string().optional()
55 | })
56 | })
57 | .response(zApiOutput(z.string()))
58 | .handler(async ({ query }) => {
59 | return apiResponse(`Hello ${query.name ?? 'everyone'}!`)
60 | })
61 | })
62 |
63 | it('Can be run as an Express handler', async () => {
64 | const helloEndpoint = endpoint('get', '/hello', 'hello')
65 | .description('Say hello to everyone')
66 | .input({
67 | query: z.object({
68 | name: z.string().optional()
69 | })
70 | })
71 | .response(zApiOutput(z.string()))
72 | .handler(async ({ query }) => {
73 | return apiResponse(`Hello ${query.name ?? 'everyone'}!`)
74 | })
75 |
76 | const expressHandler = endpointToExpressHandler(helloEndpoint)
77 |
78 | const mockReq = {
79 | query: {
80 | name: 'Satan'
81 | }
82 | } as unknown as Request
83 |
84 | const { error, response } = await promisifyExpressHandler(
85 | expressHandler,
86 | mockReq
87 | )
88 |
89 | expect(response).to.deep.eq(apiResponse('Hello Satan!'))
90 | expect(error).to.be.undefined
91 | })
92 |
93 | it('Can throw a validation error', async () => {
94 | const helloEndpoint = endpoint('get', '/hello', 'hello')
95 | .description('Say hello to everyone')
96 | .input({
97 | query: z.object({
98 | name: z.string().min(10)
99 | })
100 | })
101 | .response(zApiOutput(z.string()))
102 | .handler(async ({ query }) => {
103 | return apiResponse(`Hello ${query.name ?? 'everyone'}!`)
104 | })
105 |
106 | const expressHandler = endpointToExpressHandler(helloEndpoint)
107 |
108 | const mockReq = {
109 | query: {
110 | name: 'Jos'
111 | }
112 | } as unknown as Request
113 |
114 | const { error, response } = await promisifyExpressHandler(
115 | expressHandler,
116 | mockReq
117 | )
118 |
119 | expect(error).to.be.instanceOf(ValidationError)
120 | expect(response).to.be.undefined
121 | })
122 |
123 | it('Can throw a not implemented error', async () => {
124 | const helloEndpoint = endpoint('get', '/hello', 'hello')
125 | .description('Say hello to everyone')
126 | .input({
127 | query: z.object({
128 | name: z.string()
129 | })
130 | })
131 | .response(zApiOutput(z.string()))
132 |
133 | const expressHandler = endpointToExpressHandler(helloEndpoint)
134 |
135 | const mockReq = {
136 | query: {
137 | name: 'Jos'
138 | }
139 | } as unknown as Request
140 |
141 | const { error, response } = await promisifyExpressHandler(
142 | expressHandler,
143 | mockReq
144 | )
145 |
146 | expect(error).to.be.instanceOf(NotImplementedError)
147 | expect(response).to.be.undefined
148 | })
149 | })
150 |
--------------------------------------------------------------------------------
/.github/workflows/push.yml:
--------------------------------------------------------------------------------
1 | name: push
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request_target:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | authorize:
13 | environment:
14 | ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && 'external' || 'internal' }}
15 | runs-on: ubuntu-latest
16 | steps:
17 | - run: true
18 |
19 | lint:
20 | runs-on: ubuntu-latest
21 | steps:
22 | - name: Checkout code
23 | uses: actions/checkout@v3
24 | with:
25 | fetch-depth: 0
26 | ref: ${{ github.event.pull_request.head.ref }}
27 |
28 | - name: Set Node.js 20.11.0
29 | uses: actions/setup-node@v3
30 | with:
31 | node-version: 20.11.0
32 |
33 | - name: Install Dependencies
34 | run: npm ci
35 |
36 | - name: Lint
37 | run: npm run lint
38 |
39 | build:
40 | runs-on: ubuntu-latest
41 | steps:
42 | - name: Checkout code
43 | uses: actions/checkout@v3
44 | with:
45 | fetch-depth: 0
46 | ref: ${{ github.event.pull_request.head.ref }}
47 |
48 | - name: Set Node.js 20.11.0
49 | uses: actions/setup-node@v3
50 | with:
51 | node-version: 20.11.0
52 |
53 | - name: Install Dependencies
54 | run: npm ci
55 |
56 | - name: Build
57 | run: npm run build
58 |
59 | - name: Upload build artifacts
60 | uses: actions/upload-artifact@v4
61 | with:
62 | name: build
63 | path: ./packages/*/dist
64 |
65 | test:
66 | runs-on: ubuntu-latest
67 | needs:
68 | - build
69 | permissions:
70 | checks: write
71 | pull-requests: write
72 | steps:
73 | - name: Checkout code
74 | uses: actions/checkout@v3
75 | with:
76 | fetch-depth: 0
77 | ref: ${{ github.event.pull_request.head.ref }}
78 |
79 | - name: Set Node.js 20.11.0
80 | uses: actions/setup-node@v3
81 | with:
82 | node-version: 20.11.0
83 |
84 | - name: Download build artifacts
85 | uses: actions/download-artifact@v4
86 | with:
87 | name: build
88 | path: ./packages
89 |
90 | - name: Install Dependencies
91 | run: npm ci
92 |
93 | - name: Test
94 | id: test
95 | run: docker compose run dev npm test
96 |
97 | - name: Upload test artifacts
98 | uses: actions/upload-artifact@v4
99 | with:
100 | name: test
101 | path: ./packages/*/test-report.xml
102 |
103 | - name: Test Report
104 | if: always()
105 | uses: EnricoMi/publish-unit-test-result-action@v2
106 | with:
107 | files: ./packages/*/test-report.xml
108 |
109 | pr-prerelease:
110 | if: github.event_name == 'pull_request_target' && github.event.action != 'closed'
111 | runs-on: ubuntu-latest
112 | needs:
113 | - lint
114 | - build
115 | - authorize
116 | # - test
117 | steps:
118 | - name: Checkout code
119 | uses: actions/checkout@v3
120 | with:
121 | fetch-depth: 0
122 | ref: ${{ github.event.pull_request.head.ref }}
123 |
124 | - name: Set Node.js 20.11.0
125 | uses: actions/setup-node@v3
126 | with:
127 | node-version: 20.11.0
128 |
129 | - name: Download build artifacts
130 | uses: actions/download-artifact@v4
131 | with:
132 | name: build
133 | path: ./packages
134 |
135 | - name: Install Dependencies
136 | run: npm ci
137 |
138 | - name: Extract branch name
139 | shell: bash
140 | run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
141 | id: extract_branch
142 |
143 | - name: Configure Git
144 | uses: fregante/setup-git-user@v1
145 |
146 | - name: Version the packages
147 | id: version
148 | run: |
149 | ./scripts/version.sh prerelease --preid $(echo "${{ steps.extract_branch.outputs.branch }}" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/-$//')
150 | export VERSION=$(cat package.json | jq -r '.version')
151 | echo "version=$VERSION" >> $GITHUB_OUTPUT
152 |
153 | - name: Publish to npm
154 | run: |
155 | echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_PUBLISH_TOKEN}}" >> ~/.npmrc
156 | echo "registry=https://registry.npmjs.org" >> ~/.npmrc
157 | npm publish --workspaces --access=public --tag prerelease
158 | rm -f ~/.npmrc
159 |
160 | - name: Commit and push changes
161 | run: |
162 | git commit -am "chore(version): 📦 prerelease version bump to ${{ steps.version.outputs.version }}"
163 | git push
164 | auto-merge:
165 | needs:
166 | - authorize
167 | - build
168 | - lint
169 | - pr-prerelease
170 | - test
171 | if: ${{ github.event_name == 'pull_request' }}
172 | runs-on: ubuntu-latest
173 | steps:
174 | - uses: actions/checkout@v2
175 | - uses: ahmadnassri/action-dependabot-auto-merge@v2
176 | with:
177 | github-token: ${{ secrets.automergetoken }}
178 |
--------------------------------------------------------------------------------
/packages/core/src/util/endpoint.ts:
--------------------------------------------------------------------------------
1 | import { type ZodRawShape, type ZodString, type ZodObject, type ZodSchema } from 'zod'
2 | import z from 'zod'
3 | import type { NextFunction, Request, Response } from 'express'
4 | import { type Middleware } from './middleware.js'
5 | import { InternalServerError, NotImplementedError, ValidationError } from '@zhttp/errors'
6 | import { type OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'
7 | import { loggerInstance } from './logger.js'
8 |
9 | export type EndpointOasInfo = Parameters['0']
10 |
11 | const methods = [
12 | 'get',
13 | 'put',
14 | 'post',
15 | 'delete',
16 | 'head',
17 | 'options',
18 | 'patch',
19 | 'trace'
20 | ] as const
21 | export type Method = (typeof methods)[number]
22 |
23 | type ExtractRouteParams = string extends Path
24 | ? Record
25 | : // eslint-disable-next-line @typescript-eslint/no-unused-vars
26 | Path extends `${infer _Start}:${infer Param}/${infer Rest}`
27 | ? { [K in Param | keyof ExtractRouteParams]?: ZodString }
28 | : // eslint-disable-next-line @typescript-eslint/no-unused-vars
29 | Path extends `${infer _Start}:${infer Param}`
30 | ? { [K in Param]: ZodString }
31 | : ZodRawShape
32 |
33 | export type InputValidationSchema = ZodObject<{
34 | params?: ZodObject>
35 | query?: ZodObject>
36 | body?: ZodSchema
37 | }>
38 |
39 | export type ResponseValidationSchema = ZodSchema
40 |
41 | export interface EndpointOptions<
42 | Path extends string,
43 | InputsSchema extends
44 | InputValidationSchema = InputValidationSchema,
45 | OutputSchema extends ResponseValidationSchema = ResponseValidationSchema,
46 | > {
47 | method: Method
48 | path: Path
49 | name?: string
50 | description?: string
51 | oasInfo?: Partial
52 | // TODO: everything inside inputs is actually part of req. Maybe it shouldn't be passed as a separate object?
53 | handler?: (
54 | inputs: z.output,
55 | req: Request,
56 | res: Response,
57 | ) => Promise>
58 | inputValidationSchema?: InputsSchema
59 | responseValidationSchema?: OutputSchema
60 | responseContentType: string
61 | middlewares?: Middleware[]
62 | }
63 |
64 | export class Endpoint<
65 | Path extends string = string,
66 | InputsSchema extends
67 | InputValidationSchema = InputValidationSchema,
68 | OutputSchema extends ResponseValidationSchema = ResponseValidationSchema,
69 | > {
70 | constructor (
71 | private readonly options: EndpointOptions
72 | ) {}
73 |
74 | /** Add a description to the endpoint */
75 | description (description: (typeof this.options)['description']) {
76 | this.options.description = description
77 | return this
78 | }
79 |
80 | /** Name the endpoint */
81 | name (name: (typeof this.options)['name']) {
82 | this.options.name = name
83 | return this
84 | }
85 |
86 | /** Add openapi properties to the endpoint, which will be reflected in the openapi spec */
87 | oas (oasInfo: (typeof this.options)['oasInfo']) {
88 | this.options.oasInfo = oasInfo
89 | return this
90 | }
91 |
92 | /** Specify a an input schema for the endpoint.
93 | * Must be an object on which the following properties are allowed: `params`, `query`, `body`.
94 | * query and params must be `ZodObject`s. `body` can be any Zod schema
95 | *
96 | * @example
97 | * ```ts
98 | * ...
99 | * .input({
100 | * query: z.object({
101 | * name: z.string(),
102 | * age: z.number().optional()
103 | * })
104 | * })
105 | * ```
106 | * */
107 | input['shape']>(
108 | inputValidationSchemaShape: NewInputsSchemaShape
109 | ) {
110 | return new Endpoint, OutputSchema>({
111 | ...this.options,
112 | inputValidationSchema: z.object(inputValidationSchemaShape)
113 | })
114 | }
115 |
116 | /** Specify a zod output schema for the endpoint. Corresponds to the body of the HTTP response.
117 | * @example
118 | * ```ts
119 | * ...
120 | * .output(zApiResponse(z.object({
121 | * greeting: z.string()
122 | * })))
123 | * ```
124 | * */
125 | response(
126 | responseValidationSchema: NewOutputSchema
127 | ) {
128 | return new Endpoint({
129 | ...this.options,
130 | responseValidationSchema
131 | })
132 | }
133 |
134 | /**
135 | * Specify the response content type. Defaults to `application/json`.
136 | * @example
137 | * ```ts
138 | *...
139 | *.responseContentType('text/plain')
140 | * ```
141 | * */
142 | responseContentType (contentType: string) {
143 | this.options.responseContentType = contentType
144 | return this
145 | }
146 |
147 | /** Define the handler of the endpoint. Should be an async function.
148 | * The return type should correspond to the response schema.
149 | * @example
150 | * ```ts
151 | * ...
152 | * .handler(async (input, req, res) => {
153 | * return {
154 | * greeting: `Hello ${input.query.name ?? 'everybody}!`
155 | * }
156 | * })
157 | * ```
158 | * */
159 | handler (
160 | handler: (
161 | inputs: z.output,
162 | req: Request,
163 | res: Response,
164 | ) => Promise>
165 | ) {
166 | this.options.handler = handler
167 | return this
168 | }
169 |
170 | /** Add an array of middlewares to the endpoint */
171 | middlewares (middlewares: Middleware[]) {
172 | this.options.middlewares = [
173 | ...(this.options.middlewares ?? []),
174 | ...middlewares
175 | ]
176 | return this
177 | }
178 |
179 | /** Add a middleware to the endpoint */
180 | middleware (middleware: Middleware) {
181 | this.options.middlewares = [
182 | ...(this.options.middlewares ?? []),
183 | middleware
184 | ]
185 | return this
186 | }
187 |
188 | getName () {
189 | return this.options.name
190 | }
191 |
192 | getDescription () {
193 | return this.options.description
194 | }
195 |
196 | getMiddlewares () {
197 | return this.options.middlewares
198 | }
199 |
200 | getHandler () {
201 | return this.options.handler
202 | }
203 |
204 | getOasInfo () {
205 | return this.options.oasInfo
206 | }
207 |
208 | getMethod () {
209 | return this.options.method
210 | }
211 |
212 | getPath () {
213 | return this.options.path
214 | }
215 |
216 | getInputValidationSchema () {
217 | return this.options.inputValidationSchema
218 | }
219 |
220 | getResponseValidationSchema () {
221 | return this.options.responseValidationSchema
222 | }
223 |
224 | getResponseContentType () {
225 | return this.options.responseContentType
226 | }
227 | }
228 |
229 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
230 | export type AnyEndpoint = Endpoint
231 |
232 | /** Define a new endpoint */
233 | export const endpoint = (
234 | method: Method,
235 | path: Path,
236 | name?: string
237 | ) =>
238 | new Endpoint({
239 | name,
240 | method,
241 | path,
242 | responseContentType: 'application/json'
243 | })
244 |
245 | /** Define a new GET endpoint */
246 | export const get = (path: Path, name?: string) =>
247 | endpoint('get', path, name)
248 |
249 | /** Define a new PUT endpoint */
250 | export const put = (path: Path, name?: string) =>
251 | endpoint('put', path, name)
252 |
253 | /** Define a new POST endpoint */
254 | export const post = (path: Path, name?: string) =>
255 | endpoint('post', path, name)
256 |
257 | /** Define a new DELETE endpoint */
258 | export const del = (path: Path, name?: string) =>
259 | endpoint('delete', path, name)
260 |
261 | export const endpointToExpressHandler = (endpoint: AnyEndpoint) => {
262 | const endpointHandler = (req: Request, res: Response, next: NextFunction) => {
263 | // Input validation
264 | let inputParams = {}
265 | try {
266 | inputParams = endpoint.getInputValidationSchema()?.parse(req)
267 | } catch (error) {
268 | const e = error as z.ZodError
269 | next(new ValidationError(e.message, e.issues))
270 | return
271 | }
272 |
273 | if (endpoint.getHandler() == null) {
274 | next(new NotImplementedError())
275 | return
276 | }
277 |
278 | endpoint
279 | .getHandler()?.(inputParams, req, res)
280 | .then((responseObj) => {
281 | // Output validation
282 | let postOutputValidationResponseObj: z.output<
283 | NonNullable>
284 | >
285 |
286 | const responseValidationSchema = endpoint.getResponseValidationSchema()
287 | if (responseValidationSchema != null) {
288 | try {
289 | postOutputValidationResponseObj = responseValidationSchema.parse(responseObj)
290 | } catch (error) {
291 | const e = error as z.ZodError
292 | loggerInstance.logger('endpointOutputValidation').error(e)
293 | next(new InternalServerError())
294 | return
295 | }
296 | } else {
297 | postOutputValidationResponseObj = responseObj
298 | }
299 |
300 | res.header('content-type', endpoint.getResponseContentType())
301 | res.send(postOutputValidationResponseObj)
302 | })
303 | .catch((e) => {
304 | next(e)
305 | })
306 | }
307 |
308 | return endpointHandler
309 | }
310 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | `zhttp` is a minimal, typesafe, [OpenAPI](https://www.openapis.org/) compatible HTTP library. It's build around [express](https://github.com/expressjs/express) and [Zod](https://github.com/colinhacks/zod).
4 |
5 | It solves some of the major pains of building an API with express (handler typing, error handling, input/output validation, openapi...) while attempting to stay as flexible (read: _as close to plain express_) as possible.
6 |
7 | [🧪 Try out zhttp on Stackblitz!](https://stackblitz.com/~/github.com/evertdespiegeleer/zhttp-example-app?initialPath=%2Fapi.html)
8 |
9 | # Installation
10 |
11 | ```sh
12 | npm install @zhttp/core @zhttp/errors zod
13 | ```
14 |
15 | # Basic usage examples
16 |
17 | ```ts
18 | // ./examples/basic-usage.ts
19 |
20 | import { z } from 'zod'
21 | import {
22 | Server,
23 | controller,
24 | get,
25 | extendZodWithOpenApi,
26 | zApiOutput,
27 | apiResponse,
28 | openapiController
29 | } from '@zhttp/core'
30 |
31 | extendZodWithOpenApi(z)
32 | // ⬆ What this allows you to do is to optionally add OAS info
33 | // to a Zod validation schema using zodSchema.openapi(...)
34 | // If this Zod schema is used in the input or output of an endpoint,
35 | // the info provided will be included in the generated openapi spec.
36 | //
37 | // Exmaple:
38 |
39 | const zHelloResponse = zApiOutput(z.object({
40 | greeting: z.string().openapi({ example: 'Hello Joske!' })
41 | })).openapi('HelloResponse')
42 |
43 | const helloController = controller('Hello')
44 | .description('This controller says hello to everyone')
45 |
46 | helloController.endpoint(
47 | get('/hello')
48 | .input({
49 | params: z.object({
50 | name: z.string().optional()
51 | })
52 | })
53 | .response(zHelloResponse)
54 | .handler(async (input) => {
55 | return apiResponse({
56 | // Both the input object ⬇ and the handler response are strongly typed :)
57 | greeting: `Hello ${input.params.name ?? 'everybody'}!`
58 | })
59 | })
60 | )
61 |
62 | const server = new Server({
63 | controllers: [
64 | helloController,
65 | openapiController
66 | ],
67 | middlewares: []
68 | }, {
69 | port: 3000,
70 | oasInfo: {
71 | title: 'A very cool api',
72 | version: '1.0.0'
73 | }
74 | })
75 |
76 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
77 | server.start()
78 |
79 | ```
80 |
81 | # Concepts
82 |
83 | ## Endpoints
84 |
85 | Endpoints are the building blocks of your API. They define the HTTP methods, paths, inputs, outputs, and behavior for each API call.
86 | As can be seen in the example below, endpoints are defined using a function chaining approach.
87 | These functions are further refered to as 'modulators'.
88 |
89 | ### Handlers and input/output typing
90 |
91 | Endpoint handlers are similar to plain express handlers, with some extra feastures for strict input and output typing. Rather than the typical 2 express handler parameters (`request` and `response`), there's three: `inputs`, `request`, `response`.
92 | `inputs` is a typed object containing everything defined in the input schema. `request` and `response` are simply the express `request` and `response` objects.
93 |
94 | Sending out a response is as simle as returning a value in the handler function. Handler responses, just like inputs, are strictly typed. When an output schema is specified and the handler function doesn't return a value that corresponds to said schema, typescript will comlain.
95 |
96 | By default, every endpoint will send its response with a `application/json` content type. This is typial for APIs, but there might be exceptions. You can override this content type using the `responseContentType` modulator.
97 |
98 | #### Using req/res directly
99 |
100 | You _can_ take input via the `request` object and use the `response` object to send a response in typical express manner, so migrating from express should be fairly trivial.
101 | However, beware that where you do this, you'll lose:
102 | - strict typing
103 | - output validation
104 | - AFTER-type middlewares
105 |
106 | ### Routing
107 | An important distinction between plain express and zhttp is that zhttp – consciously – doesn't really support routing. The paths you see in your endpoint definitions are the paths that can be called; No hidden prefixes.
108 |
109 | ### Basic endpoint example
110 |
111 | ```ts
112 | // ./examples/concept-endpoint.ts
113 |
114 | import { z } from 'zod'
115 | import { endpoint, get } from '@zhttp/core'
116 |
117 | const zGreetingOutput = z.object({
118 | message: z.string()
119 | })
120 |
121 | const zGreetingInput = {
122 | query: z.object({
123 | name: z.string().optional()
124 | })
125 | }
126 |
127 | // ⬇ For common http methods (get, post, put, del), utility functions are available:
128 | get('/hello', 'getGreeting')
129 | .description('Say hello to everyone')
130 | .input(zGreetingInput)
131 | .response(zGreetingOutput)
132 | .handler(async ({ query }) => {
133 | return {
134 | message: `Hello ${query.name ?? 'everyone'}!`
135 | }
136 | })
137 |
138 | // `endpoint` is a generic function which supports every http method.
139 | endpoint('get', '/goodbye', 'getGoodbye')
140 | .description('Say goodbye to everyone')
141 | .input(zGreetingInput)
142 | .response(zGreetingOutput)
143 | .handler(async ({ query }) => {
144 | return {
145 | message: `Goodbye ${query.name ?? 'everyone'}!`
146 | }
147 | })
148 |
149 | ```
150 |
151 | ## Controllers
152 |
153 | An controller, essentially, is nothing but a group of endpoints.
154 | Just like individual endpoints, controllers can be assigned middlewares.
155 | Controllers do **not** serve as routers. Every endpoint path should be a _complete_ path.
156 |
157 | ### Basic controller example
158 |
159 | ```ts
160 | // ./examples/concept-controller.ts
161 |
162 | import { z } from 'zod'
163 | import { controller, get } from '@zhttp/core'
164 |
165 | export const greetingController = controller('greeting')
166 | .description('A controller that greets the world.')
167 |
168 | greetingController.endpoint(
169 | get('/hello', 'getGreeting')
170 | .description('Say hello to everyone')
171 | .input({
172 | query: z.object({
173 | name: z.string().optional()
174 | })
175 | })
176 | .response(z.object({
177 | message: z.string()
178 | }))
179 | .handler(async ({ query }) => {
180 | return {
181 | message: `Hello ${query.name ?? 'everyone'}!`
182 | }
183 | })
184 | )
185 |
186 | ```
187 |
188 | ## Middleware
189 |
190 | A middleware is a function that operates between an incoming request and the corresponding outgoing response. It serves as a processing layer before or after an endpoint handler, carrying out tasks like logging, authentication, and other sorts of data manipulation.
191 |
192 | Middlewares in `zhttp` are essentially just express middlewares, with two extra properties: their type ([indicating when to run them](#order-of-execution)), and an optional name.
193 | Middlewares can be bound on multiple levels:
194 | - The server
195 | - A controller
196 | - An endpoint
197 |
198 | ### Basic middleware example
199 |
200 | ```ts
201 | // ./examples/concept-middleware.ts
202 |
203 | import { type Request, type Response, type NextFunction } from 'express'
204 | import { middleware, MiddlewareTypes } from '@zhttp/core'
205 |
206 | export const lastVisitMiddleware = middleware({
207 | name: 'lastVisitMiddleware',
208 | type: MiddlewareTypes.BEFORE,
209 | handler (req: Request, res: Response, next: NextFunction) {
210 | const now = new Date()
211 | const lastVisitCookieValue = req.cookies.beenHereBefore
212 | const lastVisitTime = lastVisitCookieValue != null ? new Date(String(lastVisitCookieValue)) : undefined
213 | res.cookie('beenHereBefore', now.toISOString())
214 | if (lastVisitTime == null) {
215 | console.log('Seems like we\'ve got a new user 👀')
216 | next(); return
217 | }
218 | const daysSinceLastVisit = (now.getTime() - lastVisitTime.getTime()) / (1000 * 60 * 60 * 24)
219 | console.log(`It's been ${daysSinceLastVisit} days since this user last visited.`)
220 | next()
221 | }
222 | })
223 |
224 | ```
225 |
226 | ## Server
227 |
228 | ### Basic server example
229 |
230 | ```ts
231 | // ./examples/concept-server.ts
232 |
233 | import { Server } from '@zhttp/core'
234 | import { greetingController } from './concept-controller.js'
235 | import { lastVisitMiddleware } from './concept-middleware.js'
236 |
237 | export const server = new Server({
238 | controllers: [greetingController],
239 | middlewares: [lastVisitMiddleware]
240 | }, {
241 | port: 8080
242 | })
243 |
244 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
245 | server.start()
246 |
247 | ```
248 |
249 | # OpenAPI
250 |
251 | ## `openapiController`
252 |
253 | The package exports a special controller `openapiController`. When used, this controller exposes routes `/openapi.json` (the OpenAPI json spec) and `/api.html` (a [RapiDoc](https://rapidocweb.com/) api interface).
254 |
255 | ## Programmatic access
256 |
257 | The openapi definition can be directly from the server object.
258 |
259 | ```ts
260 | // ./examples/direct-openapi.ts
261 |
262 | import { server } from './concept-server.js'
263 |
264 | console.log(
265 | server.oasInstance.getJsonSpec()
266 | )
267 |
268 | ```
269 |
270 | # Errors
271 |
272 | `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.
273 |
274 | ## `@zhttp/errors`
275 |
276 | Any type of unknown error will be logged and will result in a `InternalServerError` response (http status code 500).
277 |
278 | 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.
279 |
280 | ```ts
281 | // ./examples/concepts-errors.ts
282 |
283 | import { z } from 'zod'
284 | import { controller, get } from '@zhttp/core'
285 | import { NotFoundError } from '@zhttp/errors'
286 |
287 | // Let's presume we're talking to some sort of database
288 | const db: any = undefined
289 |
290 | export const vegetablesController = controller('vegetables')
291 |
292 | vegetablesController.endpoint(
293 | get('/vegetables/:vegetableId', 'getVegetableDetails')
294 | .input({
295 | params: z.object({
296 | vegetableId: z.string().uuid()
297 | })
298 | })
299 | .response(z.object({
300 | message: z.string()
301 | }))
302 | .handler(async ({ params: { vegetableId } }) => {
303 | const vegetableDetails = await db.getVegetableById(vegetableId)
304 | if (vegetableDetails == null) {
305 | // ✨✨✨✨✨✨✨✨✨
306 | throw new NotFoundError(`Vegetable with id ${vegetableId} does not exist`)
307 | // ⬆ This will result in a 404 response
308 | // ✨✨✨✨✨✨✨✨✨
309 | }
310 | return vegetableDetails
311 | })
312 | )
313 |
314 | ```
315 |
316 | ## Validation errors
317 |
318 | 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.
319 |
320 | If an error is detected as part of the request output validation, an `InternalServerError` is returned, and error message is logged.
321 |
322 | ```ts
323 | // ./examples/validation-errors.ts
324 |
325 | import { z } from 'zod'
326 | import { controller, get } from '@zhttp/core'
327 |
328 | export const validationExampleController = controller('validationExample')
329 |
330 | validationExampleController.endpoint(
331 | get('/hello', 'getGreeting')
332 | .input({
333 | query: z.object({
334 | // If a name shorter than 5 characcters is provided, then the server will responde with a ValidationError.
335 | name: z.string().min(5)
336 | })
337 | })
338 | .response(z.object({
339 | message: z.string()
340 | }))
341 | .handler(async ({ query }) => {
342 | return {
343 | message: `Hello ${query.name ?? 'everyone'}!`
344 | }
345 | })
346 | )
347 |
348 | validationExampleController.endpoint(
349 | get('/goodbye', 'getGoodbye')
350 | .input({
351 | query: z.object({
352 | name: z.string().optional()
353 | })
354 | })
355 | .response(z.object({
356 | message: z.string()
357 | }))
358 | .handler(async ({ query }) => {
359 | return {
360 | thisKeyShouldntBeHere: 'noBueno'
361 | } as any
362 | // ⬆ As zhttp is typesafe, you actually have to manually $x&! up the typing
363 | // to provoke an output validation error :)
364 | // This will result in an InternalServerError.
365 | })
366 | )
367 |
368 | ```
369 |
370 | # Order of execution
371 | - Server 'BEFORE' middlewares
372 | - Controller 'BEFORE' middlewares
373 | - Endpoint 'BEFORE' middlewares
374 | - **Endpoint handler**
375 | - Endpoint 'AFTER' middlewares
376 | - Controller 'AFTER' middlewares
377 | - Server 'AFTER' middlewares
378 |
379 | # CommonJS support
380 |
381 | [📰 CommonJS is hurting JavaScript](https://deno.com/blog/commonjs-is-hurting-javascript)
382 |
383 | The JavaScript ecosystem is (slowly but steadily) moving towards ESM and away from CommonJS. zhttp is build as an ESM module. It's strongly encouraged to use it like that.
384 |
385 | CommonJS is currently supported; the packages include both builds for ESM and CommonJS. You can use zhttp both ways.
386 |
387 | If major issues with supporting CommonJS were to come up, or if we'd notice that the package would become too big (by essentially having to ship the build code twice), CommonJS support might be dropped in the future.
--------------------------------------------------------------------------------