Express Zod API

repository·master·Indexed 21 days ago

https://github.com/robintail/express-zod-api

A framework for building Express-based web servers featuring integrated I/O schema validation with Zod, automated OpenAPI 3.2 documentation, and end-to-end type safety. It provides hierarchical routing, co-located schemas, and specialized utilities for pagination, date handling, and request parameter transformation.

Tokens
29.5K
Snippets
91
Records
112
Agent score
73%

What's inside express-zod-api

  1. Overview of Express Zod API

    master

    Express Zod API is a framework designed to reduce repetitive tasks when starting web server APIs. It integrates Express with I/O schema validation (using Zod), logging, and documentation tools.

    Key benefits include:

    • Hierarchical Routing: Describe web server routes as a structured object.
    • Co-located Schemas: Keep endpoint input and output type declarations next to their handlers.
    • Strict Validation: Ensures all input and output data adheres to declared schemas (preventing issues like unexpected null or undefined values).
    • TypeScript Integration: Provides full type safety and IDE hints within endpoint handlers based on declared schemas.
    • Consistent Responses: Ensures all endpoints respond in a uniform manner.
    • End-to-End Type Safety: Export input/response types to the frontend to synchronize client and server implementations.
    • Automated Documentation: Generate API documentation in OpenAPI 3.2 and JSON Schema compatible formats.
  2. Define API routing

    master

    Routing is defined using a Routing object that supports several styles:

    • Flat syntax: Use a string path like "/v1/users".
    • Nested syntax: Use objects to create subpaths (e.g., v1: { path: ... }).
    • Path parameters: Use colon syntax (e.g., ":id") in the routing object; these must be declared in the endpoint's input schema.
    • Method-based routing: Explicitly define methods (e.g., get, post, delete) within a path object.
    • Mixed syntax: Combine path and method in a single string (e.g., "delete /user/:id").
    • Static file serving: Use the ServeStatic class to serve files from a directory.
    import { Routing, ServeStatic } from "express-zod-api";
    
    const routing: Routing = {
      // flat syntax — /v1/users
      "/v1/users": listUsersEndpoint,
      // nested syntax
      v1: {
        // the way to have both — /v1/path and /v1/path/subpath
        path: endpointA.nest({
          subpath: endpointB,
        }),
        // path parameters — /v1/user/:id
        user: {
          ":id": getUserEndpoint,
        },
        // mixed syntax with explicit method — /v1/user/:id
        "delete /user/:id": deleteUserEndpoint,
        // method-based routing — /v1/account
        account: {
          get: endpointA,
          delete: endpointA,
          post: endpointB,
          patch: endpointB,
        },
      },
      // static file serving — /public serves files from ./assets
      public: new ServeStatic("assets", {
        /** @see https://expressjs.com/en/5x/api.html#express.static */
        dotfiles: "deny",
        index: false,
        redirect: false,
      }),
    };
  3. Understand the Error Handling lifecycle

    master

    Errors in Express Zod API are handled by a ResultHandler. Errors can originate from three distinct layers:

    1. Endpoint Execution (including Middleware):
      • InputValidationError: Request violates input schema (Default: 400).
      • OutputValidationError: Handler violates output schema (Default: 500).
      • HttpError: Thrown via createHttpError() (Uses provided .statusCode).
      • Other errors: Default to 500.
    2. Routing, Parsing, and Uploads:
      • Handled by the ResultHandler configured as errorHandler.
      • Parsing errors: Passed through (typically 4XX).
      • Routing errors: 404 or 405 (based on hintAllowedMethods).
      • Upload issues: Thrown if upload.limitError is configured.
    3. ResultHandler Failures:
      • Handled by AbstractResultHandler::lastResort() (Status 500, plain text).

    Note on Production Mode: When NODE_ENV=production, the defaultResultHandler generalizes 5XX errors to a generic Internal Server Error to prevent leaking sensitive details. To force an error message to be visible in production, use createHttpError(status, message, { expose: true }).

  4. Remap object keys for naming standard interoperability

    master

    To bridge the gap between public interfaces (e.g., snake_case) and internal implementations (e.g., camelCase), use the .remap() method provided by the Zod plugin.

    Unlike standard .transform(), .remap() also uses .pipe() to ensure the transformed object adheres to a new schema, which is critical for valid documentation generation. It is recommended to use shallow transformations.

    .remap() can also accept an object for explicit key mapping. Keys not present in the mapping object remain unchanged (partial mapping).

    import camelize from "camelize-ts";
    import snakify from "snakify-ts";
    import { z } from "zod";
    
    const endpoint = endpointsFactory.build({
      input: z
        .object({ user_id: z.string() })
        .transform((inputs) => camelize(inputs, /* shallow: */ true)),
      output: z
        .object({ userName: z.string() })
        .remap((outputs) => snakify(outputs, /* shallow: */ true)),
      handler: async ({ input: { userId }, logger }) => {
        logger.debug("user_id became userId", userId);
        return { userName: "Agneta" }; // becomes "user_name" in response
      },
    });
  5. Customize handling for branded Zod schemas

    master

    You can define custom handling rules for Zod schemas that use branding via the x-brand metadata. This is useful for customizing how branded types are represented in API documentation or how they are generated for client integration.

    When using the @express-zod-api/zod-plugin, you can use the .xBrand(symbol) method on a Zod schema. You then provide custom implementations using the brandHandling option in the Documentation and Integration constructors.

    To reuse handling rules for multiple brands, use the Depicter type (for documentation) and the Producer type (for client integration).

    import ts from "typescript";
    import { z } from "zod";
    import { Documentation, type Depicter } from "express-zod-api/documentation";
    import { Integration, type Producer } from "express-zod-api/integration";
    
    const myBrand = Symbol("MamaToldMeImSpecial");
    const myBrandedSchema = z.string().xBrand(myBrand); // requires Zod Plugin
    
    const ruleForDocs: Depicter = (
      { zodSchema, jsonSchema },
      { path, method, isResponse },
    ) => ({ 
      ...jsonSchema, 
      summary: "Special type of data" 
    });
    
    const ruleForClient: Producer = (
      schema: typeof myBrandedSchema,
      { next, isResponse },
    ) => ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
    
    new Documentation({
      brandHandling: { [myBrand]: ruleForDocs },
    });
    
    new Integration({
      brandHandling: { [myBrand]: ruleForClient },
    });
  6. How Express Zod API works

    master

    The framework uses Zod object schemas to validate both input and output.

    • Input: A combination of certain request properties (query, body, params, etc.) validated against an input schema. This validated object is passed to the endpoint handler as the input parameter.
    • Middleware: Middlewares have access to all request properties and can provide additional data to endpoint handlers via a ctx (context) object.
    • Output: The object returned by the endpoint handler is called output. It is passed to a ResultHandler, which is responsible for sending a consistent response containing either the output or an error.

    This flow ensures that data entering and leaving your API is strictly validated according to your schemas.

  7. Generate end-to-end TypeScript types and clients

    master

    Use the Integration class to generate a TypeScript file containing your API's IO types and a type-safe client. This provides end-to-end type safety between your API and frontend.

    Key Features:

    • variant: "client": Generates a client implementation.
    • print() / printFormatted(): Outputs the generated code. Use printFormatted() with prettier installed for clean output.
    • Frontend Usage: The generated client uses fetch by default. It supports path parameter substitution and provides a Subscription class for Server-Sent Events (SSE).

    Requirements: TypeScript 4.1+.

    import { Integration } from "express-zod-api/integration";
    
    const client = new Integration({
      routing,
      config,
      variant: "client",
    });
    
    const prettierFormattedTypescriptCode = await client.printFormatted();
  8. Test endpoints with testEndpoint()

    master

    The testEndpoint utility simplifies testing by mocking request, response, and logger objects using node-mocks-http.

    Usage: Pass an object containing the endpoint and requestProps (e.g., method, body). You can then use the provided mock getters to assert results:

    • responseMock._getStatusCode()
    • responseMock._getHeaders()
    • responseMock._getJSONData()
    • loggerMock._getLogs()
    import { testEndpoint } from "express-zod-api";
    
    test("should respond successfully", async () => {
      const { responseMock, loggerMock } = await testEndpoint({
        endpoint: yourEndpoint,
        requestProps: {
          method: "POST",
          body: {},
        },
      });
      expect(loggerMock._getLogs().error).toHaveLength(0);
      expect(responseMock._getStatusCode()).toBe(200);
      expect(responseMock._getHeaders()).toHaveProperty("x-custom", "one");
      expect(responseMock._getJSONData()).toEqual({ status: "success" });
    });
  9. Configure and use file uploads

    master

    To support file uploads, install express-fileupload and @types/express-fileupload. Configure the upload object within createConfig to set limits, error handlers, and authorization logic via beforeUpload.

    Configuration Options:

    • limits: Object containing fileSize (in bytes).
    • limitError: An http-errors object returned when the limit is exceeded. If not set, the file property will have truncated: true on failure.
    • beforeUpload: A function ({ request, logger }) => void used to restrict uploads (e.g., checking authorization).
    • debug: Boolean to enable debug mode.

    Usage: Use ez.upload() within your Zod input schema. The request Content-Type must be multipart/form-data. The resulting file object contains properties like name, mv(), mimetype, data, and size.

    import createHttpError from "http-errors";
    import { z } from "zod";
    import { ez, defaultEndpointsFactory, createConfig } from "express-zod-api";
    
    const config = createConfig({
      upload: {
        limits: { fileSize: 51200 }, // 50 KB
        limitError: createHttpError(413, "The file is too large"),
        beforeUpload: ({ request, logger }) => {
          if (!canUpload(request)) throw createHttpError(403, "Not authorized");
        },
        debug: true,
      },
    });
    
    const fileUploadEndpoint = defaultEndpointsFactory.build({
      method: "post",
      input: z.object({
        avatar: ez.upload(),
      }),
      output: z.object({}),
      handler: async ({ input: { avatar } }) => {
        // avatar: {name, mv(), mimetype, data, size, etc}
      },
    });
  10. Tag endpoints for documentation grouping

    master

    To group endpoints in your generated documentation, use the tag property in defaultEndpointsFactory.build(). To ensure consistency, you can declare valid tags using the TagOverrides interface.

    Steps:

    1. Declare your tags in a module augmentation of express-zod-api using the TagOverrides interface.
    2. Assign tags to endpoints using tag: "tagName" or tag: ["tag1", "tag2"].
    3. (Optional) Provide extended descriptions for these tags when instantiating Documentation.
    import { defaultEndpointsFactory, Documentation } from "express-zod-api";
    
    declare module "express-zod-api" {
      interface TagOverrides {
        users: unknown;
        files: unknown;
      }
    }
    
    const exampleEndpoint = defaultEndpointsFactory.build({
      tag: "users",
    });
    
    new Documentation({
      tags: {
        users: "All about users",
      },
    });
  11. Migrate Express Zod API using the ESLint plugin

    master

    To automatically migrate your Express Zod API codebase to the next major version, use the @express-zod-api/migration ESLint plugin. This plugin allows you to apply migrations automatically by running eslint --fix.

    // eslint.config.mjs
    import { parser } from "typescript-eslint";
    import migration from "@express-zod-api/migration";
    
    export default [
      { languageOptions: { parser }, plugins: { migration } },
      { files: ["**/*.ts"], rules: { "migration/v29": "error" } },
    ];
  12. Use Middlewares to provide context (ctx)

    master

    Middlewares can validate input (from headers, body, etc.) and return an object that is injected into the endpoint handler's ctx parameter.

    To connect a middleware to an endpoint, use .addMiddleware() on an EndpointsFactory before calling .build().

    You can chain middlewares together, where each subsequent middleware has access to the ctx provided by the previous one.

    import { z } from "zod";
    import createHttpError from "http-errors";
    import { Middleware } from "express-zod-api";
    
    const authMiddleware = new Middleware({
      security: {
        // this information is optional and used for generating documentation
        and: [
          { type: "input", name: "key" },
          { type: "header", name: "token" },
        ],
      },
      input: z.object({
        key: z.string().min(1),
      }),
      handler: async ({ input: { key }, request, logger }) => {
        // ... logic to find user ...
        const user = { id: 1, name: 'John' }; 
        return { user }; // provides endpoints with ctx.user
      },
    });
    
    const yourEndpoint = defaultEndpointsFactory
      .addMiddleware(authMiddleware)
      .build({
      handler: async ({ ctx: { user } }) => {
        // user is the one returned by authMiddleware
      },
    });