Skip to content

A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.

License

Notifications You must be signed in to change notification settings

RobinTail/express-zod-api

Repository files navigation

Express Zod API

logo

CI OpenAPI coverage

downloads npm release GitHub Repo stars License

Start your API server with I/O schema validation and custom middlewares in minutes.

  1. Overview
  2. How it works
  3. Quick startFast Track
  4. Basic features
    1. Middlewares
    2. Options
    3. Using native express middlewares
    4. Refinements
    5. Transformations
    6. Top level transformations and mapping
    7. Dealing with dates
    8. Cross-Origin Resource Sharing (CORS)
    9. Enabling HTTPS
    10. Customizing logger
    11. Child logger
    12. Profiling
    13. Enabling compression
  5. Advanced features
    1. Customizing input sources
    2. Nested routes
    3. Route path params
    4. Multiple schemas for one route
    5. Response customization
    6. Empty response
    7. Error handling
    8. Production mode
    9. Non-object response including file downloads
    10. File uploads
    11. Serving static files
    12. Connect to your own express app
    13. Testing endpoints
    14. Testing middlewares
  6. Special needs
    1. Different responses for different status codes
    2. Array response for migrating legacy APIs
    3. Headers as input source
    4. Accepting raw data
    5. Graceful shutdown
    6. Subscriptions
  7. Integration and Documentation
    1. Zod Plugin
    2. Generating a Frontend Client
    3. Creating a documentation
    4. Tagging the endpoints
    5. Customizable brands handling
  8. Caveats
    1. Coercive schema of Zod
    2. Excessive properties in endpoint output
  9. Your input to my output

You can find the release notes and migration guides in Changelog.

Overview

I made this framework because of the often repetitive tasks of starting a web server APIs with the need to validate input data. It integrates and provides the capabilities of popular web server, logging, validation and documenting solutions. Therefore, many basic tasks can be accomplished faster and easier, in particular:

  • You can describe web server routes as a hierarchical object.
  • You can keep the endpoint's input and output type declarations right next to its handler.
  • All input and output data types are validated, so it ensures you won't have an empty string, null or undefined where you expect a number.
  • Variables within an endpoint handler have types according to the declared schema, so your IDE and Typescript will provide you with necessary hints to focus on bringing your vision to life.
  • All of your endpoints can respond in a consistent way.
  • The expected endpoint input and response types can be exported to the frontend, so you don't get confused about the field names when you implement the client for your API.
  • You can generate your API documentation in OpenAPI 3.1 and JSON Schema compatible format.

Contributors

These people contributed to the improvement of the framework by reporting bugs, making changes and suggesting ideas:

@HenriJ @JonParton @williamgcampbell @t1nky @Tomtec331 @rottmann @boarush @shawncarr @ben-xD @daniel-white @kotsmile @arlyon @elee1766 @danclaytondev @huyhoang160593 @sarahssharkey @bobgubko @master-chu @alindsay55661 @john-schmitz @miki725 @dev-m1-macbook @McMerph @niklashigi @maxcohn @VideoSystemsTech @TheWisestOne @lazylace37 @leosuncin @kirdk @johngeorgewright @ssteuteville @rayzr522 @HardCoreQual @hellovai @Isaac-Leonard @digimuza @glitch452

How it works

Concept

The API operates object schemas for input and output validation. The object being validated is the combination of certain request properties. It is available to the endpoint handler as the input parameter. Middlewares have access to all request properties, they can provide endpoints with options. The object returned by the endpoint handler is called output. It goes to the ResultHandler which is responsible for transmitting consistent responses containing the output or possible error. Much can be customized to fit your needs.

Dataflow

Technologies

  • Typescript first.
  • Web server — Express.js v4 or v5.
  • Schema validation — Zod 3.x including Zod Plugin.
  • Supports any logger having info(), debug(), error() and warn() methods;
    • Built-in console logger with colorful and pretty inspections by default.
  • Generators:
  • File uploads — Express-FileUpload (based on Busboy).

Quick start

Installation

Install the framework, its peer dependencies and type assistance packages using your favorite package manager.

# example for yarn and express 5 (recommended):
yarn add express-zod-api express@^5 zod typescript http-errors
yarn add -D @types/express@^5 @types/node @types/http-errors

Ensure having the following options in your tsconfig.json file in order to make it work as expected:

{
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true
  }
}

Set up config

Create a minimal configuration. See all available options in sources.

import { createConfig } from "express-zod-api";

const config = createConfig({
  http: {
    listen: 8090, // port, UNIX socket or options
  },
  cors: true,
});

Create an endpoints factory

In the basic case, you can just import and use the default factory. See also Middlewares and Response customization.

import { defaultEndpointsFactory } from "express-zod-api";

Create your first endpoint

The endpoint responds with "Hello, World" or "Hello, {name}" if the name is supplied within GET request payload.

import { z } from "zod";

