Intergrate with `msw` mock service
Description
I think it's possible to generate mock server handler for better unit testing
@himself65 What do you do currently?
Related to https://github.com/hey-api/openapi-ts/issues/1486
Closing this in favour of https://github.com/hey-api/openapi-ts/issues/1486, please move any discussion there!
Hey @himself65, are you using MSW Source? What's the benefit for you to have Hey API generate MSW handlers?
So I don't need manually write random data code
@himself65 which parts do you still need to write manually when using MSW Source?
Also, I recently found msw-auto-mock, looks like it also covers all the functionality you might need. (When you prefer static generation over runtime)
@mkeyy0 did you end up using it? The GenAI feature looks interesting
No, I've just found it and right now trying it in my repo, I'll give you feedback a bit later during the day
After a small investigation, it looks like msw-auto-mock doesn't work for me by the following reasons:
- It looks like it is developed to be used in the browser environment . It uses the
nextfunction that increases the counter using closure after each request execution and then makes a%on a possible request length which makes the request order of subsequent requests unpredictable. So this approach doesn't work for tests - Doesn't have built-in typescript support
Also, some things to say about MSW Source. I quite don't like the approach with runtime-generated data based on the schema, it looks like it also was developed for the browser environment rather than for the test one. Also right now it is not possible to get a specific response for a specific endpoint in a test environment. You can do it by adding ?response={response_code} query parameter but this doesn't work for tests of course.
I'm not even sure if should we ask for a plugin from your side or if should it be some additional functionality provided by MSW Source package. But from my side what I'd like to have from the plugin:
- Support file generation based on the API schema of handlers. (200 status is always a default one that is sent)
- Support generation of the factory-like function to create responses described in the OpenAPI schema. For example
UserCreateUnauthorizedResponseetc. Why should it be a factory? Because one response object can't be read twice. You can see this issue for more info https://github.com/mswjs/source/issues/66 - TypeScript support.
File as output is the main feature I guess, because it simplifies debugging and you can change it, while in runtime you can't do anything.
@mrlubos
@mkeyy0 just want to say I appreciate these comments! Noted and will be taken into consideration. There's a draft pull request with the MSW plugin btw if you haven't seen, slowly working on it
Hey @mrlubos, I wanted to share our use case and suggest a feature that could improve the type generator experience when working with tests.
We use the hey-api generated SDK along with @tanstack/react-query. In our tests, we mock all HTTP requests using MSW. Since we use operation IDs in the code (thanks to the generator), we no longer deal directly with URLs—which is great. But in tests, we still need to map these operation IDs back to their real API paths to mock them properly, and that requires digging into the generated files.
For example:
// Using a hook generated by operationId (e.g., 'getAll')
useQuery({
...getAllOptions()
})
// Behind the scenes, this calls something like:
'/myservice/mycontroller/list'
To mock that endpoint, we currently create a handler manually:
export const mswGetAllHandler = ({
data,
onCall,
hasError,
isDelayed,
}: MswMock<GetAllResponse> = {}) => {
return http.get(
`/myservice/mycontroller/list`, // <-- we have to manually find this URL
async ({ request }) => {
onCall?.(request)
if (isDelayed) await delay('infinite')
if (hasError) throw new HttpResponse(null, { status: 500 })
return HttpResponse.json(data)
},
)
}
So while the generator hides API paths in the app code, in tests we still need to trace them back manually, which can be time-consuming.
We don’t need the generator to create random mock data based on schemas, since we typically provide our own. What would really help is generating MSW handler factories, using the same logic already used for SDK and react-query hooks.
Here’s what I imagine the generator could produce for each operation:
function getAllMswHandler(handler) {
return http.get<PathParams, RequestBody, ResponseType>(
'/myservice/mycontroller/list',
handler
)
}
http.get/http.postetc. → derived from the operation'smethodPathParams,RequestBody,ResponseType→ taken from OpenAPI schema, just like for SDK types/myservice/mycontroller/list→ path from the schema- OpenAPI placeholders like
{id}should be transformed to MSW format:id
With this in place, test usage would be simple and consistent:
// success state
mswServer.use(getAllMswHandler(({ req }) => {
return HttpResponse.json(data) // <-- type-checked based on OpenAPI
}))
// loading state
mswServer.use(getAllMswHandler(async ({ req }) => {
await delay('infinite')
}))
// error state
mswServer.use(getAllMswHandler(async ({ req }) => {
throw new HttpResponse(null, { status: 500 })
}))
I think this feature alone would be a huge improvement. Generating mock data could come later, but just having prebuilt handler factories with proper types and paths would save us a lot of time.
I actually started working on a custom plugin to generate these, but quickly hit a wall: most of the useful internal methods used by built-in plugins aren’t publicly exposed. Re-implementing or copying them didn’t feel like a sustainable approach.
It would be amazing if one day those utilities were made public so developers could build their own plugins more easily. But for now, it would be even better if this feature could be added to the main generator itself. 😊
I hear you @timofei-iatsenko. A couple thoughts:
- testing isn't addressed at all by the current version, that's still coming
- let's revisit your comments when I get to it, should be fun!
- most of the internals aren't exposed because they're unfinished/unstable and anything you expose will get used and if you introduce breaking changes people will be angry, no matter how many disclaimers you place everywhere 😂
- I'll be dedicating way more time to Hey API soon so hopefully this will get addressed!
I was able to implement my proposal without autogenerating, using some typescript magic for type inference and runtime mocking:
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
http,
HttpHandler,
HttpResponseResolver,
PathParams,
DefaultBodyType,
} from 'msw'
type RequestOptions = {
url: string
body: Record<string, any>
path: Record<string, any>
query: Record<string, any>
}
type SdkFn = (options: any, ...args: any[]) => any
type MswResponseResolver<Fn extends SdkFn> = Fn extends (
options: infer Opt,
...args: any[]
) => infer RequestResult
? HttpResponseResolver<
Opt extends { path: infer P }
? P extends PathParams<keyof P>
? P
: never
: never,
Opt extends { body: infer B }
? B extends DefaultBodyType
? B
: never
: never,
RequestResult extends Promise<{
data: infer ResponseData
error: infer ResponseError
}>
? ResponseData | ResponseError extends DefaultBodyType
? ResponseData | ExcludeCatchAll<ResponseError>
: never
: never
>
: never
type MswHandlerFactory<Fn extends SdkFn> = (
resolver: MswResponseResolver<Fn>,
) => HttpHandler
// Transforms OpenAPI-style placeholders `{id}` into MSW-style `:id`
function convertOpenApiUrlToMsw(url: string): string {
return url.replace(/{(\w+)}/g, (_match, p1) => `:${p1}`)
}
// This will generate a handler wrapper for one SDK function
function wrapMswHandler<Fn extends SdkFn>(sdkFn: Fn) {
return (resolver: MswResponseResolver<Fn>) => {
const mockClient: Record<string, SdkFn> = {}
// create a mock client which will call msw handler factories
;(
['head', 'get', 'post', 'put', 'delete', 'patch', 'options'] as const
).forEach((method) => {
mockClient[method] = (options: RequestOptions) => {
return http[method](convertOpenApiUrlToMsw(options.url), resolver)
}
})
// Call the SDK function to bind it with the mock client (no real request happens)
return sdkFn({
client: mockClient,
}) as HttpHandler
}
}
/**
* Create MSW handlers factories from autogenerated OpenApi SDK
* @param sdk
*/
export function createMswMocks<Sdk extends Record<string, SdkFn>>(sdk: Sdk) {
const mocks: Record<string, SdkFn> = {}
for (const key in sdk) {
const sdkFn = sdk[key]
mocks[`${key}MswHandler`] = wrapMswHandler(sdkFn)
}
return mocks as {
[K in keyof Sdk as `${string & K}MswHandler`]: MswHandlerFactory<Sdk[K]>
}
}
The usage of it is like so
import * as sdk from './__generated__/sdk.gen'
export const mswSdk = createMswMocks(sdk)
// Given we have in SDK a `setLimit` and `removeLimit` methods
mswServer.use([
mswSdk.setLimitMswHandler(({request}) => HttpResponse())
mswSdk.removeLimitMswHandler(({request}) => HttpResponse())
])
The createMswMocks function wraps every method exported from an SDK into a function which produce an msw handler. The types are correctly inferred from Options and ReturnType of the original SDK method.
Im still interested in implementing this in the generator itself, because reading this typescript magic is not always straightforward, especially when you have types mismatches, but generally it works now.
Based on https://github.com/hey-api/openapi-ts/issues/1069#issuecomment-2879231945, a refined version:
mocks/openapi-msw.ts (supports both flat SDK and class SDK now)
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Client, RequestOptions, RequestResult } from '@/api/client/types'
import type { Options } from '@/api/sdk.gen'
import { client } from '@/api/client.gen'
import * as sdk from '@/api/sdk.gen'
import {
http,
HttpHandler,
type HttpResponseResolver,
type PathParams,
type DefaultBodyType,
} from 'msw'
export type SdkFn
= ((options: any, ...args: any[]) => any)
| ((options?: any, ...args: any[]) => any)
export type SdkFnOptions<Fn extends SdkFn>
= Fn extends (options?: infer Opt, ...args: any[]) => any
? Opt extends Options ? Opt : never
: Fn extends (options: infer Opt, ...args: any[]) => any
? Opt extends Options ? Opt : never
: never
export type SdkFnOptionsPathType<Fn extends SdkFn>
= SdkFnOptions<Fn> extends { path: infer P }
? P extends PathParams<keyof P> ? P : never
: never
export type SdkFnOptionsBodyType<Fn extends SdkFn>
= SdkFnOptions<Fn> extends { body: infer B }
? B extends DefaultBodyType ? B : never
: never
export type SdkFnRequestResult<Fn extends SdkFn>
= Fn extends (options?: any, ...args: any[]) => infer Result
? Result extends RequestResult ? Result : never
: Fn extends (options: any, ...args: any[]) => infer Result
? Result extends RequestResult ? Result : never
: never
export type SdkFnRequestResultDataType<Fn extends SdkFn>
= SdkFnRequestResult<Fn> extends Promise<infer R>
? R extends { data: infer D } ? Exclude<D, undefined> : never
: never
export type SdkFnRequestResultErrorType<Fn extends SdkFn>
= SdkFnRequestResult<Fn> extends Promise<infer R>
? R extends { error: infer E } ? Exclude<E, undefined> : never
: never
export type ExcludeUnknown<T> = unknown extends T ? (T extends unknown ? never : T) : T
export type VoidToNull<T> = T extends void ? null : T
export type MswResponseResolver<Fn extends SdkFn> = HttpResponseResolver<
SdkFnOptionsPathType<Fn>,
SdkFnOptionsBodyType<Fn>,
VoidToNull<ExcludeUnknown<SdkFnRequestResultDataType<Fn>> | ExcludeUnknown<SdkFnRequestResultErrorType<Fn>>>
>
type MswHandlerFactory<Fn extends SdkFn> = (
resolver: MswResponseResolver<Fn>,
) => HttpHandler
export type FlatMswMocks<Sdk extends Record<string, SdkFn> | (new (...args: any[]) => any)> = {
[K in keyof Sdk as (Sdk[K] extends SdkFn ? `${string & K}MswHandler` : never)]: Sdk[K] extends SdkFn ? MswHandlerFactory<Sdk[K]> : never
}
export type ClassMswMocks<Sdk extends Record<string, Record<string, SdkFn> | (new (...args: any[]) => any)>> = {
[K in keyof Sdk]: FlatMswMocks<Sdk[K]>
}
function isFlatSdk(sdk: any): sdk is Record<string, SdkFn> {
if (!['object', 'function'].includes(typeof sdk)) {
return false
}
if (typeof sdk.constructor === 'function') { // Class style
return Object.getOwnPropertyNames(sdk).every((key) => {
if (key == 'length' || key == 'name' || key == 'prototype') {
return true
}
return typeof sdk[key] === 'function'
})
}
return Object.values(sdk).every((value) => typeof value === 'function' && typeof value.constructor !== 'function') // Namespace/module style
}
function isClassSdk(sdk: any): sdk is Record<string, Record<string, SdkFn>> {
if (typeof sdk !== 'object' || sdk === null) {
return false
}
return Object.values(sdk).every((value) => isFlatSdk(value))
}
// Transforms OpenAPI-style placeholders `{id}` into MSW-style `:id`
function convertOpenApiUrlToMsw(url: string, baseUrl?: string): string {
return `${baseUrl || client.getConfig().baseUrl || ''}${url.replace(/{(\w+)}/g, (_match, p1) => `:${p1}`)}`
}
// This will generate a handler wrapper for one SDK function
function wrapMswHandler<Fn extends SdkFn>(sdkFn: Fn, baseUrl?: string) {
return (resolver: MswResponseResolver<Fn>) => {
// Create a mock client which will call msw handler factories
const mockClient: Record<string, (options: RequestOptions) => HttpHandler> = {};
for (const method of ['head', 'get', 'post', 'put', 'delete', 'patch', 'options'] as const) {
mockClient[method] = options => http[method](convertOpenApiUrlToMsw(options.url, baseUrl), resolver)
}
// This is a trick to rely on SDK function implementation to retrieve HTTP method of sdkFn. no real request will happen.
return sdkFn({ client: mockClient as unknown as Client }) as HttpHandler
}
}
/**
* Create MSW handlers factories from autogenerated OpenApi SDK
* @param sdk
*/
export function createMswMocks<Sdk extends Record<string, SdkFn>>(sdk: Sdk, baseUrl?: string): FlatMswMocks<Sdk>;
export function createMswMocks<Sdk extends new (...args: any[]) => any>(sdk: Sdk, baseUrl?: string): FlatMswMocks<Sdk>;
export function createMswMocks<Sdk extends Record<string, Record<string, SdkFn> | (new (...args: any[]) => any)>>(sdk: Sdk, baseUrl?: string): ClassMswMocks<Sdk>;
export function createMswMocks(sdk: any, baseUrl?: string) {
switch (true) {
case isFlatSdk(sdk): {
const mocks = {} as FlatMswMocks<typeof sdk>
for (const key of typeof sdk.constructor === 'function' ? Object.getOwnPropertyNames(sdk) : Object.keys(sdk)) {
if (typeof sdk[key] !== 'function') {
continue
}
mocks[`${key}MswHandler`] = wrapMswHandler(sdk[key], baseUrl)
}
return mocks
}
case isClassSdk(sdk): {
const mocks = {} as ClassMswMocks<typeof sdk>
for (const [flatSdkKey, flatSdk] of Object.entries(sdk)) {
mocks[flatSdkKey] = {}
for (const key of typeof flatSdk.constructor === 'function' ? Object.getOwnPropertyNames(flatSdk) : Object.keys(flatSdk)) {
if (typeof flatSdk[key] !== 'function') {
continue
}
mocks[flatSdkKey][`${key}MswHandler`] = wrapMswHandler(flatSdk[key], baseUrl)
}
}
return mocks
}
default:
throw new Error('Invalid SDK argument')
}
}
export const mswSdk = createMswMocks(sdk)
// export const mswSdk = createMswMocks(sdk, import.meta.env.VITE_API_BASEURL) // Use this if you're using `client.setConfig({ baseUrl: import.meta.env.VITE_API_BASEURL })` elsewhere
// export const mswAuthApi = createMswMocks(sdk.AuthApi) // You can also create mocks on a class basis
mocks/auth.ts
import { HttpResponse } from 'msw'
import { mswSdk } from './openapi-msw'
import type { LoginResponse, UserResponse } from '@/api'
const handlers = [
mswSdk.AuthApi.loginMswHandler(async ({ request }) => { ... }),
mswSdk.AuthApi.getCurrentUserMswHandler(() => ...),
mswSdk.AuthApi.registerLocalUserMswHandler(async ({ request }) => { ... }),
mswSdk.AuthApi.logoutMswHandler(() => ...),
]
export default { handlers }
mocks/index.ts
import { setupWorker } from 'msw/browser'
import auth from './auth'
const worker = setupWorker(
...auth.handlers,
)
export default worker
main.ts
import mswWorker from './mocks'
....