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:
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.
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:
- use
openapi-typescript
library to create TypeScript types for the OpenAPI-spec, including all request-, parameter- and response-types - And write the result to the file
- Create an interface
RestMsw
in the same file - 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:
- write-mock-service-worker-types.mjs generates the type definitions
- openapi-msw-types.ts contains the generated result
- openapi-msw/index.ts contains some glue-code to use the types
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.