const helloWorldEndpoint = defaultEndpointsFactory.build({
  // method: "get" (default) or array ["get", "post", ...]
  input: z.object({
    name: z.string().optional(),
  }),
  output: z.object({
    greetings: z.string(),
  }),
  handler: async ({ input: { name }, options, logger }) => {
    logger.debug("Options:", options); // middlewares provide options
    return { greetings: `Hello, ${name || "World"}. Happy coding!` };
  },
});

Set up routing

Connect your endpoint to the /v1/hello route:

import { Routing } from "express-zod-api";

const routing: Routing = {
  v1: {
    hello: helloWorldEndpoint,
  },
};

Create your server

See the complete implementation example.

import { createServer } from "express-zod-api";

createServer(config, routing);

Try it

Start your application and execute the following command:

curl -L -X GET 'localhost:8090/v1/hello?name=Rick'

You should receive the following response:

{ "status": "success", "data": { "greetings": "Hello, Rick. Happy coding!" } }

Basic features

Middlewares

Middleware can authenticate using input or request headers, and can provide endpoint handlers with options. Inputs of middlewares are also available to endpoint handlers within input.

Here is an example of the authentication middleware, that checks a key from input and token from headers:

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 }) => {
    logger.debug("Checking the key and token");
    const user = await db.Users.findOne({ key });
    if (!user) throw createHttpError(401, "Invalid key");
    if (request.headers.token !== user.token)
      throw createHttpError(401, "Invalid token");
    return { user }; // provides endpoints with options.user
  },
});

By using .addMiddleware() method before .build() you can connect it to the endpoint:

const yourEndpoint = defaultEndpointsFactory
  .addMiddleware(authMiddleware)
  .build({
    handler: async ({ options: { user } }) => {
      // user is the one returned by authMiddleware
    }, // ...
  });

You can create a new factory by connecting as many middlewares as you want — they will be executed in the specified order for all the endpoints produced on that factory. You may also use a shorter inline syntax within the .addMiddleware() method, and have access to the output of the previously executed middlewares in chain as options:

import { defaultEndpointsFactory } from "express-zod-api";

const factory = defaultEndpointsFactory
  .addMiddleware(authMiddleware) // add Middleware instance or use shorter syntax:
  .addMiddleware({
    handler: async ({ options: { user } }) => ({}), // user from authMiddleware
  });

Options

In case you'd like to provide your endpoints with options that do not depend on Request, like non-persistent connection to a database, consider shorthand method addOptions. For static options consider reusing const across your files.

import { readFile } from "node:fs/promises";
import { defaultEndpointsFactory } from "express-zod-api";

const endpointsFactory = defaultEndpointsFactory.addOptions(async () => {
  // caution: new connection on every request:
  const db = mongoose.connect("mongodb://connection.string");
  const privateKey = await readFile("private-key.pem", "utf-8");
  return { db, privateKey };
});

Notice on resources cleanup: If necessary, you can release resources at the end of the request processing in a custom Result Handler:

import { ResultHandler } from "express-zod-api";

const resultHandlerWithCleanup = new ResultHandler({
  handler: ({ options }) => {
    // necessary to check for certain option presence:
    if ("db" in options && options.db) {
      options.db.connection.close(); // sample cleanup
    }
  },
});

Using native express middlewares

There are two ways of connecting the native express middlewares depending on their nature and your objective.

In case it's a middleware establishing and serving its own routes, or somehow globally modifying the behaviour, or being an additional request parser (like cookie-parser), use the beforeRouting option. However, it might be better to avoid cors here — the framework handles it on its own.

import { createConfig } from "express-zod-api";
import ui from "swagger-ui-express";

const config = createConfig({
  beforeRouting: ({ app, getLogger }) => {
    const logger = getLogger();
    logger.info("Serving the API documentation at https://example.com/docs");
    app.use("/docs", ui.serve, ui.setup(documentation));
    app.use("/custom", (req, res, next) => {
      const childLogger = getLogger(req); // if childLoggerProvider is configured
    });
  },
});

In case you need a special processing of request, or to modify the response for selected endpoints, use the method addExpressMiddleware() of EndpointsFactory (or its alias use()). The method has two optional features: a provider of options and an error transformer for adjusting the response status code.

import { defaultEndpointsFactory } from "express-zod-api";
import createHttpError from "http-errors";
import { auth } from "express-oauth2-jwt-bearer";

const factory = defaultEndpointsFactory.use(auth(), {
  provider: (req) => ({ auth: req.auth }), // optional, can be async
  transformer: (err) => createHttpError(401, err.message), // optional
});

Refinements

You can implement additional validations within schemas using refinements. Validation errors are reported in a response with a status code 400.

import { z } from "zod";
import { Middleware } from "express-zod-api";

const nicknameConstraintMiddleware = new Middleware({
  input: z.object({
    nickname: z
      .string()
      .min(1)
      .refine(
        (nick) => !/^\d.*$/.test(nick),
        "Nickname cannot start with a digit",
      ),
  }),
  // ...,
});

By the way, you can also refine the whole I/O object, for example in case you need a complex validation of its props.

