Giter Club home page Giter Club logo

express-zod-api's Introduction

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. Why and what is it for
  2. How it works
    1. Technologies
    2. Concept
  3. Quick start — Fast Track
    1. Installation
    2. Set up config
    3. Create an endpoints factory
    4. Create your first endpoint
    5. Set up routing
    6. Create your server
    7. Try it
  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. Enabling compression
  5. Advanced features
    1. Customizing input sources
    2. Route path params
    3. Multiple schemas for one route
    4. Response customization
    5. Non-object response including file downloads
    6. File uploads
    7. Serving static files
    8. Connect to your own express app
    9. Testing endpoints
    10. 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. 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.

Why and what is it for

I made this library 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.

How it works

Technologies

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

Quick start

Installation

Run one of the following commands to install the library, its peer dependencies and packages for types assistance.

yarn add express-zod-api express zod typescript http-errors
yarn add --dev @types/express @types/node @types/http-errors

or

npm install express-zod-api express zod typescript http-errors
npm install -D @types/express @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({
  server: {
    listen: 8090, // port, UNIX socket or options
  },
  cors: true,
  logger: { level: "debug", color: 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", // or methods: ["get", "post", ...]
  input: z.object({
    // for empty input use 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 }) => {
      // options.user is the user 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({
    input: z.object({}),
    handler: async ({ options: { user } }) => ({}), // options.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 library handles it on its own.

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

const config = createConfig({
  server: {
    listen: 80,
    beforeRouting: ({ app, logger }) => {
      logger.info("Serving the API documentation at https://example.com/docs");
      app.use("/docs", ui.serve, ui.setup(documentation));
    },
  },
});

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({
  method: "get",
  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 library 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 importnant to use shallow transformations only.

import camelize from "camelize-ts";
import snakify from "snakify-ts";
import { z } from "zod";

const endpoint = endpointsFactory.build({
  method: "get",
  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 explictly 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 library 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 ResultHanlder 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
  }),
  output: z.object({
    createdAt: ez.dateOut(), // Date -> string
  }),
  handler: async ({ input }) => {
    // input.birthday is Date
    return {
      // transmitted as "2022-01-22T00:00:00.000Z"
      createdAt: new Date("2022-01-22"),
    };
  },
});

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({
  // ... other options
  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. When using the createServer() method, you can additionally configure and run the HTTPS server.

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

const config = createConfig({
  server: {
    listen: 80,
  },
  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, httpServer, httpsServer, 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

If the simple console output of the built-in logger is not enough for you, you can connect any other compatible one. It must support 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 } from "express-zod-api";
import { randomUUID } from "node:crypto";

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

const config = createConfig({
  logger: { level: "debug", color: true },
  childLoggerProvider: ({ parent, request }) =>
    parent.child({ requestId: randomUUID() }),
});

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({
  server: {
    /** @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.

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

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

Route path params

You can describe the route of the endpoint using parameters:

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

const routing: Routing = {
  v1: {
    user: {
      // route path /v1/user/:id, where :id is the path param
      ":id": getUserEndpoint,
      // use the empty string to represent /v1/user if needed:
      // "": listAllUsersEndpoint,
    },
  },
};

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

const getUserEndpoint = endpointsFactory.build({
  method: "get",
  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 the route path param, 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.

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: yourEndpointA,
      delete: yourEndpointA,
      post: yourEndpointB,
      patch: yourEndpointB,
    }),
  },
};

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> =
  | {
      // Positive response
      status: "success";
      data: OUT;
    }
  | {
      // or Negative response
      status: "error";
      error: {
        message: string;
      };
    };

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

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

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

Note: OutputValidationError and InputValidationError are also available for your custom error handling. 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);

Please note: ResultHandler must handle any errors and not throw its own. Otherwise, the case will be passed to the LastResortHandler, which will set the status code to 500 and send the error message as plain text.

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) {
        response.status(400).send(error.message);
        return;
      }
      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({
  server: {
    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({
  server: {
    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 library 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 library 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, similar to endpoints, but using the testMiddleware() method. There is also an ability to pass options collected from outputs of previous middlewares, if the one being tested somehow depends on them.

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
});
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) => ({
    statusCodes: [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() }),
    },
    {
      statusCodes: [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 library 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({
  method: "get",
  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
  }),
});

Subscriptions

If you want the user of a client application to be able to subscribe to subsequent updates initiated by the server, the capabilities of this library and the HTTP protocol itself would not be enough in this case. I have developed an additional pluggable library, Zod Sockets, which has similar principles and capabilities, but uses the websocket transport and Socket.IO protocol for that purpose. Check out an example of the synergy between two libraries on handling the incoming subscribe and unsubscribe events in order to emit (broadcast) the time event every second with a current time in its payload:

https://github.com/RobinTail/zod-sockets#subscriptions

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
  splitResponse: false, // optional, prints the positive and negative response types separately
});

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({
  // ..., use the simple or the advanced syntax:
  tags: {
    users: "Everything about the users",
    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 tags: ["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 library 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, serializer }, // handle a nested schema using next()
) => ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);

new Documentation({
  /* config, routing, title, version */
  brandHandling: { [myBrand]: ruleForDocs },
});

new Integration({
  /* routing */
  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 library, 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({
  methods,
  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.

express-zod-api's People

Contributors

dependabot[bot] avatar github-actions[bot] avatar john-schmitz avatar mcmerph avatar rayzr522 avatar robintail avatar sarahssharkey avatar shroudedcode avatar thewisestone avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

express-zod-api's Issues

Make the server starts optional

This is a great library that you have going on here.
Unfortunately, it doesn't play well with a lot of serverless environments, such as Google Cloud Functions. Mainly because that environment wants to start the GCP server itself.

It might be worth making the starting of a server optional. Since you expose the app, that allows the serverless controller to start it up the way it needs.

const httpServer = app.listen(config.server.listen, () => {
logger.info(`Listening ${config.server.listen}`);
});
let httpsServer: https.Server | undefined;
if (config.https) {
httpsServer = https
.createServer(config.https.options, app)
.listen(config.https.listen, () => {
logger.info(`Listening ${config.https!.listen}`);
});
}

Parameter descriptions are only outputted on referenced schema, making them invisible in docs

We recently upgraded to express-zod-api v10. This, in general, massively cleaned up our OpenAPI schema and made it work much better with code generation tools (thanks for that! 🙏). Still, it seems like it also introduced a new bug or potentially a new gotcha we weren't aware of.

Here's how to reproduce the problem:

  1. Create an endpoint or middleware that has an input with a description:
export const testEndpoint = endpointFactory.build({
  input: z.object({
    cursor: z
      .string()
      .optional()
      .describe(
        'An optional cursor string used for pagination.' +
          ' This can be retrieved from the `next` property of the previous page response.',
      ),
  }),
  output: z.object({}),
  handler: async () => {
    return {}
  },
})
  1. Generate the OpenAPI schema and look at the output:
parameters:
  - name: cursor
    in: query
    required: false
    description: GET /hris/employees parameter
    schema:
      $ref: "#/components/schemas/GetHrisEmployeesParameterCursor"
GetHrisEmployeesParameterCursor:
  type: string
  description: An optional cursor string used for pagination. This can be
    retrieved from the `next` property of the previous page response.

The custom description is outputted, but only on the referenced schema, not the actual parameter. On the parameter, the description is not only missing (which might cause documentation tools to pull the description from the schema), but specifically set to something generic (GET /hris/employees parameter).

The result is that no helpful information makes it all the way through to the docs:

image

I'm wondering: Is this expected behavior, and there's a workaround, or is this a bug introduced by the new major release? 🤔

Type error when using custom server

I get this error when using a custom express instance:
Argument of type '{ app: Express; }' is not assignable to parameter of type '(AppConfig | ServerConfig) & CommonConfig'.
Type '{ app: Express; }' is not assignable to type 'AppConfig & CommonConfig'.
Type '{ app: Express; }' is missing the following properties from type 'CommonConfig': cors, logger [2345]

Accepting a request body for a DELETE endpoint

We recently upgraded our express-zod-api major version and only now realized a bug with one of our lesser-used endpoints. It seems like the update changed how inputs on DELETE endpoints are interpreted.

Previously, the inputs were interpreted to come from the request body, and the OpenAPI schema also reflected that.

Now, the inputs are interpreted to come from the query parameters, with the OpenAPI schema also reflecting that.

I'm wondering: Was this a conscious change in behavior? Is there a way to revert back to using the request body?

Let me know if I should provide any more detailed examples!

`z.record(z.any())` generates invalid OpenAPI spec

Hi, I've run into some trouble with OpenAPI spec generation of input and output payloads. My goal is to accept an arbitrary object as input. The way I've found that I can accomplish this is with z.record(z.any()) and that works just fine functionality wise. The issue I run into is that when I generate the docs, I get the following YAML in the OpenAPI spec that's generated:

type: object
additionalProperties:
  format: any
  nullable: true

When I check the validity of this using a tool like Redocly's CLI, I get the following error:

The `type` field must be defined when the `nullable` field is used.

687 |   additionalProperties:
688 |     format: any
689 |     nullable: true

From looking at the OpenAPI spec, this is an accurate error. I was wondering if there was a known workaround/better way to handle a schema field for accepting an arbitrary object. Just using z.any() results in the same error as above.

If I manually edit the spec and add type: object to the additionalProperties, the spec stops throwing that error. Here's an example:

type: object
additionalProperties:
  format: any
  nullable: true
  type: object

I'm wondering if this is an issue with spec generation, or if I'm just trying to tackle this problem the wrong way. If you have any advice on how I should be using Zod to accept an arbitrary object that passes the OpenAPI spec, I'd love to hear.

Thank you!

Please do not lock package versions

The locked package creates this error
The inferred type of 'courierEndpoint' cannot be named without a reference to 'express-zod-api/node_modules/zod'. This is likely not portable. A type annotation is necessary.

OpenAPI spec generates extra examples if the output schema extends the input schema

Steps to reproduce:

  • Add test (e.g. at tests/unit/open-api.spec.ts):
test("should pass over the multiple examples of an object", () => {
  const zodSchema = z.object({ a: z.string() });
  const spec = new OpenAPI({
    config: sampleConfig,
    routing: {
      v1: {
        addSomething: defaultEndpointsFactory.build({
          method: "post",
          input: withMeta(zodSchema).example({ a: "first" }),
          output: withMeta(zodSchema.extend({ b: z.string() }))
            .example({ a: "first", b: "prefix_first" })
            .example({ a: "second", b: "prefix_second" }),
          handler: async ({ input: { a } }) => ({ a, b: `prefix_${a}` }),
        }),
      },
    },
    version: "3.4.5",
    title: "Testing Metadata:example on IO parameter",
    serverUrl: "http://example.com",
  }).getSpecAsYaml();
  expect(spec).toMatchSnapshot();
});
  • Add snapshot (e.g. at tests/unit/__snapshots__/open-api.spec.ts.snap):
exports[`Open API generator Metadata should pass over the multiple examples of an object 1`] = `
"openapi: 3.0.0
info:
  title: Testing Metadata:example on IO parameter
  version: 3.4.5
paths:
  /v1/addSomething:
    post:
      responses:
        "200":
          description: POST /v1/addSomething Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - success
                  data:
                    type: object
                    properties:
                      a:
                        type: string
                      b:
                        type: string
                    required:
                      - a
                      - b
                    example:
                      a: first
                      b: prefix_first
                required:
                  - status
                  - data
              examples:
                example1:
                  value:
                    status: success
                    data:
                      a: first
                      b: prefix_first
                example2:
                  value:
                    status: success
                    data:
                      a: second
                      b: prefix_second
        "400":
          description: POST /v1/addSomething Error response
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - error
                  error:
                    type: object
                    properties:
                      message:
                        type: string
                    required:
                      - message
                required:
                  - status
                  - error
              examples:
                example1:
                  value:
                    status: error
                    error:
                      message: Sample error message
      requestBody:
        content:
          application/json:
            schema:
              description: POST /v1/addSomething request body
              type: object
              properties:
                a:
                  type: string
              required:
                - a
            examples:
              example1:
                value:
                  a: first
components:
  schemas: {}
  responses: {}
  parameters: {}
  examples: {}
  requestBodies: {}
  headers: {}
  securitySchemes: {}
  links: {}
  callbacks: {}
tags: []
servers:
  - url: http://example.com
"
`;
  • run test

Actual behaviour:

The above test fails because there are extra generated examples in the endpoint requestBody:

                  - a
                  examples:
                    example1:
                      value:
                        a: first
    +               example2:
    +                 value:
    +                   a: first
    +                   b: prefix_first
    +               example3:
    +                 value:
    +                   a: second
    +                   b: prefix_second
    +               example4:
    +                 value:
    +                   a: first
    +                   b: prefix_first
    +               example5:
    +                 value:
    +                   a: first
    +                   b: prefix_first
    +               example6:
    +                 value:
    +                   a: second
    +                   b: prefix_second
    +               example7:
    +                 value:
    +                   a: first
    +                   b: prefix_second
    +               example8:
    +                 value:
    +                   a: first
    +                   b: prefix_first
    +               example9:
    +                 value:
    +                   a: second
    +                   b: prefix_second
      components:
        schemas: {}
        responses: {}
        parameters: {}
        examples: {}

Expected behaviour

The above test passes.


The workaround is to use

input: withMeta(zodSchema.extend({})).example(...

instead of

input: withMeta(zodSchema).example(...

So my guess is that the reason for this behaviour is that withMeta mutates its argument

Locking package versions

This is basically the same issue as #178, just a new version of it.

Because express-zod-api specifies that it is only compatible with zod version 3.20.2 you will get errors if try to use it in an app that uses any other version of zod.

This is all that is needed to reproduce it:

mkdir repro && cd repro
yarn init -y
yarn add typescript zod express-zod-api

cat <<'END' > index.ts
import { z } from 'zod';
import { defaultEndpointsFactory } from 'express-zod-api';

defaultEndpointsFactory.build( {
  method: 'get',
  input: z.object({}),
  output: z.object({}),
  async handler() {},
} );
END

./node_modules/.bin/tsc ./index.ts

This is the error that is produced:

repro.ts:6:3 - error TS2322: Type 'ZodObject<{}, "strip", ZodTypeAny, {}, {}>' is not assignable to type 'IOSchema<any>'.
  Type 'ZodObject<{}, "strip", ZodTypeAny, {}, {}>' is not assignable to type 'ZodObject<any, any, ZodTypeAny, { [x: string]: any; }, { [x: string]: any; }>'.
    Types have separate declarations of a private property '_cached'.

6   input: z.object({}),
    ~~~~~

This happens because locking the version of zod means that unless every package in the project that uses zod also locks it to the exact same version, you are going to end up with a situation like this:

❯ find node_modules -type d -name zod
node_modules/zod
node_modules/express-zod-api/node_modules/zod

This means that the zod being used by express-zod-api is not the same as the zod being used in the rest of the app.

Unable to use .array() as return type

Hello and thanks for building this package, i was looking for something like this since a while.
It looks like i'm unable to use a zod array as an endpoint return type.


 const updateUserEndpoint = basicEndpointsFactory.build({
    method: "put",
    input: z.object({}),
    output: z.object({ test: z.string() }).array(),
    handler: async ({ options, logger, input }) => {
//.....
}
)}

gives me a typerror on the output key, with the following output

Type 'ZodArray<ZodObject<{ test: ZodString; }, "strip", ZodTypeAny, { test: string; }, { test: string; }>, "many">' is not assignable to type 'IOSchema<any>'.
  Type 'ZodArray<ZodObject<{ test: ZodString; }, "strip", ZodTypeAny, { test: string; }, { test: string; }>, "many">' is missing the following properties from type 'ZodDiscriminatedUnion<string, Primitive, ZodObject<any, any, ZodTypeAny, { [x: string]: any; }, { [x: string]: any; }>>': discriminator, validDiscriminatorValues, optionsts(2322)
endpoints-factory.d.ts(12, 5): The expected type comes from property 'output' which is declared here on type 'BuildProps<ZodObject<{}, "strip", ZodTypeAny, {}, {}>, IOSchema<any>, ZodObject<{}, "strip", ZodTypeAny, {}, {}>, { ...; }, "put", string>'

Unfortunately, .array does not exist on type ZodObject, if i remember correctly AnyZodObject should be the right type in order to accept every zod schema.
Am i doing something wrong or this is an intended behavior?
Thanks in advance!

Nested top level refinements

It would be great to have the possibility to use the .superRefine() method in the endpoint's inputs. It will allow validating the input object in a more advanced way and return all issues found. To build the OpenAPI spec it's possible to use .InnerType() I think.

Bug: Cannot use `dateIn` as an input for a middleware

Another small bug my team discovered:

Right now, it's not possible to define a middleware with a dateIn input. Calling an endpoint using such a middleware always results in a <field_name>: Expected string, received date error.

I didn't have time to look into the source code here, but it seems like inputs might be parsed/validated multiple times (once at the middleware and then again at the endpoint), and by the time they're checked for the second time the date string has already been parsed to a date (which is not the expected input type of dateIn). Could that be the case?

docs: paths, arrays & DependsOnMethod clarification

Hi! I was looking for something that enforced zod types to my input body/query params and automatically caught exceptions thrown from my async handlers and i found this. Looks awesome and at first glance it functions well. After diving a bit deeper though i've run into some problems:

  • I can't find any documentation on how you access & validate express route path values. E.g. express.get('/users/:id', ...) - how do i set a schema for id and access its value?
  • I can't find any documentation on how you work with array outputs [1]
  • I can't seem to create an endpoint without any inputs, e.g. a GET route with not query params - is this a conscious decision that you always have to define an input schema? Can't find any documentation on it.
  • When using DependsOnMethod i can specify a certain endpoint for e certain method, but i still have to set the method property of the endpoint. Concious decision? [2]
  • There is no example of a POST with query parameters. Is this not supported? I know of several APIs that use this and since im doing some vetting i just want to know :-)

1. Array output error

image

2. DependsOnMethod

const post = defaultEndpointsFactory.build({
  description: 'Create todo',
  method: 'post', // explicitly have to say that it is a post method
  input: CreateTodoInput,
  output: CreateTodoOutput,
  handler: async ({ input: todo}) => {
    todos.push({ ...todo, id })
    id++;
    return {...todo, id }
  },
})

export default new DependsOnMethod({
  post,
})

Most of these questions probably have a simple answer, and this issue is not about the problem themselves (if any) but the lack of documentation. Not trying to sound like a jerk here, i'm just happy i found this. Great work 👍

New Zod version breaks the existing versions when using yarn

Suddenly I found out that yarn does NOT respect yarn.lock files of sub-dependencies. So the version of zod defined in my yarn.lock file does not actually mean anything when doing yarn add express-zod-api.

Related issue: yarnpkg/yarn#4928

https://classic.yarnpkg.com/blog/2016/11/24/lockfiles-for-all/

When you publish a package that contains a yarn.lock, any user of that library will not be affected by it. When you install dependencies in your application or library, only your own yarn.lock file is respected. Lockfiles within your dependencies will be ignored.

Holy Moly!

The version of Zod 3.10.x seems to have some breaking changes and it should not be installed according to my lock file included to the package distribution, but yarn installs this version along with my library and it causes the following error.

/node_modules/zod/lib/types.js:82
           path: params?.path || [],
                        ^

SyntaxError: Unexpected token '.'

Issue: Generating documentation gives error: "Zod type ZodDate is unsupported"

Whenever I try to generate OpenAPI spec using:

const yamlString = new OpenAPI({
  routing: routes, // the same routing and config that you use to start the server
  config,
  version: '1',
  title: 'Posterr API',
  serverUrl: 'https://localhost:8080'
}).getSpecAsYaml();

I get the following error:

Error: Zod type ZodDate is unsupported
    at depictSchema (/Users/gmcouto/code/strider-backend/node_modules/express-zod-api/src/open-api-helpers.ts:652:11)
    at /Users/gmcouto/code/strider-backend/node_modules/express-zod-api/src/open-api-helpers.ts:124:46
    at Array.map (<anonymous>)
    at depictUnion (/Users/gmcouto/code/strider-backend/node_modules/express-zod-api/src/open-api-helpers.ts:124:18)

coerce and transform not having proper types in output

Typescript reports an error when using z.coerce or .transform in output.

Additionally generating an OpenAPI spec fails at runtime when using z.coere.bigint in input

Minimal reproducible example

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

const testEndpoint = defaultEndpointsFactory.build({
  method: "get",
  input: z.object({}),
  output: z.object({
    name: z.coerce.string(),
    id: z.coerce.bigint().
  }),
  handler: async () => {
    return { name: 1, id: "1" };  // <-  Type 'number' is not assignable to type 'string'
  },
});

Edit: Reading a bit through issues and discussions the behavior for output transforms seems intended?
Anyhow generating a OpenAPI spec still fails with z.coerce.bigint in input.

Insufficient exports

Originally posted by @McMerph in #951

TS4023: Exported variable 'getFileStreamingEndpointsFactory' has or is using name 'ZodFileDef' from external module "{{some path}}/node_modules/.pnpm/[email protected]_n5zqmeauuo4jduap7lnbjtm454/node_modules/express-zod-api/dist/index" but cannot be named.

since 10.0.0-beta1

Middlewares do not run for the OPTIONS request

Doing .addExpressMiddleware(cors()) has no effect since it's not run for the OPTIONS request. This example is also in Readme, so it should be fixed.

Originally posted by @HardCoreQual in #500 (reply in thread)

Can be bug that
ExpressZodEndpoidFactory.addExpressMiddleware(cors()) don't work // createServer
and is necessary to use expressApp.use(cors()) // attachRouting
or it work like expected ?

Refinements on input object

Currently, there is no way to do refinements on the entire input object, only on it's properties. It would be great if there was a way to support this:

example:

body is

{
  type: 'type1',
  dynamicValue: { 
     type1Attribute: 1,
  } 
}
{
  type: 'type2',
  dynamicValue: { 
     type2Attribute: '1',
  } 
}

I want to be able to do:

defaultEndpointsFactory.build({
   input: z.object({ 
      type: z.union([z.literal('type1'), z.literal('type2')]),
      dynamicValue: z.union([
        z.object({ type1Attribute: z.number() }),
        z.object({ type2Attribute: z.string() })
      ]),
    }).refine(data => {
       if (data.type === "type1") {
          return !!(data.dynamicValue as any).type1Attribute
       }
       return true;
    }, { message: 'type1Attribute is required if type is type1', path: ['dynamicValue']]),
   ...
});

but I get an error like this..

type ZodEffects<....> is not assignable to type IOSchema<any>

As a workaround, I could change my input to be something like..

z.object({ data: z.object(...).refine(...) })

But I don't want to have to define my inputs like that.

Add test data generation / test generation

Given that express-zod-api is currently able to generate swagger documentation would it be possible to add test-data generation too or even full jest test generation?
not as a replacement for tests but as a way to remove the majority of the boilerplate usually required when writing tests for express controllers.
I'm happy to write the code for this myself if needed.

Expose default 404 handler

Would it be possible to expose the default 404 handler so it can be used when using a custom express instance to provide a consistent api interface with minimal effort?

Add dependancy injection for handlers

Currently the package provides a logger in a sort of DI way which is extremely useful.
I believe it should be possible to generalise this so users could add their own dependancy objects to pass to handlers when building endpoints.
I've got some code that can sort of do this already however it is not perfect yet and would be a breaking change when it shouldn't be so not ready for a pull request yet.
An example usage would be

new EndPointFactory(...)
  .addDependancy({db:SomeDataBaseObject})
  .build({
    input:...
    output:...
    handler: async ({input, db, logger})=>{
      db.query();
      return ...;
    }),
  });

Sorry for any mistakes, its 04:00 am here.
What do you think of this?
I'd also like to expand it to add dependancies that get created with a function on each request too for stuff like specific database connections instead of a single database object.

Body parser to connect to your own express app

Hello,

I'm following documentation https://github.com/RobinTail/express-zod-api#connect-to-your-own-express-app and it states Please note that in this case you probably need to parse request.body

What I do is:

import bodyParser from "body-parser";

export const postUserEndpointInput = z.object({
  id: z.number(),
  ids: z.number().array(),
});

export const postUserEndpoint = defaultEndpointsFactory
  .use(bodyParser.json()) // <-- express middleware to parse body
  .build({
    method: "post",
    input: postUserEndpointInput,
    output: z.object({
      id: z.number().optional(),
      ids: z.number().array().optional(),
    }),
    handler: async ({ input: { id, ids }, logger }) => {
      logger.debug("id", id); // type: number
      logger.debug("ids", ids); // type: number[]
      return { id, ids };
    },
  });
type PostUserEndpointInput = z.infer<typeof postUserEndpointInput>;

const postInput: PostUserEndpointInput = {
  id: 123,
  ids: [1, 2, 3, 4, 5]
}

fetch('/v1/post-user', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(postInput)
})
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) => (error.value = err))

But POST request always fail the validation. Looks like body is never parsed.

How to handle body in a correct way with custom express integration?

Direct access to endpoint handler

Hi and first of all thanks for express-zod-api.
To avoid redundant code it would be useful in my case to reuse an endpoint handler.
I explain better the use case, suppose you have two endpoints:

  • api/company/create that create a company record and the owner profile
  • api/profile/create that create a profile in the company, regardless of its role.

Calling the first endpoint I have to provide also the data to create the first profile, so in the first endpoint's handle I would like to use the functionality offered by the second endpoint.
I know that I can workaround this by export the handle function directly and use it, but in this case I will lose some features like the parsing of the input and the output, access to options, etc.

do you have any suggestions?
Thanks

Async refinements

Hello,

First of all, thank you very much for your package!

I'm starting using it and I'm running into an issue. I have a piece of code that looks like this:

input: z.object({
        username: z.string().nonempty().max(100).refine(async username => {
            return await someAsyncFunction(username} == 0
       })
}),

I'm doing an async refinement, which should be allowed according to Zod docs. But I am getting this error:
Async refinement encountered during synchronous parse operation. Use .parseAsync instead..

It looks like my username is not being parsed with parseAsync() but with parse(). Is the fact that we can't use async refinements on purpose?

Question: Different I/O for different methods

Hi,

I've started trying to use this module to manage my endpoints since I love the philosophy of how it's setup. I have a question about a use case.

For an endpoint with multiple methods (i.e. post and get) is it possible to have an input and output for post that is different than the input and output for get? Or, I guess a way to attach different endpoint definitions to the same route would also work?

How to annotate the type of a streamed file response

I'm looking at moving my apis over to use express-zod-api however one endpoint returns large zipped files using res.sendFile() .
The example says that this is possible but doesn't explain what sort of output validation / typing information I should give for streamed responses.
Do I just use string or is there a better option?

`DELETE` endpoints with `requestBody` leads to an OpenAPI semantic error

It looks like OpenAPI spec forbids a request body for DELETE operations:

Although request message framing is independent of the method used, content received in a DELETE request has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request and close the connection because of its potential as a request smuggling attack

No, you cannot use the OpenAPI 3.0 Specification and Swagger tools to implement DELETE requests with a request body


Steps to reproduce:

  • add endpoint with delete method like this:
defaultEndpointsFactory.build({
  methods: ["delete"],
  input: z.object({}),
...

Actual behaviour:

There is a semantic error DELETE operations cannot have a requestBody in OpenAPI spec

Expected behaviour

OpenApi spec generates without requestBody for DELETE operations

Refined inputs

Are there any future plans to allow refining of an input, and not just it's properties?

For example, I want to check that an input has at least one property, but I don't care which one.

endpointsFactory.build({
  method: "post",
  input: z
    .object({
      email: z.string().email().optional(),
      id: z.string().optional(),
      otherThing: z.string().optional()
    })
    .refine(
      x => Object.keys(x).length >= 1,
      'Please provide at least one property'
    ),
  output: z.object({
    /* ... */
  }),
  handler: async ({ input, logger }) => {
    /* ... */
  },
});

Currently the above will fail as the input is of type ZodEffects which cannot be assigned to IOSchema.

Or maybe there's a better solution to the above example

How to get req.query req.params if we dont know the shape

I have an app where we want to accept a wide number of params and query and pull it all together as a object.

I was wandering if i we could use input: z.record(z.string(), z.string()) it would accept them and pull those together

Existing Middleware

       const options = {};

        Object.keys(req.query).forEach(param => options[param] = req.query[param]);
        Object.keys(req.params).forEach(param => options[param] = req.params[param]);

        req.opts = options;

Assigning a singular `Security` schema to a `Middleware` leads to an error.

Discussed in #803

Originally posted by McMerph February 8, 2023
I want to specify that a particular middleware uses JWT Bearer Authentication.
So, I added:

security: { type: "bearer", format: "JWT" }

to createMiddleware.
Unfortunately it didn't work -(
Error is:

TypeError: methods[security.type] is not a function
    at {{PROJECT_DIR}}/node_modules/express-zod-api/src/open-api-helpers.ts:801:69
    at {{PROJECT_DIR}}/node_modules/express-zod-api/src/logical-container.ts:40:15
    at Array.map (<anonymous>)
    at mapLogicalContainer ({{PROJECT_DIR}}/node_modules/express-zod-api/src/logical-container.ts:37:28)
    at depictSecurity ({{PROJECT_DIR}}/node_modules/express-zod-api/src/open-api-helpers.ts:800:29)
    at onEndpoint ({{PROJECT_DIR}}/node_modules/express-zod-api/src/open-api.ts:120:25)
    at {{PROJECT_DIR}}/node_modules/express-zod-api/src/routing-walker.ts:46:9
    at Array.forEach (<anonymous>)
    at {{PROJECT_DIR}}/node_modules/express-zod-api/src/routing-walker.ts:45:15
    at Array.forEach (<anonymous>)

What am I doing wrong?


security: { type: "bearer" }

doesn't work either.

Actually

security: { or: [{ type: "bearer", format: "JWT" }] }

or

security: { and: [{ type: "bearer", format: "JWT" }] }

both work but https://editor.swagger.io/ reports structural errors in this case (screenshot provided).
Both the middleware and the endpoint have an empty input (input: z.object({})) if that matters.
image

OpenAPI creation does not work under debugger on Windows

Under debugger launched in VSCode on Windows the following method:

const swaggerDocument = new OpenAPI({
      routing: router,
      version: '0.0.1',
      title: 'API',
      serverUrl: 'https://example.com',
}).getSpec();

throws the error: Zod type ZodObject is unsupported

If I run the same code from the console it works fine.

NodeJS: v14.17.3
TS: v4.2.4

Output arrays and instances. Requirement of `input`.

I get type error while declaring output from defaultEndpointsFactory build method. I couldn't find any answer in the documentation.

"typescript": "^4.7.4",
"express-zod-api": "^8.3.4",

CleanShot 2022-11-22 at 22 20 27@2x

// imports
import { Feature, FeatureService } from '@entities/feature.entity';
import { 
  createServer, 
  Routing, 
  z, 
  defaultEndpointsFactory,
  createResultHandler,
  createApiResponse,
  IOSchema,
  EndpointsFactory,
  createConfig,
} from "express-zod-api";

// code
const getFeatures = defaultEndpointsFactory.build({
  method: "get",
  output: z.any().array(),
  handler: async () => {
    const features = new FeatureService();
    const featureList = await features.list();
    return featureList;
  },
});

CleanShot 2022-11-22 at 22 43 25@2x

The type error: (property) output: z.ZodArray<z.ZodAny, "many">

Type 'ZodArray<ZodAny, "many">' is not assignable to type 'IOSchema<any>'.
  Property 'innerType' is missing in type 'ZodArray<ZodAny, "many">' but required in type 'ZodEffects<ZodObject<any, any, ZodTypeAny, { [x: string]: any; }, { [x: string]: any; }>, { [x: string]: any; }, { [x: string]: any; }>'.ts(2322)
types.d.ts(631, 5): 'innerType' is declared here.
endpoints-factory.d.ts(14, 5): The expected type comes from property 'output' which is declared here on type 'BuildProps<IOSchema<any>, IOSchema<any>, null, {}, "get", string, string>'

Question: how to define route parameters?

Hello.
I have a question, it is possible to configure route parameter in endpoint url? For example for route /user/:id i want to get id parameter somewhere in handler parameter, like it is happens for query parameters.
Thanks.

http(s) server has no types

hi there.
When referencing either httpServer or httpsServer, these objects seem to miss their types.


only app and logger objects have their types



Node version: 16.17.0
TypeScript Compiler version: 4.8.4
Visual Studio Code version: 1.73.0 (user setup)
express-zod-api package version: 8.3.4

body-parser errors are not handled as HttpErrors

Steps to reproduce:

  • use createServer with default server.jsonParser to start the server
  • send request with invalid JSON. e.g.:
curl --location 'http://localhost:{{port}}/whatever-path' \
--header 'Content-Type: application/json' \
--data '{
    a: "b"
}'

Actual behaviour:

The server responds with a 500 error

Expected behaviour

The server responds with a 400 error


One possible workaround is to pass a custom jsonParser:

import { isHttpError } from "http-errors";

...
const rawJsonParser = express.json();
const jsonParser: typeof rawJsonParser = (request, response, next) => {
  rawJsonParser(request, response, (error) => {
    if (error instanceof Error) {
      response.statusCode = isHttpError(error)
        ? error.status
        : getStatusCodeFromError(error);
      response.end(getMessageFromError(error));
    } else {
      next(error);
    }
  });
};
...
createServer(createConfig({
...
server: {
  jsonParser,
  ...
}
}), routing)

Note that code above will not work without the isHttpError check, because there is no such check inside getStatusCodeFromError. @RobinTail, Is it intentional?

Examples of arrays are being merged

From discussion #361.

test("should return array of arrays", () => {
  expect(
    getExamples(
      withMeta(z.array(z.number().int())).example([1, 2]).example([3, 4]),
      false
    )
  ).toEqual([
    [1, 2],
    [3, 4],
  ]);
});

it fails with:

 FAIL  tests/unit/common-helpers.spec.ts
  ● Common Helpers › getExamples() › should return array of arrays

    expect(received).toEqual(expected) // deep equality

    - Expected  - 4
    + Received  + 0

      Array [
    -   Array [
        1,
        2,
    -   ],
    -   Array [
        3,
        4,
    -   ],
      ]

So, getExamples merges the arrays into one.
I believe it is because of concat at

return carry.concat(

Originally posted by @McMerph in #361 (reply in thread)

Bug: Cannot set description on `dateIn` and `dateOut` fields

First of all: Great job on this library! It manages to strike an almost perfect balance between being super quick and easy to get started with while also not being too restrictive or opinionated. Kind of like tRPC but more "public-API-friendly".

Anyway, my team and I discovered a small bug in how the OpenAPI schema is being generated:

All fields with the dateIn or dateOut type don't keep the descriptions assigned using .describe(). Instead, they always have the description set to YYYY-MM-DDTHH:mm:ss.sssZ. It seems to be caused by the depictDateIn and depictDateOut functions not taking into account custom descriptions.

improve handling of non-object errors thrown in endpoint handlers

oh boy, i just spent the last couple hours running in circles trying to figure this one out. my API endpoint was returning {"status":"success"} with no data, which was then causing errors when my generated client was assuming "status":"success" means data should be present

the culprit is this:

  1. the error is only passed to the result handler if it's instanceof Error -
    if (e instanceof Error) {
    error = e;
    }
  2. if there's no error passed to the result handler, it returns a 200 and a success status regardless of whether there's any real data or not -
    if (!error) {
    response.status(200).json({
    status: "success" as const,
    data: output,
    });
    return;
    }
  3. turns out that running prettier programmatically throws a string 😅 - this was in my code

I was about to open a PR up but I'm actually not really sure how you want to handle this since this will be a breaking change, albeit a needed one IMO (probably before the v8 release?). the two obvious choices seem to be:

  1. widening the typings on the ResultHandler so you can pass in any kind of error (thus passing the onus to the default handler or the downstream consumer to handle more error cases)
  2. just stringify any non-object error and create an Error object out of it

the latter seems way easier and with less breaking changes, whereas the former will certainly break any custom result handler implementations. but if you're gonna make a breaking change anyway, I think the former is the way to go because if you just coerce everything into a string then it gives downstream consumers no way to implement custom error handling based on the type of the error

so my suggestion is to change the error type in the ResultHandler to unknown, then check if it's !== undefined (or != null to handle both undefined & null?) rather than just checking if it's falsey like you currently do. but not sure if you've got some other thoughts on how to better handle this?

using jest and ts-jest with express-zod-api

Hello there.
I am trying to setup a project and have tests with jest and ts-jest.
The installation of jest itself is pretty smooth.
However, whenever I try to install ts-jest, i get the following error:
image
image

Is it possible to install ts-jest along with the express-zod-api package without forcing it with either of the --force, or --legacy-peer-deps flags?

P.S., Here is some info about my environment:

OS: Windows 10 | 64 bit
Node.js Version: 16.17.0
npm Version: 8.17.0

API Routes with multiple `-` in the path breaks client library generation

When an API route has multiple - in the path, for example /api/test-this-one-out, generating the client-side library breaks.

Specifically, the type names that are generated include all the - characters after the first one. My guess is there is a replace that should be a replaceAll or something along those lines

An example screenshot is attached
Screen Shot 2023-02-09 at 4 20 36 PM

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.