Skip to content

feat: implement msw plugin#3570

Open
malcolm-kee wants to merge 3 commits intohey-api:mainfrom
malcolm-kee:feat/msw-plugin
Open

feat: implement msw plugin#3570
malcolm-kee wants to merge 3 commits intohey-api:mainfrom
malcolm-kee:feat/msw-plugin

Conversation

@malcolm-kee
Copy link
Contributor

@malcolm-kee malcolm-kee commented Mar 13, 2026

Closes #1486

Summary

Implement msw plugin that generates a msw.gen.ts file with type-safe mock handler factories from OpenAPI specs. Each operation is exported as a named handler creator (<operationId>Mock) with a wildcard base URL, plus a getAllMocks helper to generate handlers for all operations at once. A createMswHandlerFactory function is also exported for custom base URL binding.

Important

Even though many expect fake data generation is part of this plugin, that probably overlaps with faker plugin. The only mock data handled by this plugin at the moment is the example defined in the OpenAPI spec.

API Design

Configuration

export default {
  plugins: [
    {
      name: "msw",
      valueSources: ["example"], // set to [] to disable example embedding
    },
  ],
};

Usage

Individual handler exports (wildcard base URL)

import { HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { getPetByIdMock, updatePetMock, getInventoryMock, getAllMocks } from "./client/msw.gen";

const server = setupServer(
  // Static response — type-checked result, status defaults to dominant success code
  getPetByIdMock({ result: { id: 1, name: "Fido", photoUrls: [] } }),

  // Explicit status code
  getPetByIdMock({ status: 200, result: { id: 1, name: "Fido", photoUrls: [] } }),

  // Custom resolver — params and request body are typed
  updatePetMock(async ({ request, params }) => {
    const body = await request.json();
    return HttpResponse.json({ id: Number(params.petId), ...body }, { status: 200 });
  }),

  // Operations with spec examples — no args needed
  getInventoryMock(),
);

Handler options

MSW handler options can be passed as a second argument:

getPetByIdMock({ result: { id: 1, name: "Fido" } }, { once: true });

Custom base URL (createMswHandlerFactory)

import { createMswHandlerFactory } from "./client/msw.gen";

// Explicit base URL
const createMock = createMswHandlerFactory({
  baseUrl: "http://localhost:3000",
});

// No args — infers base URL from spec's servers field
const createMock2 = createMswHandlerFactory();

const server = setupServer(
  createMock.getPetByIdMock({ result: { id: 1, name: "Fido", photoUrls: [] } }),
);

All handlers (getAllMocks)

import { getAllMocks } from "./client/msw.gen";

// Quick setup — all operations with defaults
setupServer(...getAllMocks());

// Strict mode — missing mocks return 501
setupServer(...getAllMocks({ onMissingMock: "error" }));

// With overrides — keys are handler names (<operationId>Mock)
setupServer(
  ...getAllMocks({
    onMissingMock: "skip",
    overrides: {
      getPetByIdMock: {
        result: { id: 1, name: "Fido", photoUrls: [] },
      },
    },
  }),
);

Design decisions

Why <operationId>Mock naming? — Appending Mock avoids naming collisions with other generated artifacts (types, SDK functions) while keeping the handler clearly associated with its operation.

Why both individual exports and createMswHandlerFactory? — Individual exports use a wildcard (*) base URL for zero-config convenience. The factory function allows binding to a specific base URL when needed (e.g. integration tests against a specific server).

Why valueSources instead of example: boolean? — Extensible for future sources (e.g. ['example', 'faker'] when faker plugin is ready).

onMissingMock — Operations that require a response argument (no default example) are either skipped ('skip') or return a 501 ('error'). Overrides always take precedence.

Handler creator signatures

Operation has Parameter type Optional?
Response type with status codes { result, status? } | ToResponseUnion<Responses> | HttpResponseResolver<PathParams, Body> No*
Response type, void { result, status? } | ToResponseUnion<Responses> | HttpResponseResolver<PathParams, Body> Yes
No response type (no status code) HttpResponseResolver<PathParams, Body> Yes

* Optional if the spec defines an example for the dominant response.

Response method selection

Content type Method
application/json HttpResponse.json()
text/* HttpResponse.text()
binary/octet-stream new HttpResponse()
void / no content new HttpResponse(null)

When multiple 2xx responses exist, the dominant one is chosen by priority: json > text > binary > void.

Known limitations

  • Response type generic is omitted from HttpResponseResolver to avoid MSW's DefaultBodyType constraint issues with union/void response types
  • Query parameters are not typed in resolvers (MSW doesn't support typed query params natively)
  • Only 2xx responses are considered for the dominant response

@bolt-new-by-stackblitz
Copy link

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@pullfrog
Copy link

pullfrog bot commented Mar 13, 2026

Error

agent completed without reporting progress

Pullfrog  | Rerun failed job ➔View workflow run | Triggered by Pullfrogpullfrog.com𝕏

@vercel
Copy link

vercel bot commented Mar 13, 2026

@malcolm-kee is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot
Copy link

changeset-bot bot commented Mar 13, 2026

⚠️ No Changeset found

Latest commit: 8d0e313

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. feature 🚀 Feature request. labels Mar 13, 2026
@malcolm-kee malcolm-kee mentioned this pull request Mar 13, 2026
4 tasks
@codecov
Copy link

codecov bot commented Mar 13, 2026

Codecov Report

❌ Patch coverage is 16.77560% with 382 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.62%. Comparing base (05f6fdf) to head (8d0e313).

Files with missing lines Patch % Lines
...kages/openapi-ts/src/plugins/msw/handlerCreator.ts 7.69% 101 Missing and 19 partials ⚠️
examples/openapi-ts-fetch/src/client/msw.gen.ts 33.13% 99 Missing and 16 partials ⚠️
packages/openapi-ts/src/plugins/msw/plugin.ts 1.20% 74 Missing and 8 partials ⚠️
...napi-ts/src/plugins/msw/computeDominantResponse.ts 8.92% 33 Missing and 18 partials ⚠️
...pi-ts/src/plugins/msw/sortHandlersBySpecificity.ts 12.50% 12 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3570      +/-   ##
==========================================
- Coverage   39.23%   38.62%   -0.62%     
==========================================
  Files         513      530      +17     
  Lines       18773    19906    +1133     
  Branches     5567     6006     +439     
==========================================
+ Hits         7365     7688     +323     
- Misses       9218     9860     +642     
- Partials     2190     2358     +168     
Flag Coverage Δ
unittests 38.62% <16.77%> (-0.62%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mrlubos
Copy link
Member

mrlubos commented Mar 13, 2026

@malcolm-kee Before I go into it, two questions:

  1. How much AI was used to create this pull request?
  2. How much are you willing to improve it? i.e. Is this the final version?

@malcolm-kee
Copy link
Contributor Author

@mrlubos I come up with the API design and AI was doing most of the implementations while I watch.

Not final. I'm happy to iterate on this, just want some progress on this plugin.

@malcolm-kee
Copy link
Contributor Author

The diff is big is mostly because of the tests and snapshots.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 13, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3570

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3570

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3570

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3570

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3570

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3570

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3570

commit: 8d0e313

@malcolm-kee
Copy link
Contributor Author

I did some refactoring/enhancements:

  • remove duplications of type definition and function param definition. It's only defined at type level and the implementation param is auto-inferred from that.
  • extract example from schema as default value.

@malcolm-kee
Copy link
Contributor Author

malcolm-kee commented Mar 14, 2026

@mrlubos I manually validated the code and made some refactoring. It would be great if you can provide some feedbacks, especially on the API.

@malcolm-kee malcolm-kee force-pushed the feat/msw-plugin branch 2 times, most recently from ac3c300 to b34b5bd Compare March 15, 2026 04:36
@malcolm-kee
Copy link
Contributor Author

malcolm-kee commented Mar 15, 2026

More revision:

  • Redesign the API - the static parameter becomes { status: number; result: ResultType } instead of just ResultType. This design allows typescript to infer the types better with a stable object type with explicit properties instead of a more generic ResultType. This also make it easier to overwrite the status code without falling back to use the custom resolver approach.
  • Forward the handler options to msw, so no lost of capabilities.
  • Added resolveToNull helper function to remove duplicate fallbacks

@malcolm-kee
Copy link
Contributor Author

Added examples options to the plugin.

@malcolm-kee
Copy link
Contributor Author

Change plugin options from examples: boolean to valueSources: Array<'example'>, so that when fakerjs is ready, we just need to switch the default value to valueSources: ['example', 'faker'].

@malcolm-kee malcolm-kee force-pushed the feat/msw-plugin branch 2 times, most recently from e3e18f0 to 7bb2ae4 Compare March 16, 2026 09:20
@malcolm-kee
Copy link
Contributor Author

malcolm-kee commented Mar 16, 2026

Ideas on how to continue enhancing this PR, in case anyone want to take over this, since I might not be free to iterate on this:

Implement ofAll

We can actually implement ofAll helper without waiting for faker plugin, by providing options to customize its behavior:

const createMock = createMswHandlerFactory();

const allMockHandlers = createMock.ofAll({
  onMissingMock: 'skip', // 'skip' | 'error',
  overrides: {
    getPetById({
      status: 200,
      result: { id: 1, name: "Fido", photoUrls: [] },
    })
  }
});

const server = setupServer(...allMockHandlers);

onMissingMock option:

  • skip: we will not include the MSW handlers that requires argument in the returned array of requestHandlers. This is possible because we can infer if argument is required for a handler (I differentiate them by providing them different types - HttpHandlerFactory are those require argument while OptionalHttpHandlerFactory are those does not require argument)
  • error: we will include the MSW handlers for all, but for those require argument, we will return HttpResponse('[heyapi-msw] The mock of this request is not implemented.', { status: 501 })

overrides option API is similar to the API of single handler, but instead of calling individual helper, user can define all of them at once here.

@mrlubos
Copy link
Member

mrlubos commented Mar 17, 2026

@malcolm-kee Which prior art did you consider when designing this plugin? In other words, how did you come up with the .of pattern? I don't think I've seen this approach before. Not saying it's right or wrong, just curious

@malcolm-kee
Copy link
Contributor Author

@mrlubos that's a good question. I think I was influenced by some Java library that I've been reading recently, which I think is not a good reference. 😆

I'm happy to change to other APIs.

@malcolm-kee
Copy link
Contributor Author

Ideas on how to continue enhancing this PR, in case anyone want to take over this, since I might not be free to iterate on this:

Implement ofAll

We can actually implement ofAll helper without waiting for faker plugin, by providing options to customize its behavior:

Implemented

@mrlubos
Copy link
Member

mrlubos commented Mar 17, 2026

@mrlubos that's a good question. I think I was influenced by some Java library that I've been reading recently, which I think is not a good reference. 😆

I'm happy to change to other APIs.

@malcolm-kee Have you used any other generators in the past? Did you write MSW by hand?

What would be helpful is noting what works and what doesn't work in other solutions. I'm going to play with this pull request as well and compare.

More context:
I'm wary of merging this one quickly because as soon as you do that people will start using it, and any drastic changes will cause major pain. I view this plugin as 1 of 2 remaining crown jewels (along with Faker) so my expectations are it needs to be way better than anything else out there (including MSW Source 😈)

@malcolm-kee
Copy link
Contributor Author

I did write MSW by hand, and that's why I actually implemented this API for a private project a while ago, by parsing the OpenAPI spec and use ts-morph to read the output file of hey-api/typescript. That code was pretty ugly because I also did some logic to generate fake data.

Is there any chance we can publish this as experimental or with next tag?

@malcolm-kee
Copy link
Contributor Author

I didn't try others as they wasn't what I really wanted, because the pain point wasn't just about the fake data, it was also the need to look at the implementation details of each API call code generated by hey api, and translate it to how to define them for MSW.

Copy link
Member

@mrlubos mrlubos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@malcolm-kee couple smaller ones

@mrlubos
Copy link
Member

mrlubos commented Mar 17, 2026

I did write MSW by hand, and that's why I actually implemented this API for a private project a while ago, by parsing the OpenAPI spec and use ts-morph to read the output file of hey-api/typescript. That code was pretty ugly because I also did some logic to generate fake data.

Is there any chance we can publish this as experimental or with next tag?

Yes. I won't do another release for a while but after it's in main it will be under next

@mrlubos
Copy link
Member

mrlubos commented Mar 17, 2026

I didn't try others as they wasn't what I really wanted, because the pain point wasn't just about the fake data, it was also the need to look at the implementation details of each API call code generated by hey api, and translate it to how to define them for MSW.

@malcolm-kee can you show me an example or explain more? In the past I'd always tell people they can use another generator for MSW with Hey API for the rest. Are you saying that's not possible/painful?

@kettanaito
Copy link

My two cents: I find the .of chaining to have no practical value. I'd rather use something like mocks.getPetById(). Depending on the spec shape, you can go even wilder with ergonomics by doing things like mocks.pets.all(), mocks.pets.findUnique(), etc.

@malcolm-kee
Copy link
Contributor Author

can you show me an example or explain more? In the past I'd always tell people they can use another generator for MSW with Hey API for the rest. Are you saying that's not possible/painful?

I did some homework, here is what I found:

Orval

This is actually very similar to what we have now, the API is very similar. But it doesn't make sense to use it in conjunction of hey-api, since Orval is like a replacement of Hey API.

import { HttpResponse, delay, http } from 'msw';
import type { RequestHandlerOptions } from 'msw';
export const getShowPetByIdMockHandler = (
  overrideResponse?:
    | Pet
    | ((info: Parameters<Parameters<typeof http.get>[1]>[0]) => Promise<Pet> | Pet),
  options?: RequestHandlerOptions,
) => {
  return http.get('*/pets/:petId', async (info) => {
    await delay(1000);
    return HttpResponse.json(
      overrideResponse !== undefined
        ? typeof overrideResponse === 'function'
          ? await overrideResponse(info)
          : overrideResponse
        : getShowPetByIdResponseMock(),
      { status: 200 },
    );
  }, options);
};