const endpoint = endpointsFactory.build({
  input: z
    .object({
      email: z.string().email().optional(),
      id: z.string().optional(),
      otherThing: z.string().optional(),
    })
    .refine(
      (inputs) => Object.keys(inputs).length >= 1,
      "Please provide at least one property",
    ),
  // ...,
});

Transformations

Since parameters of GET requests come in the form of strings, there is often a need to transform them into numbers or arrays of numbers.

import { z } from "zod";

const getUserEndpoint = endpointsFactory.build({
  input: z.object({
    id: z.string().transform((id) => parseInt(id, 10)),
    ids: z
      .string()
      .transform((ids) => ids.split(",").map((id) => parseInt(id, 10))),
  }),
  handler: async ({ input: { id, ids }, logger }) => {
    logger.debug("id", id); // type: number
    logger.debug("ids", ids); // type: number[]
  },
});

Top level transformations and mapping

For some APIs it may be important that public interfaces such as query parameters use snake case, while the implementation itself requires camel case for internal naming. In order to facilitate interoperability between the different naming standards you can .transform() the entire input schema into another object using a well-typed mapping library, such as camelize-ts. However, that approach would not be enough for the output schema if you're also aiming to generate a valid documentation, because the transformations themselves do not contain schemas. Addressing this case, the framework offers the .remap() method of the object schema, a part of the Zod plugin, which under the hood, in addition to the transformation, also .pipe() the transformed object into a new object schema. Here is a recommended solution: it is important to use shallow transformations only.

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
  },
});

The .remap() method can also accept an object with an explicitly defined naming of your choice. The original keys missing in that object remain unchanged (partial mapping).

z.object({ user_name: z.string(), id: z.number() }).remap({
  user_name: "weHAVEreallyWEIRDnamingSTANDARDS", // "id" remains intact
});

Dealing with dates

Dates in Javascript are one of the most troublesome entities. In addition, Date cannot be passed directly in JSON format. Therefore, attempting to return Date from the endpoint handler results in it being converted to an ISO string in actual response by calling toJSON(), which in turn calls toISOString(). It is also impossible to transmit the Date in its original form to your endpoints within JSON. Therefore, there is confusion with original method z.date() that should not be used within IO schemas of your API.

In order to solve this problem, the framework provides two custom methods for dealing with dates: ez.dateIn() and ez.dateOut() for using within input and output schemas accordingly.

ez.dateIn() is a transforming schema that accepts an ISO string representation of a Date, validates it, and provides your endpoint handler or middleware with a Date. It supports the following formats:

2021-12-31T23:59:59.000Z
2021-12-31T23:59:59Z
2021-12-31T23:59:59
2021-12-31

ez.dateOut(), on the contrary, accepts a Date and provides ResultHandler with a string representation in ISO format for the response transmission. Consider the following simplified example for better understanding:

import { z } from "zod";
import { ez, defaultEndpointsFactory } from "express-zod-api";

const updateUserEndpoint = defaultEndpointsFactory.build({
  method: "post",
  input: z.object({
    userId: z.string(),
    birthday: ez.dateIn(), // string -> Date in handler
  }),
  output: z.object({
    createdAt: ez.dateOut(), // Date -> string in response
  }),
  handler: async ({ input }) => ({
    createdAt: new Date("2022-01-22"), // 2022-01-22T00:00:00.000Z
  }),
});

Cross-Origin Resource Sharing

You can enable your API for other domains using the corresponding configuration option cors. It's not optional to draw your attention to making the appropriate decision, however, it's enabled in the Quick start example above, assuming that in most cases you will want to enable this feature. See MDN article for more information.

In addition to being a boolean, cors can also be assigned a function that overrides default CORS headers. That function has several parameters and can be asynchronous.

import { createConfig } from "express-zod-api";

const config = createConfig({
  cors: ({ defaultHeaders, request, endpoint, logger }) => ({
    ...defaultHeaders,
    "Access-Control-Max-Age": "5000",
  }),
});

Please note: If you only want to send specific headers on requests to a specific endpoint, consider the Middlewares or response customization approach.

Enabling HTTPS

The modern API standard often assumes the use of a secure data transfer protocol, confirmed by a TLS certificate, also often called an SSL certificate in habit. This way you can additionally (or solely) configure and run the HTTPS server:

import { createConfig, createServer } from "express-zod-api";

const config = createConfig({
  https: {
    options: {
      cert: fs.readFileSync("fullchain.pem", "utf-8"),
      key: fs.readFileSync("privkey.pem", "utf-8"),
    },
    listen: 443, // port, UNIX socket or options
  }, // ... cors, logger, etc
});

// 'await' is only needed if you're going to use the returned entities.
// For top level CJS you can wrap you code with (async () => { ... })()
const { app, servers, logger } = await createServer(config, routing);

Ensure having @types/node package installed. At least you need to specify the port (usually it is 443) or UNIX socket, certificate and the key, issued by the certifying authority. For example, you can acquire a free TLS certificate for your API at Let's Encrypt.

Customizing logger

A simple built-in console logger is used by default with the following options that you can configure:

