├── 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:
22 |

zhttp built with ❤️ by Evert De Spiegeleer

23 |

Docs generated with Nextra

24 |
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 | ![zhttp, a minimal, typesafe HTTP library with Zod validation](./.readme-assets/header.png) 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. --------------------------------------------------------------------------------