Type-safe mock-backends with Mock Service Worker and OpenAPI

We can convert OpenAPI specs to TypeScript clients. We can create mock-APIs with the mock-service-worker library. How about converting an OpenAPI spec to TypeScript types for the Mock Service Worker library. It's magic...

In my last posts, I wrote about

  • generating type-safe frontend code based on OpenAPI-specs (see “Type-safe API clients”)
  • using the Mock Service Worker library for testing frontend-code without having to run a real backend (see Mock APIs).

So why don’t we generate types for Mock Service Worker based on the OpenAPI-spec as well?

Imagine you have to adjust your tests due to breaking changes in the backend. Of course, you can run your tests and see which ones fail. But with generated types, you get a complete list of errors just by running TypeScript.

And I cannot stop my self from pointing out that you get

  • auto-completion and
  • validation

directly in your IDE. This can be a huge efficiency booster when writing tests.

I couldn’t find an existing library that creates such types, so I decided to implement my own. The result is this:

Animation showing autocompletion support for msw with TypeScript

The “not so good way”

Before going to my implementation, I want to talk about the naive approach and its downsides.

Since we already have types for the OpenAPI-spec (generated by oazapfts or openapi-client-axios), why not just use those. For example UserResponse is generated and exported by oazapfts and we could write:

import { UserResponse } from "src/backend/__generated__/openapi-client";

rest.get("/api/users/:username", (req, res, context) => {
  const body: UserResponse = {
    username: "bob",
    roles: ["ADMIN"],
  };
  return res(context.json(body));
});

But if the type UserResponse is used in our code, the name UserResponse in the OpenAPI spec will cause the code to not compile anymore. Also, the library oazapfts will become hard to replace.

This is way too fragile and frustrating when refactoring the API. There should be no need to specify types explicitly. What we really want is this code:

const handler = rest.get("/api/users/:username", (req, res, context) => {
  return res(
    context.json({
      username: "bob",
      roles: ["ADMIN"],
    }),
  );
});

But we want compile-errors if /api/users/:username is not a valid path or if { username: "bob", roles: ["ADMIN"] } does not match the required schema of this endpoint.

Implementing a code-generator

I have not created a dedicated library on npm. Instead, I wrote this code directly in the Gachou project.

tldr;

You don’t want any explanations? Go straight to the generated code. and to the generator Just note that it implements another feature as well, that I will write about later.

Understanding the Mock Service Worker types

By default, if you don’t specify and generic types like in

const handler = rest.get("/users/:id", (req, res, context) => {
  /* ... */
});

the following types apply

  • The first parameter /users/:id can be any string.
  • req.body will be typed as any object (Record<string, any>) with some alternatives (string etc.).
  • req.params will be an object of strings or string arrays.
  • context.json({...}) can also receive any object.

There is a lot of “any” in that list. But, the function (req, res, context) => {} is typed as ResponseResolver, which has generic type arguments for all parameters. Let’s create a more specific response resolver:

const resolver: ResponseResolver<
  RestRequest<{ password: string }, { username: string }>,
  RestContext,
  { success: boolean }
> = (req, res, context) => {
  /* ... */
};

RestRequest has two parameters: BodyType and PathParamsType. In this function it is valid to access req.body.password and req.params.username, but not req.params.password.

The third parameter { success: boolean } is the response body type, so you can call res(context.json({success: true})), but not res(context.json({wrong: 'prop'})).

What types do we want to generate?

In order to get TypeScript support for all endpoints in the OpenAPI spec, our rest object needs to have a type similar to this:

export interface RestMsw {
  put(
    path: "/api/users/:username",
    resolver: ResponseResolver<
      RestRequest<
        operations["updateUser"]["requestBody"]["content"]["application/json"],
        operations["updateUser"]["parameters"]["path"]
      >,
      RestContext,
      operations["updateUser"]["responses"][200]["content"]["application/json"]
    >,
  ): RestHandler;
}

We can add multiple such put-methods with different paths and resolvers.

How do we generate the response types?

In my project, I have used openapi-typescript to create all the types for responses, requests and parameters. Maybe there are better solutions, but this one works quite well. The generated type contains on object for each operation (i.e. “updateUser”), which in turn contains types for requestBody, parameters and responses. By accessing

operations["updateUser"]["requestBody"]["content"]["application/json"];

you get the TypeScript type for the request-body of the “updateUser” operation. In order for this to work, you have to define the operationId for each OpenAPI operation.

Generate the code

The nice thing about OpenAPI is, that it is just plain JSON. That’s why it is easy to parse and iterate a spec in JavaScript. In order to generate the types, we do the following:

  1. use openapi-typescript library to create TypeScript types for the OpenAPI-spec, including all request-, parameter- and response-types
  2. And write the result to the file
  3. Create an interface RestMsw in the same file
  4. Write one line for each operation into the interface, as in the example above.

The exact types for request and response depend on the OpenAPI spec, of course. In case there is no defined requestBody, we can use never to prevent the use of req.body in the resolver function.

Finally, to put everything in used, you can create a TypeScript module

export const oas = rest as RestMsw;

Then you can use the oas-object just like rest, but with the benefits of having proper types for all operations.

A note about my implementation

My implementation is currently part of the project Gachou, in which I am (very slowly) attempting to build a photo- and video-management software for my family photos. In that project I had to solve a different problem: Wrapping a group of RestHandlers with some kind of middleware. That’s why the implementation is not exactly like described in this article.

Instead of letting oas.get() create a RestHandler, I create my own object RestHandlerConfig which just stores the path, method and resolver. Only when I call the setupServer-function of msw are proper RestHandlers being created from those configs. I might write an article about that as well.

Here is my code:

Will there be a library?

At the moment, my goal is to concentrate on Gachou, and write blog-posts about it. I don’t have much time to work on it and I don’t want to get distracted by creating a new npm-library. Such a library would need tests and documentation which I haven’t written so far.

If you feel ambitious enough to extract the code-generator from Gachou and publish your own package on npm, fell free to do so, but please don’t forget attribution (under the terms of the MIT license). Contact me if you have questions.

If you give me access to the repo and possibly the npm-package, I might help out. If it works well, I might replace my implementation with the library.

But I won’t do this on my own. At least not now, not in my free-time.

Conclusion

I am pretty excited about prospect of IDE support when writing api-mocks. I think the code-generator works pretty well, although it’s not perfect. For example, if you have multiple response-code in an operations, the types do not ensure that the response matches the schema of the status-code that you used in res(context.status(...)).

I think this kind of typings can save a lot of time when writing tests, especially because of the code-completion it provides.