import { createConfig } from "express-zod-api";
const config = createConfig({
  logger: {
    level: "debug", // or "warn" in production mode
    color: undefined, // detects automatically, boolean
    depth: 2, // controls how deeply entities should be inspected
  },
});

You can also replace it with a one having at least the following methods: info(), debug(), error() and warn(). Winston and Pino support is well known. Here is an example configuring pino logger with pino-pretty extension:

import pino, { Logger } from "pino";
import { createConfig } from "express-zod-api";

const logger = pino({
  transport: {
    target: "pino-pretty",
    options: { colorize: true },
  },
});
const config = createConfig({ logger });

// Setting the type of logger used
declare module "express-zod-api" {
  interface LoggerOverrides extends Logger {}
}

Child logger

In case you need a dedicated logger for each request (for example, equipped with a request ID), you can specify the childLoggerProvider option in your configuration. The function accepts the initially defined logger and the request, it can also be asynchronous. The child logger returned by that function will replace the logger in all handlers. You can use the .child() method of the built-in logger or install a custom logger instead.

import { createConfig, BuiltinLogger } from "express-zod-api";
import { randomUUID } from "node:crypto";

// This enables the .child() method on "logger":
declare module "express-zod-api" {
  interface LoggerOverrides extends BuiltinLogger {}
}

const config = createConfig({
  childLoggerProvider: ({ parent, request }) =>
    parent.child({ requestId: randomUUID() }),
});

Profiling

For debugging and performance testing purposes the framework offers a simple .profile() method on the built-in logger. It starts a timer when you call it and measures the duration in adaptive units (from picoseconds to minutes) until you invoke the returned callback. The default severity of those measurements is debug.

import { createConfig, BuiltinLogger } from "express-zod-api";

// This enables the .profile() method on built-in logger:
declare module "express-zod-api" {
  interface LoggerOverrides extends BuiltinLogger {}
}

// Inside a handler of Endpoint, Middleware or ResultHandler:
const done = logger.profile("expensive operation");
doExpensiveOperation();
done(); // debug: expensive operation '555 milliseconds'

You can also customize the profiler with your own formatter, chosen severity or even performance assessment function:

logger.profile({
  message: "expensive operation",
  severity: (ms) => (ms > 500 ? "error" : "info"), // assess immediately
  formatter: (ms) => `${ms.toFixed(2)}ms`, // custom format
});
doExpensiveOperation();
done(); // error: expensive operation '555.55ms'

Enabling compression

According to Express.js best practices guide it might be a good idea to enable GZIP compression of your API responses.

Install the following additional packages: compression and @types/compression, and enable or configure compression:

import { createConfig } from "express-zod-api";

const config = createConfig({
  /** @link https://www.npmjs.com/package/compression#options */
  compression: { threshold: "1kb" }, // or true
});

In order to receive a compressed response the client should include the following header in the request: Accept-Encoding: gzip, deflate. Only responses with compressible content types are subject to compression.

Advanced features

Customizing input sources

You can customize the list of request properties that are combined into input that is being validated and available to your endpoints and middlewares. The order here matters: each next item in the array has a higher priority than its previous sibling. The following arrangement is default:

import { createConfig } from "express-zod-api";

createConfig({
  inputSources: {
    get: ["query", "params"],
    post: ["body", "params", "files"],
    put: ["body", "params"],
    patch: ["body", "params"],
    delete: ["query", "params"],
  }, // ...
});

Nested routes

Suppose you want to assign both /v1/path and /v1/path/subpath routes with Endpoints:

import { Routing } from "express-zod-api";

const routing: Routing = {
  v1: {
    path: endpointA.nest({
      subpath: endpointB,
    }),
  },
};

Route path params

You can assign your Endpoint to a route like /v1/user/:id where :id is the path parameter:

import { Routing } from "express-zod-api";

const routing: Routing = {
  v1: {
    user: { ":id": getUserEndpoint },
  },
};

You then need to specify these parameters in the endpoint input schema in the usual way:

const getUserEndpoint = endpointsFactory.build({
  input: z.object({
    // id is the route path param, always string
    id: z.string().transform((value) => parseInt(value, 10)),
    // other inputs (in query):
    withExtendedInformation: z.boolean().optional(),
  }),
  output: z.object({}),
  handler: async ({ input: { id } }) => ({}), // id is number,
});

Multiple schemas for one route

Thanks to the DependsOnMethod class a route may have multiple Endpoints attached depending on different methods. It can also be the same Endpoint that handles multiple methods as well. The method and methods properties can be omitted for EndpointsFactory::build() so that the method determination would be delegated to the Routing.

import { DependsOnMethod } from "express-zod-api";

// the route /v1/user has two Endpoints
// which handle a couple of methods each
const routing: Routing = {
  v1: {
    user: new DependsOnMethod({
      get: endpointA,
      delete: endpointA,
      post: endpointB,
      patch: endpointB,
    }),
  },
};

See also Different responses for different status codes.

Response customization