msw-auto-mock

It generates a worker entry point. No granular overwrite.

openapi-msw

This is a type-helper that depends on schema generated by OpenAPI-TS. The functions exposed by the library is more to achieve type-safety.

@malcolm-kee
Copy link
Contributor Author

malcolm-kee commented Mar 18, 2026

There are a few weaknesses with the Orval supports:

  • can't overwrite base URL in runtime
  • it doesn't allow returning non success response

but I like the idea of using '*' as default base URL.

I think @kettanaito point is valid, so I think we could do

import { setupServer } from "msw/node";
import { createMswHandlerFactory } from "./msw.gen";

const mocks = createMswHandlerFactory({})

const server = setupServer(
  ...mocks.getAllMocks(),
  mocks.getPetByIdMock({ status: 200, result: { id: 1, name: "Fido", photoUrls: [] } }),
);

The naming clashing is avoided with

  • individual mock always ends with <operation>Mock
  • the all mocks is always getAllMocks

In fact, to be consistent with other client code, createMswHandlerFactory could be even optional if no overwriting of baseUrl is required:

import { setupServer } from "msw/node";
import { getAllMocks, getPetByIdMock } from "./msw.gen";

const server = setupServer(
  ...getAllMocks(),
  getPetByIdMock({ status: 200, result: { id: 1, name: "Fido", photoUrls: [] } }),
);

