Skip to content

feat: implement msw plugin#3570

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

feat: implement msw plugin#3570
malcolm-kee wants to merge 4 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. Includes individual handler creators (of) and a bulk helper (ofAll) to generate handlers for all operations at once.

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

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

const createMock = createMswHandlerFactory({
  baseUrl: "http://localhost:3000", // optional, inferred from spec's servers field
});

const server = setupServer(
  // Static response — type-checked status/result
  createMock.of.getPetById({ status: 200, result: { id: 1, name: "Fido", photoUrls: [] } }),

  // Custom resolver — params and request body are typed
  createMock.of.getPetById(({ params }) =>
    HttpResponse.json({ id: Number(params.petId), name: "Test", photoUrls: [] }),
  ),

  // Operations with spec examples — no args needed
  createMock.of.getInventory(),
);

// Bulk: generate handlers for all operations
setupServer(...createMock.ofAll());

// ofAll with options
setupServer(
  ...createMock.ofAll({
    onMissingMock: 'error', // 'skip' (default) omits required handlers, 'error' returns 501
    overrides: {
      getPetById: { status: 200, result: { id: 1, name: 'Fido', photoUrls: [] } },
    },
  }),
);

Design decisions

Why createMock.of.x() instead of createMock.x()? — The of namespace keeps individual handler creators and ofAll as sibling properties without naming collisions.

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 ToResponseUnion<Responses> | HttpResponseResolver<PathParams, Body> No*
Response type, void 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: e337675

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 13.01205% with 361 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.44%. Comparing base (b2de023) to head (e337675).

Files with missing lines Patch % Lines
examples/openapi-ts-fetch/src/client/msw.gen.ts 24.83% 99 Missing and 16 partials ⚠️
...kages/openapi-ts/src/plugins/msw/handlerCreator.ts 6.61% 94 Missing and 19 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 ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3570      +/-   ##
==========================================
- Coverage   39.08%   38.44%   -0.65%     
==========================================
  Files         495      511      +16     
  Lines       18451    19540    +1089     
  Branches     5479     5907     +428     
==========================================
+ Hits         7212     7512     +300     
- Misses       9071     9694     +623     
- Partials     2168     2334     +166     
Flag Coverage Δ
unittests 38.44% <13.01%> (-0.65%) ⬇️

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: e337675

Comment on lines +23 to +27
getFoo: (resultOrResolver = {
firstName: 'Marry',
lastName: 'Jane',
age: 30
}) => http.get(toMswPath('/foo', baseUrl), typeof resultOrResolver === 'function' ? resultOrResolver : () => HttpResponse.json(resultOrResolver ?? null)),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

To make the DX better, the plugin will infer the value if example is defined in the spec.

@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 16, 2026

@malcolm-kee I'm really not the best person to comment yet! I'll see what @kettanaito thinks about the output first, and I can provide input on the plugin architecture after. I'll try to make that happen this week

@kettanaito
Copy link

Hi 👋 I think the generated file looks okay to me. Otherwise, this is quite a lot of abstractions for me to provide any meaningful judgement.

As I understand, developers already have some sort of API spec and now they can generate handlers for MSW out of it. If that's the case, I think this is fine!

We have some official generators at @msw/source and I highly recommend checking it out to see how we are approaching the handler generation out of OpenAPI specs. Might be it helps here, too.

Keep up the good work and thank you!

@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

},
"peerDependencies": {
"typescript": ">=5.5.3 || 6.0.1-rc"
"msw": "^2",
Copy link
Member

Choose a reason for hiding this comment

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

@malcolm-kee why add msw here? what does it do? it would be effectively the first plugin peer dependency

*
* @default ['example']
*/
valueSources?: Array<'example'>;
Copy link
Member

Choose a reason for hiding this comment

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

Readonly probably safer here?

Suggested change
valueSources?: Array<'example'>;
valueSources?: ReadonlyArray<'example'>;

valueSources?: Array<'example'>;
};

export type MswPlugin = DefinePlugin<UserConfig, UserConfig>;
Copy link
Member

Choose a reason for hiding this comment

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

Why not add Config too?

export const defaultConfig: MswPlugin['Config'] = {
config: {
includeInEntry: false,
valueSources: ['example'],
Copy link
Member

Choose a reason for hiding this comment

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

@malcolm-kee what if someone were to supply an empty array?

@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: '*'.

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