ResultHandler is responsible for transmitting consistent responses containing the endpoint output or an error. The defaultResultHandler sets the HTTP status code and ensures the following type of the response:

type DefaultResponse<OUT> =
  | { status: "success"; data: OUT } // Positive response
  | { status: "error"; error: { message: string } }; // or Negative response

You can create your own result handler by using this example as a template:

import { z } from "zod";
import {
  ResultHandler,
  ensureHttpError,
  getMessageFromError,
} from "express-zod-api";

const yourResultHandler = new ResultHandler({
  positive: (data) => ({
    schema: z.object({ data }),
    mimeType: "application/json", // optinal or array
  }),
  negative: z.object({ error: z.string() }),
  handler: ({ error, input, output, request, response, logger }) => {
    if (error) {
      const { statusCode } = ensureHttpError(error);
      const message = getMessageFromError(error);
      return void response.status(statusCode).json({ error: message });
    }
    response.status(200).json({ data: output });
  },
});

See also Different responses for different status codes.

After creating your custom ResultHandler you can use it as an argument for EndpointsFactory instance creation:

import { EndpointsFactory } from "express-zod-api";

const endpointsFactory = new EndpointsFactory(yourResultHandler);

Empty response

For some REST APIs, empty responses are typical: with status code 204 (No Content) and redirects (302). In order to describe it set the mimeType to null and schema to z.never():

const resultHandler = new ResultHandler({
  positive: { statusCode: 204, mimeType: null, schema: z.never() },
  negative: { statusCode: 404, mimeType: null, schema: z.never() },
});

Error handling

ResultHandler is designed to be the entity responsible for centralized error handling. By default, that center is the defaultResultHandler, however, since much can be customized, you should be aware that there are three possible origins of errors that could happen in runtime and be handled the following way:

  • Ones related to Endpoint execution — handled by a ResultHandler assigned to the EndpointsFactory produced it:
    • The following proprietary classes are available to you for customizing error handling in your ResultHandler:
      • InputValidationError — when request payload does not match the input schema of the endpoint or middleware. The default response status code is 400, cause property contains the original ZodError;
      • OutputValidationError — when returns of the endpoint's handler does not match its output schema (500);
    • Errors thrown within endpoint's handler:
      • HttpError, made by createHttpError() method of http-errors (required peer dependency). The default response status code is taken from error.statusCode;
      • Others, inheriting from Error class (500);
  • Ones related to routing, parsing and upload issues — handled by ResultHandler assigned to errorHandler in config:
    • Default is defaultResultHandler — it sets the response status code from the corresponding HttpError: 400 for parsing, 404 for routing, config.upload.limitError.statusCode for upload issues, or 500 for others.
    • ResultHandler must handle possible error and avoid throwing its own errors, otherwise:
  • Ones related to ResultHandler execution — handled by LastResortHandler:
    • Response status code is always 500 and the response itself is a plain text.

Production mode

Consider enabling production mode by setting NODE_ENV environment variable to production for your deployment:

  • Express activates some performance optimizations;
  • Self-diagnosis for potential problems is disabled to ensure faster startup;
  • The defaultResultHandler, defaultEndpointsFactory and LastResortHandler generalize server-side error messages in negative responses in order to improve the security of your API by not disclosing the exact causes of errors:
    • Throwing errors that have or imply 5XX status codes become just Internal Server Error message in response;
    • You can control that behavior by throwing errors using createHttpError() and using its expose option:
import createHttpError from "http-errors";
// NODE_ENV=production
// Throwing HttpError from Endpoint or Middleware that is using defaultResultHandler or defaultEndpointsFactory:
createHttpError(401, "Token expired"); // —> "Token expired"
createHttpError(401, "Token expired", { expose: false }); // —> "Unauthorized"
createHttpError(500, "Something is broken"); // —> "Internal Server Error"
createHttpError(501, "We didn't make it yet", { expose: true }); // —> "We didn't make it yet"

Non-object response

Thus, you can configure non-object responses too, for example, to send an image file.

You can find two approaches to EndpointsFactory and ResultHandler implementation in this example. One of them implements file streaming, in this case the endpoint just has to provide the filename. The response schema generally may be just z.string(), but I made more specific ez.file() that also supports ez.file("binary") and ez.file("base64") variants which are reflected in the generated documentation.

const fileStreamingEndpointsFactory = new EndpointsFactory(
  new ResultHandler({
    positive: { schema: ez.file("buffer"), mimeType: "image/*" },
    negative: { schema: z.string(), mimeType: "text/plain" },
    handler: ({ response, error, output }) => {
      if (error) return void response.status(400).send(error.message);
      if ("filename" in output)
        fs.createReadStream(output.filename).pipe(
          response.type(output.filename),
        );
      else response.status(400).send("Filename is missing");
    },
  }),
);

File uploads

Install the following additional packages: express-fileupload and @types/express-fileupload, and enable or configure file uploads:

import { createConfig } from "express-zod-api";

const config = createConfig({
  upload: true, // or options
});