Those default mock factory would be binded with baseUrl: '*'.

@mrlubos
Copy link
Member

mrlubos commented Mar 19, 2026

@malcolm-kee did you see my code comments?

@malcolm-kee malcolm-kee force-pushed the feat/msw-plugin branch 2 times, most recently from 88bba39 to 863c7e2 Compare March 19, 2026 11:56
@malcolm-kee
Copy link
Contributor Author

malcolm-kee commented Mar 19, 2026

I've made a few more improvements, other than the one mentioned here:

  • status is no longer required. Only result is required for the simpler use case. status is only required when the endpoint supports multiple status code and user wants emulate the non dominant status.
  • No more toMswPath helper in runtime, it's being evaluated in build time. I've also fixed an edge case that I didn't realize. MSW use path-to-regexp, which only supports word only character for param names (e.g. api-version is invalid). I've handled that now.

@mrlubos
Copy link
Member

mrlubos commented Mar 19, 2026

@malcolm-kee I think that's all for now! I'll review this pull request later and potentially make changes to the plugin, but as far as the initial functionality is concerned, this looks good!

@malcolm-kee
Copy link
Contributor Author

Some ideas to explore, probably not going to implement it in this PR:

  • randomDelayMs - add a delay call before returning the result. This makes the order of resolving network request unpredictable, which may be desirable for some team as it might surface race condition. We'll add await delay(Math.random() * CONFIGURED_VALUE). But if nobody requests maybe just point them to solution provided by msw out of the box: https://mswjs.io/docs/recipes/global-response-delay#option-1-a-delay-passthrough-handler
  • handle SSE - I'm not familiar with how it works in OpenAPI spec and how HeyAPI supports it right now, but since msw has SSE support, this is definitely worth exploring.
  • Docs: realistic life example of how to compose the provided mockHandlers with custom convention. Like mergeCrudMocks({ createHandler, updateHandler, deleteHandler, idFactory, defaultData }). Ideally in a typesafe way.

