openapi-ts icon indicating copy to clipboard operation
openapi-ts copied to clipboard

Intergrate with `msw` mock service

Open himself65 opened this issue 1 year ago • 1 comments

Description

I think it's possible to generate mock server handler for better unit testing

himself65 avatar Sep 19 '24 19:09 himself65

@himself65 What do you do currently?

mrlubos avatar Sep 19 '24 21:09 mrlubos

Related to https://github.com/hey-api/openapi-ts/issues/1486

mrlubos avatar Dec 20 '24 22:12 mrlubos

Closing this in favour of https://github.com/hey-api/openapi-ts/issues/1486, please move any discussion there!

mrlubos avatar Jan 06 '25 02:01 mrlubos

Hey @himself65, are you using MSW Source? What's the benefit for you to have Hey API generate MSW handlers?

mrlubos avatar Jan 21 '25 16:01 mrlubos

So I don't need manually write random data code

himself65 avatar Jan 21 '25 19:01 himself65

@himself65 which parts do you still need to write manually when using MSW Source?

mrlubos avatar Jan 21 '25 19:01 mrlubos

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 avatar Feb 12 '25 12:02 mkeyy0

@mkeyy0 did you end up using it? The GenAI feature looks interesting

mrlubos avatar Feb 12 '25 12:02 mrlubos

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

mkeyy0 avatar Feb 12 '25 13:02 mkeyy0

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 next function 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 UserCreateUnauthorizedResponse etc. 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 avatar Feb 12 '25 14:02 mkeyy0

@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

mrlubos avatar Feb 12 '25 14:02 mrlubos

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.post etc. → derived from the operation's method
  • PathParams, 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. 😊

timofei-iatsenko avatar May 08 '25 14:05 timofei-iatsenko

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!

mrlubos avatar May 08 '25 18:05 mrlubos

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.

timofei-iatsenko avatar May 14 '25 08:05 timofei-iatsenko

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'

....

Vigilans avatar Nov 10 '25 08:11 Vigilans