Refer to documentation on available options. Some options are forced in order to ensure the correct workflow: abortOnLimit: false, parseNested: true, logger is assigned with .debug() method of the configured logger, and debug is enabled by default. The limitHandler option is replaced by the limitError one. You can also connect an additional middleware for restricting the ability to upload using the beforeUpload option. So the configuration for the limited and restricted upload might look this way:

import createHttpError from "http-errors";

const config = createConfig({
  upload: {
    limits: { fileSize: 51200 }, // 50 KB
    limitError: createHttpError(413, "The file is too large"), // handled by errorHandler in config
    beforeUpload: ({ request, logger }) => {
      if (!canUpload(request)) throw createHttpError(403, "Not authorized");
    },
  },
});

Then you can change the Endpoint to handle requests having the multipart/form-data content type instead of JSON by using ez.upload() schema. Together with a corresponding configuration option, this makes it possible to handle file uploads. Here is a simplified example:

import { z } from "zod";
import { ez, defaultEndpointsFactory } from "express-zod-api";

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}
    // avatar.truncated is true on failure when limitError option is not set
  },
});

You can still send other data and specify additional input parameters, including arrays and objects.

Serving static files

In case you want your server to serve static files, you can use new ServeStatic() in Routing using the arguments similar to express.static(). The documentation on these arguments you may find here.

import { Routing, ServeStatic } from "express-zod-api";
import { join } from "node:path";

const routing: Routing = {
  // path /public serves static files from ./assets
  public: new ServeStatic(join(__dirname, "assets"), {
    dotfiles: "deny",
    index: false,
    redirect: false,
  }),
};

Connect to your own express app

If you already have your own configured express application, or you find the framework settings not enough, you can connect the endpoints to your app or any express router using the attachRouting() method:

import express from "express";
import { createConfig, attachRouting, Routing } from "express-zod-api";

const app = express(); // or express.Router()
const config = createConfig({ app /* cors, logger, ... */ });
const routing: Routing = {}; // your endpoints go here

const { notFoundHandler, logger } = attachRouting(config, routing);

app.use(notFoundHandler); // optional
app.listen();
logger.info("Glory to science!");

Please note that in this case you probably need to parse request.body, call app.listen() and handle 404 errors yourself. In this regard attachRouting() provides you with notFoundHandler which you can optionally connect to your custom express app.

Besides that, if you're looking to include additional request parsers, or a middleware that establishes its own routes, then consider using the beforeRouting option in config instead.

Testing endpoints

The way to test endpoints is to mock the request, response, and logger objects, invoke the execute() method, and assert the expectations on status, headers and payload. The framework provides a special method testEndpoint that makes mocking easier. Under the hood, request and response object are mocked using the node-mocks-http library, therefore you can utilize its API for settings additional properties and asserting expectation using the provided getters, such as ._getStatusCode().

import { testEndpoint } from "express-zod-api";

test("should respond successfully", async () => {
  const { responseMock, loggerMock } = await testEndpoint({
    endpoint: yourEndpoint,
    requestProps: {
      method: "POST", // default: GET
      body: {}, // incoming data as if after parsing (JSON)
    }, // responseOptions, configProps, loggerProps
  });
  expect(loggerMock._getLogs().error).toHaveLength(0);
  expect(responseMock._getStatusCode()).toBe(200);
  expect(responseMock._getHeaders()).toHaveProperty("x-custom", "one"); // lower case!
  expect(responseMock._getJSONData()).toEqual({ status: "success" });
});

Testing middlewares

Middlewares can also be tested individually using the testMiddleware() method. You can also pass options collected from outputs of previous middlewares, if the one being tested somehow depends on them. There is errorHandler option for catching a middleware error and transforming into a response to assert in test along with other returned entities.

import { z } from "zod";
import { Middleware, testMiddleware } from "express-zod-api";

const middleware = new Middleware({
  input: z.object({ test: z.string() }),
  handler: async ({ options, input: { test } }) => ({
    collectedOptions: Object.keys(options),
    testLength: test.length,
  }),
});

const { output, responseMock, loggerMock } = await testMiddleware({
  middleware,
  requestProps: { method: "POST", body: { test: "something" } },
  options: { prev: "accumulated" }, // responseOptions, configProps, loggerProps
  // errorHandler: (error, response) => response.end(error.message),
});
expect(loggerMock._getLogs().error).toHaveLength(0);
expect(output).toEqual({ collectedOptions: ["prev"], testLength: 9 });

Special needs

Different responses for different status codes

In some special cases you may want the ResultHandler to respond slightly differently depending on the status code, for example if your API strictly follows REST standards. It may also be necessary to reflect this difference in the generated Documentation. For that purpose, the constructor of ResultHandler accepts flexible declaration of possible response schemas and their corresponding status codes.

import { ResultHandler } from "express-zod-api";