@malcolm-kee
Copy link
Contributor Author

malcolm-kee commented Mar 22, 2026

Fix another edge case:

In getAllMocks, specific handler should goes first. To handle the edge case for overlapping routes, like:

  • /api/permissions/:userId
  • /api/permissions/all

If the OpenAPI spec specifies the former first, it would shadow the latter. This scenario is raised in Orval repo, so let's just fix it.

@malcolm-kee
Copy link
Contributor Author

More edge cases to be fixed.

MSW actually has support of supports of different content type:

  • HttpResponse.html
  • HttpResponse.xml
  • HttpResponse.formData
  • HttpResponse.arrayBuffer

I think we should map to those field whenever we can.

This wouldn't be a blocker, user can always overwrite the entire handler and returns with those MSW helpers, but it would be great if we can do them correctly in the default handler.

@malcolm-kee
Copy link
Contributor Author

Simplify types - Only include ToResponseUnion if multiple status code is available.

@malcolm-kee
Copy link
Contributor Author

I think there are still gap in the API. Right now the generated code use the 'dominant' response to determine if it should use HttpResponse.json or HttpResponse.text etc, but that's conceptually incorrect. OAS supports support multiple response types for a single status code. As a result, other than result and status, the override object should include another property, contentType to allows user to explicitly set the type of response they want.

Again, this is not a blocker because users always can provide custom function, but the type we're providing may be misleading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 🚀 Feature request. size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MSW plugin

3 participants