Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enhanced request object typing #52

Merged
merged 5 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perfect-pans-pump.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions src/api-spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
OperationRequestBody,
OperationRequestBodyContent,
PathsWithMethod,
} from "openapi-typescript-helpers";
Expand Down Expand Up @@ -64,6 +65,22 @@ type StrictQueryParams<Params> = [Params] extends [never]
? NonNullable<unknown>
: 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<ApiSpec[Path][Method]>
? Partial<
NonNullable<OperationRequestBody<ApiSpec[Path][Method]>>["content"]
>
: OperationRequestBody<ApiSpec[Path][Method]>["content"]
: never;

/** Extract the request body of a given path and method from an api spec. */
export type RequestBody<
ApiSpec extends AnyApiSpec,
Expand Down
13 changes: 13 additions & 0 deletions src/request.ts
Original file line number Diff line number Diff line change
@@ -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<RequestMap> extends Request {
json(): JSONLike<RequestMap> extends never
? never
: Promise<JSONLike<RequestMap>>;
text(): FilterKeys<RequestMap, `text/${string}`> extends never
? never
: FilterKeys<RequestMap, `text/${string}`> extends string
? Promise<FilterKeys<RequestMap, `text/${string}`>>
: never;
}
8 changes: 8 additions & 0 deletions src/response-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -26,6 +28,9 @@ export interface ResponseResolverInfo<
Path extends keyof ApiSpec,
Method extends HttpMethod,
> extends MSWResponseResolverInfo<ApiSpec, Path, Method> {
/** Standard request with enhanced typing for body methods based on the given OpenAPI spec. */
request: OpenApiRequest<RequestMap<ApiSpec, Path, Method>>;

/**
* Type-safe wrapper around {@link URLSearchParams} that implements methods for
* reading query parameters.
Expand Down Expand Up @@ -88,6 +93,9 @@ export function createResolverWrapper<
return (info) => {
return resolver({
...info,
request: info.request as OpenApiRequest<
RequestMap<ApiSpec, Path, Method>
>,
query: new QueryParamsUtil(info.request),
response: createResponseHelper(),
});
Expand Down
17 changes: 17 additions & 0 deletions test/fixtures/request-body.api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 34 additions & 7 deletions test/request-body.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,56 @@ import type { paths } from "./fixtures/request-body.api.js";
describe("Given an OpenAPI schema endpoint with request content", () => {
const http = createOpenApiHttp<paths>();

test("When the request is used, Then it extend MSW's request object", () => {
type Endpoint = typeof http.get<"/resource">;
const resolver = expectTypeOf<Endpoint>().parameter(1);
const request = resolver.parameter(0).toHaveProperty("request");

request.toMatchTypeOf<Omit<StrictRequest<null>, "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<Endpoint>().parameter(1);
const request = resolver.parameter(0).toHaveProperty("request");

request.toHaveProperty("text").returns.toEqualTypeOf<never>();
request.toHaveProperty("json").returns.toEqualTypeOf<never>();
});

test("When a request is expected to contain content, Then the content is strict-typed", () => {
type Endpoint = typeof http.post<"/resource">;
const resolver = expectTypeOf<Endpoint>().parameter(1);
const request = resolver.parameter(0).toHaveProperty("request");

request.toEqualTypeOf<StrictRequest<{ name: string; value: number }>>();
request.toHaveProperty("text").returns.toEqualTypeOf<never>();
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", () => {
type Endpoint = typeof http.patch<"/resource">;
const resolver = expectTypeOf<Endpoint>().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<Endpoint>().parameter(1);
const request = resolver.parameter(0).toHaveProperty("request");

request.toEqualTypeOf<StrictRequest<undefined>>();
request
.toHaveProperty("text")
.returns.resolves.toEqualTypeOf<"Hello" | "Goodbye">();
request
.toHaveProperty("json")
.returns.resolves.toEqualTypeOf<{ name: string; value: number }>();
});
});