new ResultHandler({
  positive: (data) => ({
    statusCode: [201, 202], // created or will be created
    schema: z.object({ status: z.literal("created"), data }),
  }),
  negative: [
    {
      statusCode: 409, // conflict: entity already exists
      schema: z.object({ status: z.literal("exists"), id: z.number().int() }),
    },
    {
      statusCode: [400, 500], // validation or internal error
      schema: z.object({ status: z.literal("error"), reason: z.string() }),
    },
  ],
  handler: ({ error, response, output }) => {
    // your implementation here
  },
});

Array response

Please avoid doing this in new projects: responding with array is a bad practice keeping your endpoints from evolving in backward compatible way (without making breaking changes). Nevertheless, for the purpose of easier migration of legacy APIs to this framework consider using arrayResultHandler or arrayEndpointsFactory instead of default ones, or implement your own ones in a similar way. The arrayResultHandler expects your endpoint to have items property in the output object schema. The array assigned to that property is used as the response. This approach also supports examples, as well as documentation and client generation. Check out the example endpoint for more details.

Headers as input source

In a similar way you can enable the inclusion of request headers into the input sources. This is an opt-in feature. Please note:

  • only the custom headers (the ones having x- prefix) will be combined into the input,
  • the request headers acquired that way are lowercase when describing their validation schemas.
import { createConfig, defaultEndpointsFactory } from "express-zod-api";
import { z } from "zod";

createConfig({
  inputSources: {
    get: ["query", "headers"],
  }, // ...
});

defaultEndpointsFactory.build({
  input: z.object({
    "x-request-id": z.string(), // this one is from request.headers
    id: z.string(), // this one is from request.query
  }), // ...
});

Accepting raw data

Some APIs may require an endpoint to be able to accept and process raw data, such as streaming or uploading a binary file as an entire body of request. Use the proprietary ez.raw() schema as the input schema of your endpoint. The default parser in this case is express.raw(). You can customize it by assigning the rawParser option in config. The raw data is placed into request.body.raw property, having type Buffer.

import { defaultEndpointsFactory, ez } from "express-zod-api";

const rawAcceptingEndpoint = defaultEndpointsFactory.build({
  method: "post",
  input: ez.raw({
    /* the place for additional inputs, like route params, if needed */
  }),
  output: z.object({ length: z.number().int().nonnegative() }),
  handler: async ({ input: { raw } }) => ({
    length: raw.length, // raw is Buffer
  }),
});

Graceful shutdown

You can enable and configure a special request monitoring that, if it receives a signal to terminate a process, will first put the server into a mode that rejects new requests, attempt to complete started requests within the specified time, and then forcefully stop the server and terminate the process.

import { createConfig } from "express-zod-api";

createConfig({
  gracefulShutdown: {
    timeout: 1000,
    events: ["SIGINT", "SIGTERM"],
  },
});

Subscriptions

If you want the user of a client application to be able to subscribe to subsequent updates initiated by the server, consider Server-Sent Events (SSE) feature. Client application can subscribe to the event stream using EventSource class instance. The following example demonstrates the implementation emitting the time event each second.

import { z } from "zod";
import { EventStreamFactory } from "express-zod-api";
import { setTimeout } from "node:timers/promises";

const subscriptionEndpoint = EventStreamFactory({
  events: { time: z.number().int().positive() },
}).buildVoid({
  input: z.object({}), // optional input schema
  handler: async ({ options: { emit, isClosed } }) => {
    while (!isClosed()) {
      emit("time", Date.now());
      await setTimeout(1000);
    }
  },
});
const source = new EventSource("https://example.com/api/v1/time");
source.addEventListener("time", (event) => {
  const data = JSON.parse(event.data); // number
});

If you need more capabilities, such as bidirectional event sending, I have developed an additional websocket operating framework, Zod Sockets, which has similar principles and capabilities.

Integration and Documentation

Zod Plugin

Express Zod API acts as a plugin for Zod, extending its functionality once you import anything from express-zod-api:

  • Adds .example() method to all Zod schemas for storing examples and reflecting them in the generated documentation;
  • Adds .label() method to ZodDefault for replacing the default value in documentation with a label;
  • Adds .remap() method to ZodObject for renaming object properties in a suitable way for making documentation;
  • Alters the .brand() method on all Zod schemas by making the assigned brand available in runtime.

Generating a Frontend Client

You can generate a Typescript file containing the IO types of your API and a client for it. Consider installing prettier and using the async printFormatted() method.

import { Integration } from "express-zod-api";

const client = new Integration({
  routing,
  variant: "client", // <— optional, see also "types" for a DIY solution
  optionalPropStyle: { withQuestionMark: true, withUndefined: true }, // optional
});

const prettierFormattedTypescriptCode = await client.printFormatted(); // or just .print() for unformatted

Alternatively, you can supply your own format function into that method or use a regular print() method instead. The generated client is flexibly configurable on the frontend side using an implementation function that directly makes requests to an endpoint using the libraries and methods of your choice. The client asserts the type of request parameters and response. Consuming the generated client requires Typescript version 4.1 or higher.

// example frontend, simple implementation based on fetch()
import { ExpressZodAPIClient } from "./client.ts"; // the generated file

