diff --git a/.changeset/perfect-pans-pump.md b/.changeset/perfect-pans-pump.md new file mode 100644 index 0000000..91352e1 --- /dev/null +++ b/.changeset/perfect-pans-pump.md @@ -0,0 +1,5 @@ +--- +"openapi-msw": minor +--- + +Added enhanced typing for the `request` object. Now, `request.json()` and `request.text()` infer their return type from the given OpenAPI request-body content schema. Previously, only `request.json()` has been inferred without considering the content-type. diff --git a/src/api-spec.ts b/src/api-spec.ts index 1eff18a..3901953 100644 --- a/src/api-spec.ts +++ b/src/api-spec.ts @@ -1,4 +1,5 @@ import type { + OperationRequestBody, OperationRequestBodyContent, PathsWithMethod, } from "openapi-typescript-helpers"; @@ -64,6 +65,22 @@ type StrictQueryParams = [Params] extends [never] ? NonNullable : Params; +/** + * Extract a request map for a given path and method from an api spec. + * A request map has the shape of (media-type -> body). + */ +export type RequestMap< + ApiSpec extends AnyApiSpec, + Path extends keyof ApiSpec, + Method extends HttpMethod, +> = Method extends keyof ApiSpec[Path] + ? undefined extends OperationRequestBody + ? Partial< + NonNullable>["content"] + > + : OperationRequestBody["content"] + : never; + /** Extract the request body of a given path and method from an api spec. */ export type RequestBody< ApiSpec extends AnyApiSpec, diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..5f3a7cc --- /dev/null +++ b/src/request.ts @@ -0,0 +1,13 @@ +import type { FilterKeys, JSONLike } from "openapi-typescript-helpers"; + +/** A type-safe request helper that enhances native body methods based on the given OpenAPI spec. */ +export interface OpenApiRequest extends Request { + json(): JSONLike extends never + ? never + : Promise>; + text(): FilterKeys extends never + ? never + : FilterKeys extends string + ? Promise> + : never; +} diff --git a/src/response-resolver.ts b/src/response-resolver.ts index f303b41..c7d57eb 100644 --- a/src/response-resolver.ts +++ b/src/response-resolver.ts @@ -5,10 +5,12 @@ import type { PathParams, QueryParams, RequestBody, + RequestMap, ResponseBody, ResponseMap, } from "./api-spec.js"; import { QueryParams as QueryParamsUtil } from "./query-params.js"; +import type { OpenApiRequest } from "./request.js"; import { createResponseHelper, type OpenApiResponse } from "./response.js"; /** Response resolver that gets provided to HTTP handler factories. */ @@ -26,6 +28,9 @@ export interface ResponseResolverInfo< Path extends keyof ApiSpec, Method extends HttpMethod, > extends MSWResponseResolverInfo { + /** Standard request with enhanced typing for body methods based on the given OpenAPI spec. */ + request: OpenApiRequest>; + /** * Type-safe wrapper around {@link URLSearchParams} that implements methods for * reading query parameters. @@ -88,6 +93,9 @@ export function createResolverWrapper< return (info) => { return resolver({ ...info, + request: info.request as OpenApiRequest< + RequestMap + >, query: new QueryParamsUtil(info.request), response: createResponseHelper(), }); diff --git a/test/fixtures/request-body.api.yml b/test/fixtures/request-body.api.yml index 52e655e..a06f068 100644 --- a/test/fixtures/request-body.api.yml +++ b/test/fixtures/request-body.api.yml @@ -48,6 +48,23 @@ paths: application/json: schema: $ref: "#/components/schemas/Resource" + /multi-body: + post: + summary: "Create from Text or JSON" + operationId: postMultiBody + requestBody: + required: true + content: + text/plain: + schema: + type: string + enum: ["Hello", "Goodbye"] + application/json: + schema: + $ref: "#/components/schemas/NewResource" + responses: + 204: + description: NoContent components: schemas: Resource: diff --git a/test/request-body.test-d.ts b/test/request-body.test-d.ts index fde74b9..3fb51d7 100644 --- a/test/request-body.test-d.ts +++ b/test/request-body.test-d.ts @@ -6,12 +6,32 @@ import type { paths } from "./fixtures/request-body.api.js"; describe("Given an OpenAPI schema endpoint with request content", () => { const http = createOpenApiHttp(); + test("When the request is used, Then it extend MSW's request object", () => { + type Endpoint = typeof http.get<"/resource">; + const resolver = expectTypeOf().parameter(1); + const request = resolver.parameter(0).toHaveProperty("request"); + + request.toMatchTypeOf, "text" | "json">>(); + }); + + test("When a request is not expected to contain content, Then json and text return never", () => { + type Endpoint = typeof http.get<"/resource">; + const resolver = expectTypeOf().parameter(1); + const request = resolver.parameter(0).toHaveProperty("request"); + + request.toHaveProperty("text").returns.toEqualTypeOf(); + request.toHaveProperty("json").returns.toEqualTypeOf(); + }); + test("When a request is expected to contain content, Then the content is strict-typed", () => { type Endpoint = typeof http.post<"/resource">; const resolver = expectTypeOf().parameter(1); const request = resolver.parameter(0).toHaveProperty("request"); - request.toEqualTypeOf>(); + request.toHaveProperty("text").returns.toEqualTypeOf(); + request + .toHaveProperty("json") + .returns.resolves.toEqualTypeOf<{ name: string; value: number }>(); }); test("When a request content is optional, Then the content is strict-typed with optional", () => { @@ -19,16 +39,23 @@ describe("Given an OpenAPI schema endpoint with request content", () => { const resolver = expectTypeOf().parameter(1); const request = resolver.parameter(0).toHaveProperty("request"); - request.toEqualTypeOf< - StrictRequest<{ name: string; value: number } | undefined> - >(); + request + .toHaveProperty("json") + .returns.resolves.toEqualTypeOf< + { name: string; value: number } | undefined + >(); }); - test("When a request is not expected to contain content, Then the content is undefined", () => { - type Endpoint = typeof http.get<"/resource">; + test("When a request accepts multiple media types, Then both body parsers are typed for their media type", () => { + type Endpoint = typeof http.post<"/multi-body">; const resolver = expectTypeOf().parameter(1); const request = resolver.parameter(0).toHaveProperty("request"); - request.toEqualTypeOf>(); + request + .toHaveProperty("text") + .returns.resolves.toEqualTypeOf<"Hello" | "Goodbye">(); + request + .toHaveProperty("json") + .returns.resolves.toEqualTypeOf<{ name: string; value: number }>(); }); });