diff --git a/.changeset/modern-seals-glow.md b/.changeset/modern-seals-glow.md new file mode 100644 index 0000000..1d12cf7 --- /dev/null +++ b/.changeset/modern-seals-glow.md @@ -0,0 +1,5 @@ +--- +"openapi-msw": minor +--- + +Restructured the library to add support for additional response resolver info. The enhanced `ResponseResolver` type and `ResponseResolverInfo` are available as exports. diff --git a/exports/main.ts b/exports/main.ts index f34ce7e..3cca54a 100644 --- a/exports/main.ts +++ b/exports/main.ts @@ -1,8 +1,13 @@ -export { createOpenApiHttp } from "../src/openapi-http.js"; -export type { HttpOptions, OpenApiHttpHandlers } from "../src/openapi-http.js"; +export type { AnyApiSpec, HttpMethod } from "../src/api-spec.js"; + +export { + createOpenApiHttp, + type HttpHandlerFactory, + type HttpOptions, + type OpenApiHttpHandlers, +} from "../src/openapi-http.js"; export type { - AnyApiSpec, - HttpHandlerFactory, - HttpMethod, -} from "../src/type-helpers.js"; + ResponseResolver, + ResponseResolverInfo, +} from "../src/response-resolver.js"; diff --git a/src/type-helpers.ts b/src/api-spec.ts similarity index 56% rename from src/type-helpers.ts rename to src/api-spec.ts index 344dbea..0484bc4 100644 --- a/src/type-helpers.ts +++ b/src/api-spec.ts @@ -6,7 +6,7 @@ import type { ResponseObjectMap, SuccessResponse, } from "openapi-typescript-helpers"; -import type { http } from "msw"; +import type { ConvertToStringified } from "./type-utils.js"; /** Base type that any api spec should extend. */ export type AnyApiSpec = NonNullable; @@ -27,7 +27,7 @@ export type PathsForMethod< Method extends HttpMethod, > = PathsWithMethod; -/** Extract the params of a given path and method from an api spec. */ +/** Extract the path params of a given path and method from an api spec. */ export type PathParams< ApiSpec extends AnyApiSpec, Path extends keyof ApiSpec, @@ -37,14 +37,10 @@ export type PathParams< // eslint-disable-next-line @typescript-eslint/no-explicit-any parameters: { path: any }; } - ? ConvertToStringLike + ? ConvertToStringified : never : never; -type ConvertToStringLike = { - [Name in keyof Params]: Params[Name] extends string ? Params[Name] : string; -}; - /** Extract the request body of a given path and method from an api spec. */ export type RequestBody< ApiSpec extends AnyApiSpec, @@ -75,41 +71,7 @@ export type ResponseBody< /** * OpenAPI-TS generates "no content" with `content?: never`. - * However, `new Response().body` is `null` and strictly typing no-content in MSW requires `null`. - * Therefore, this helper maps no-content to `null`. + * However, `new Response().body` is `null` and strictly typing no-content in + * MSW requires `null`. Therefore, this helper maps no-content to `null`. */ -export type ConvertNoContent = [Content] extends [never] - ? null - : Content; - -/** MSW http handler factory with type inference for provided api paths. */ -export type HttpHandlerFactory< - ApiSpec extends AnyApiSpec, - Method extends HttpMethod, -> = >( - path: Path, - resolver: ResponseResolver, - options?: RequestHandlerOptions, -) => ReturnType; - -/** MSW handler options. */ -export type RequestHandlerOptions = Required[2]>; - -/** MSW response resolver function that is made type-safe through an api spec. */ -export interface ResponseResolver< - ApiSpec extends AnyApiSpec, - Path extends keyof ApiSpec, - Method extends HttpMethod, -> extends ResponseResolverType {} - -type ResponseResolverType< - ApiSpec extends AnyApiSpec, - Path extends keyof ApiSpec, - Method extends HttpMethod, -> = Parameters< - typeof http.all< - PathParams, - RequestBody, - ResponseBody - > ->[1]; +type ConvertNoContent = [Content] extends [never] ? null : Content; diff --git a/src/openapi-http.test.ts b/src/openapi-http.test.ts index 7ce88bd..d011a32 100644 --- a/src/openapi-http.test.ts +++ b/src/openapi-http.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { http as mswHttp } from "msw"; import { createOpenApiHttp } from "./openapi-http.js"; -import type { HttpMethod } from "./type-helpers.js"; +import type { HttpMethod } from "./api-spec.js"; const methods: HttpMethod[] = [ "get", @@ -40,7 +40,9 @@ describe.each(methods)("openapi %s http handlers", (method) => { http[method]("/test", resolver, { once: false }); expect(spy).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledWith("/test", resolver, { once: false }); + expect(spy).toHaveBeenCalledWith("/test", expect.any(Function), { + once: false, + }); }); it("should convert openapi paths to MSW compatible paths", () => { @@ -51,7 +53,11 @@ describe.each(methods)("openapi %s http handlers", (method) => { http[method]("/test/{id}", resolver); expect(spy).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledWith("/test/:id", resolver, undefined); + expect(spy).toHaveBeenCalledWith( + "/test/:id", + expect.any(Function), + undefined, + ); }); it("should prepend a configured baseUrl to the path for MSW", () => { @@ -62,6 +68,10 @@ describe.each(methods)("openapi %s http handlers", (method) => { http[method]("/test", resolver); expect(spy).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledWith("*/api/rest/test", resolver, undefined); + expect(spy).toHaveBeenCalledWith( + "*/api/rest/test", + expect.any(Function), + undefined, + ); }); }); diff --git a/src/openapi-http.ts b/src/openapi-http.ts index 6240cdb..6712437 100644 --- a/src/openapi-http.ts +++ b/src/openapi-http.ts @@ -1,10 +1,35 @@ -import { http } from "msw"; +import { http, type RequestHandlerOptions } from "msw"; +import type { AnyApiSpec, HttpMethod, PathsForMethod } from "./api-spec.js"; import { convertToColonPath } from "./path-mapping.js"; -import type { - AnyApiSpec, - HttpHandlerFactory, - HttpMethod, -} from "./type-helpers.js"; +import { + createResolverWrapper, + type ResponseResolver, +} from "./response-resolver.js"; + +/** HTTP handler factory with type inference for provided api paths. */ +export type HttpHandlerFactory< + ApiSpec extends AnyApiSpec, + Method extends HttpMethod, +> = >( + path: Path, + resolver: ResponseResolver, + options?: RequestHandlerOptions, +) => ReturnType; + +function createHttpWrapper< + ApiSpec extends AnyApiSpec, + Method extends HttpMethod, +>( + method: Method, + httpOptions?: HttpOptions, +): HttpHandlerFactory { + return (path, resolver, options) => { + const mswPath = convertToColonPath(path as string, httpOptions?.baseUrl); + const mswResolver = createResolverWrapper(resolver); + + return http[method](mswPath, mswResolver, options); + }; +} /** Collection of enhanced HTTP handler factories for each available HTTP Method. */ export type OpenApiHttpHandlers = { @@ -41,26 +66,13 @@ export function createOpenApiHttp( options?: HttpOptions, ): OpenApiHttpHandlers { return { - get: createHttpWrapper("get", options), - put: createHttpWrapper("put", options), - post: createHttpWrapper("post", options), - delete: createHttpWrapper("delete", options), - options: createHttpWrapper("options", options), - head: createHttpWrapper("head", options), - patch: createHttpWrapper("patch", options), + get: createHttpWrapper("get", options), + put: createHttpWrapper("put", options), + post: createHttpWrapper("post", options), + delete: createHttpWrapper("delete", options), + options: createHttpWrapper("options", options), + head: createHttpWrapper("head", options), + patch: createHttpWrapper("patch", options), untyped: http, }; } - -function createHttpWrapper< - ApiSpec extends AnyApiSpec, - Method extends HttpMethod, ->( - method: Method, - httpOptions?: HttpOptions, -): HttpHandlerFactory { - return (path, resolver, options) => { - const mswPath = convertToColonPath(path as string, httpOptions?.baseUrl); - return http[method](mswPath, resolver, options); - }; -} diff --git a/src/response-resolver.ts b/src/response-resolver.ts new file mode 100644 index 0000000..7af7eae --- /dev/null +++ b/src/response-resolver.ts @@ -0,0 +1,57 @@ +import type { AsyncResponseResolverReturnType, http } from "msw"; +import type { + AnyApiSpec, + HttpMethod, + PathParams, + RequestBody, + ResponseBody, +} from "./api-spec.js"; + +/** Response resolver that gets provided to HTTP handler factories. */ +export type ResponseResolver< + ApiSpec extends AnyApiSpec, + Path extends keyof ApiSpec, + Method extends HttpMethod, +> = ( + info: ResponseResolverInfo, +) => AsyncResponseResolverReturnType>; + +/** Response resolver info that extends MSW's resolver info with additional functionality. */ +export interface ResponseResolverInfo< + ApiSpec extends AnyApiSpec, + Path extends keyof ApiSpec, + Method extends HttpMethod, +> extends MSWResponseResolverInfo {} + +/** Wraps MSW's resolver function to provide additional info to a given resolver. */ +export function createResolverWrapper< + ApiSpec extends AnyApiSpec, + Path extends keyof ApiSpec, + Method extends HttpMethod, +>( + resolver: ResponseResolver, +): MSWResponseResolver { + return (info) => { + return resolver(info); + }; +} + +/** MSW response resolver info that is made type-safe through an api spec. */ +type MSWResponseResolverInfo< + ApiSpec extends AnyApiSpec, + Path extends keyof ApiSpec, + Method extends HttpMethod, +> = Parameters>[0]; + +/** MSW response resolver function that is made type-safe through an api spec. */ +export type MSWResponseResolver< + ApiSpec extends AnyApiSpec, + Path extends keyof ApiSpec, + Method extends HttpMethod, +> = Parameters< + typeof http.all< + PathParams, + RequestBody, + ResponseBody + > +>[1]; diff --git a/src/type-utils.ts b/src/type-utils.ts new file mode 100644 index 0000000..739ddb6 --- /dev/null +++ b/src/type-utils.ts @@ -0,0 +1,7 @@ +/** Converts a type to string while preserving string literal types. */ +export type Stringify = Value extends string ? Value : string; + +/** Converts a object values to their {@link Stringify} value. */ +export type ConvertToStringified = { + [Name in keyof Params]: Stringify[Name]>; +}; diff --git a/tsconfig.json b/tsconfig.json index 1547b4e..27db06c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true, "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, /* Completeness */ "skipDefaultLibCheck": true,