const client = new ExpressZodAPIClient(async (method, path, params) => {
  const hasBody = !["get", "delete"].includes(method);
  const searchParams = hasBody ? "" : `?${new URLSearchParams(params)}`;
  const response = await fetch(`https://example.com${path}${searchParams}`, {
    method: method.toUpperCase(),
    headers: hasBody ? { "Content-Type": "application/json" } : undefined,
    body: hasBody ? JSON.stringify(params) : undefined,
  });
  return response.json();
});

client.provide("get /v1/user/retrieve", { id: "10" });
client.provide("post /v1/user/:id", { id: "10" }); // it also substitues path params

Creating a documentation

You can generate the specification of your API and write it to a .yaml file, that can be used as the documentation:

import { Documentation } from "express-zod-api";

const yamlString = new Documentation({
  routing, // the same routing and config that you use to start the server
  config,
  version: "1.2.3",
  title: "Example API",
  serverUrl: "https://example.com",
  composition: "inline", // optional, or "components" for keeping schemas in a separate dedicated section using refs
  // descriptions: { positiveResponse, negativeResponse, requestParameter, requestBody } // check out these features
}).getSpecAsYaml();

You can add descriptions and examples to your endpoints, their I/O schemas and their properties. It will be included into the generated documentation of your API. Consider the following example:

import { defaultEndpointsFactory } from "express-zod-api";

const exampleEndpoint = defaultEndpointsFactory.build({
  shortDescription: "Retrieves the user.", // <—— this becomes the summary line
  description: "The detailed explanaition on what this endpoint does.",
  input: z
    .object({
      id: z.number().describe("the ID of the user"),
    })
    .example({ id: 123 }),
  // ..., similarly for output and middlewares
});

See the example of the generated documentation here

Tagging the endpoints

When generating documentation, you may find it necessary to classify endpoints into groups. For this, the possibility of tagging endpoints is provided. In order to achieve the consistency of tags across all endpoints, the possible tags should be declared in the configuration first and another instantiation approach of the EndpointsFactory is required. Consider the following example:

import {
  createConfig,
  EndpointsFactory,
  defaultResultHandler,
} from "express-zod-api";

const config = createConfig({
  tags: {
    users: "Everything about the users", // or advanced syntax:
    files: {
      description: "Everything about the files processing",
      url: "https://example.com",
    },
  },
});

// instead of defaultEndpointsFactory use the following approach:
const taggedEndpointsFactory = new EndpointsFactory({
  resultHandler: defaultResultHandler, // or use your custom one
  config, // <—— supply your config here
});

const exampleEndpoint = taggedEndpointsFactory.build({
  tag: "users", // or array ["users", "files"]
});

Customizable brands handling

You can customize handling rules for your schemas in Documentation and Integration. Use the .brand() method on your schema to make it special and distinguishable for the framework in runtime. Using symbols is recommended for branding. After that utilize the brandHandling feature of both constructors to declare your custom implementation. In case you need to reuse a handling rule for multiple brands, use the exposed types Depicter and Producer.

import ts from "typescript";
import { z } from "zod";
import {
  Documentation,
  Integration,
  Depicter,
  Producer,
} from "express-zod-api";

const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose
const myBrandedSchema = z.string().brand(myBrand);

const ruleForDocs: Depicter = (
  schema: typeof myBrandedSchema, // you should assign type yourself
  { next, path, method, isResponse }, // handle a nested schema using next()
) => {
  const defaultDepiction = next(schema.unwrap()); // { type: string }
  return { summary: "Special type of data" };
};

const ruleForClient: Producer = (
  schema: typeof myBrandedSchema, // you should assign type yourself
  { next, isResponse }, // handle a nested schema using next()
) => ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);

new Documentation({
  brandHandling: { [myBrand]: ruleForDocs },
});

new Integration({
  brandHandling: { [myBrand]: ruleForClient },
});

Caveats

There are some well-known issues and limitations, or third party bugs that cannot be fixed in the usual way, but you should be aware of them.

Coercive schema of Zod

Despite being supported by the framework, z.coerce.* schema does not work intuitively. Please be aware that z.coerce.number() and z.number({ coerce: true }) (being typed not well) still will NOT allow you to assign anything but number. Moreover, coercive schemas are not fail-safe and their methods .isOptional() and .isNullable() are buggy. If possible, try to avoid using this type of schema. This issue will NOT be fixed in Zod version 3.x.

Excessive properties in endpoint output

The schema validator removes excessive properties by default. However, Typescript does not yet display errors in this case during development. You can achieve this verification by assigning the output schema to a constant and reusing it in forced type of the output:

import { z } from "zod";

const output = z.object({
  anything: z.number(),
});

endpointsFactory.build({
  method,
  input,
  output,
  handler: async (): Promise<z.input<typeof output>> => ({
    anything: 123,
    excessive: "something", // error TS2322, ok!
  }),
});

Your input to my output

If you have a question or idea, or you found a bug, or vulnerability, or security issue, or want to make a PR: please refer to Contributing Guidelines.