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: add type inference for query parameters #33

Merged
merged 11 commits into from
Feb 15, 2024
54 changes: 54 additions & 0 deletions .changeset/bright-hats-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
"openapi-msw": minor
---

Added `query` helper to resolver-info argument. It provides a type-safe wrapper around `URLSearchParams` for reading search parameters. As usual, the information about available parameters is inferred from your OpenAPI spec.

```typescript
/*
Imagine this endpoint specification for the following example:

/query-example:
get:
summary: Query Example
operationId: getQueryExample
parameters:
- name: filter
in: query
required: true
schema:
type: string
- name: page
in: query
schema:
type: number
- name: sort
in: query
required: false
schema:
type: string
enum: ["asc", "desc"]
- name: sortBy
in: query
schema:
type: array
items:
type: string
*/

const handler = http.get("/query-example", ({ query }) => {
const filter = query.get("filter"); // Typed as string
const page = query.get("page"); // Typed as string | null since it is not required
const sort = query.get("sort"); // Typed as "asc" | "desc" | null
const sortBy = query.getAll("sortBy"); // Typed as string[]

// Supported methods from URLSearchParams: get(), getAll(), has(), size
if (query.has("sort", "asc")) {
/* ... */
}

return HttpResponse.json({
/* ... */
});
});
```
49 changes: 46 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ continuing with this usage guide.

Once you have your OpenAPI schema types ready-to-go, you can use OpenAPI-MSW to
create an enhanced version of MSW's `http` object. The enhanced version is
designed to be almost identical to MSW in usage. Using the `http` object created
with OpenAPI-MSW enables multiple type-safety and editor suggestion benefits:
designed to be almost identical to MSW in usage. To go beyond MSW's typing
capabilities, OpenAPI-MSW provides optional helpers for an even better type-safe
experience. Using the `http` object created with OpenAPI-MSW enables multiple
type-safety and editor suggestion benefits:

- **Paths:** Only accepts paths that are available for the current HTTP method
- **Params**: Automatically typed with path parameters in the current path
- **Query Params**: Automatically typed with the query parameters schema of the
current path
- **Request Body:** Automatically typed with the request-body schema of the
current path
- **Response:** Automatically forced to match the response-body schema of the
Expand All @@ -52,7 +56,10 @@ const getHandler = http.get("/resource/{id}", ({ params }) => {
});

// TS only suggests available POST paths
const postHandler = http.post("/resource", async ({ request }) => {
const postHandler = http.post("/resource", async ({ request, query }) => {
// TS infers available query parameters from the OpenAPI schema
const sortDir = query.get("sort");

const data = await request.json();
return HttpResponse.json({ ...data /* ... more response data */ });
});
Expand Down Expand Up @@ -99,6 +106,42 @@ const catchAll = http.untyped.all("/resource/*", ({ params }) => {
Alternatively, you can import the original `http` object from MSW and use that
one for unknown paths instead.

### Optional Helpers

For an even better type-safe experience, OpenAPI-MSW provides optional helpers
that are attached to MSW's resolver-info argument. Currently, the helper `query`
is provided for type-safe access to query parameters.

#### `query` Helper

Type-safe wrapper around
[`URLSearchParams`](https://developer.mozilla.org/docs/Web/API/URLSearchParams)
that implements methods for reading query parameters. For the following example,
imagine an OpenAPI specification that defines some query parameters:

- **filter**: required string
- **sort**: optional string enum of "desc" and "asc"
- **sortBy**: optional array of strings

```typescript
const http = createOpenApiHttp<paths>();

const handler = http.get("/query-example", ({ query }) => {
const filter = query.get("filter"); // string
const sort = query.get("sort"); // "asc" | "desc" | null
const sortBy = query.getAll("sortBy"); // string[]

// Supported methods from URLSearchParams: get(), getAll(), has(), size
if (query.has("sort", "asc")) {
/* ... */
}

return HttpResponse.json({
/* ... */
});
});
```

## License

This package is published under the [MIT license](./LICENSE).
16 changes: 16 additions & 0 deletions src/api-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ export type PathParams<
: never
: never;

/** Extract the query params of a given path and method from an api spec. */
export type QueryParams<
ApiSpec extends AnyApiSpec,
Path extends keyof ApiSpec,
Method extends HttpMethod,
> = Method extends keyof ApiSpec[Path]
? ApiSpec[Path][Method] extends {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameters: { query?: any };
}
? ConvertToStringified<
Required<ApiSpec[Path][Method]["parameters"]>["query"]
>
: never
: never;

/** Extract the request body of a given path and method from an api spec. */
export type RequestBody<
ApiSpec extends AnyApiSpec,
Expand Down
69 changes: 69 additions & 0 deletions src/query-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { OptionalKeys } from "./type-utils.js";

/** Return values for getting the first value of a query param. */
type ParamValuesGet<Params extends object> = {
[Name in keyof Params]-?: Name extends OptionalKeys<Params>
? Params[Name] | null
: Params[Name];
};

/** Return values for getting all values of a query param. */
type ParamValuesGetAll<Params extends object> = {
[Name in keyof Params]-?: Required<Params>[Name][];
};

/**
* Wrapper around the search params of a request that offers methods for
* querying search params with enhanced type-safety from OpenAPI-TS.
*/
export class QueryParams<Params extends object> {
#searchParams: URLSearchParams;
constructor(request: Request) {
this.#searchParams = new URL(request.url).searchParams;
}

/**
* Wraps around {@link URLSearchParams.size}.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/size)
*/
get size(): number {
return this.#searchParams.size;
}

/**
* Wraps around {@link URLSearchParams.get} with type inference from the
* provided OpenAPI-TS `paths` definition.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/get)
*/
get<Name extends keyof Params>(name: Name): ParamValuesGet<Params>[Name] {
const value = this.#searchParams.get(name as string);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return value as any;
}

/**
* Wraps around {@link URLSearchParams.getAll} with type inference from the
* provided OpenAPI-TS `paths` definition.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/getAll)
*/
getAll<Name extends keyof Params>(
name: Name,
): ParamValuesGetAll<Params>[Name] {
const values = this.#searchParams.getAll(name as string);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return values as any;
}

/**
* Wraps around {@link URLSearchParams.has} with type inference from the
* provided OpenAPI-TS `paths` definition.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/has)
*/
has<Name extends keyof Params>(name: Name, value?: Params[Name]): boolean {
return this.#searchParams.has(name as string, value as string | undefined);
}
}
22 changes: 20 additions & 2 deletions src/response-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import type {
AnyApiSpec,
HttpMethod,
PathParams,
QueryParams,
RequestBody,
ResponseBody,
} from "./api-spec.js";
import { QueryParams as QueryParamsUtil } from "./query-params.js";

/** Response resolver that gets provided to HTTP handler factories. */
export type ResponseResolver<
Expand All @@ -21,7 +23,23 @@ export interface ResponseResolverInfo<
ApiSpec extends AnyApiSpec,
Path extends keyof ApiSpec,
Method extends HttpMethod,
> extends MSWResponseResolverInfo<ApiSpec, Path, Method> {}
> extends MSWResponseResolverInfo<ApiSpec, Path, Method> {
/**
* Type-safe wrapper around {@link URLSearchParams} that implements methods for
* reading query parameters.
*
* @example
* const handler = http.get("/query-example", ({ query }) => {
* const filter = query.get("filter");
* const sortBy = query.getAll("sortBy");
*
* if (query.has("sort", "asc")) { ... }
*
* return HttpResponse.json({ ... });
* });
*/
query: QueryParamsUtil<QueryParams<ApiSpec, Path, Method>>;
}

/** Wraps MSW's resolver function to provide additional info to a given resolver. */
export function createResolverWrapper<
Expand All @@ -32,7 +50,7 @@ export function createResolverWrapper<
resolver: ResponseResolver<ApiSpec, Path, Method>,
): MSWResponseResolver<ApiSpec, Path, Method> {
return (info) => {
return resolver(info);
return resolver({ ...info, query: new QueryParamsUtil(info.request) });
};
}

Expand Down
19 changes: 17 additions & 2 deletions src/type-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
/** Converts a type to string while preserving string literal types. */
export type Stringify<Value> = Value extends string ? Value : string;
/**
* Converts a type to string while preserving string literal types.
* {@link Array}s are unboxed to their stringified value.
*/
export type Stringify<Value> = Value extends (infer Type)[]
? Type extends string
? Type
: string
: Value extends string
? Value
: string;

/** Converts a object values to their {@link Stringify} value. */
export type ConvertToStringified<Params> = {
[Name in keyof Params]: Stringify<Required<Params>[Name]>;
};

/** Returns a union of all property keys that are optional in the given object. */
export type OptionalKeys<O extends object> = {
// eslint-disable-next-line @typescript-eslint/ban-types
[K in keyof O]-?: {} extends Pick<O, K> ? K : never;
}[keyof O];
4 changes: 4 additions & 0 deletions test/fixtures/.redocly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ apis:
root: ./path-fragments.api.yml
x-openapi-ts:
output: ./path-fragments.api.ts
query-params:
root: ./query-params.api.yml
x-openapi-ts:
output: ./query-params.api.ts
request-body:
root: ./request-body.api.yml
x-openapi-ts:
Expand Down
68 changes: 68 additions & 0 deletions test/fixtures/query-params.api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
openapi: 3.0.2
info:
title: Options API
version: 1.0.0
servers:
- url: http://localhost:3000
paths:
/single-query:
get:
summary: Single Query Params
operationId: getSingleQuery
parameters:
- name: query
in: query
required: true
schema:
type: string
- name: page
in: query
schema:
type: number
- name: sort
in: query
required: false
schema:
type: string
enum: ["asc", "desc"]
responses:
200:
description: Success
content:
application/json:
schema:
$ref: "#/components/schemas/Resource"
/multi-query:
get:
summary: Multi Query Params
operationId: getMultiQuery
parameters:
- name: id
in: query
schema:
type: array
items:
type: number
- name: sortBy
in: query
schema:
type: "array"
items:
type: string
enum: ["asc", "desc"]
responses:
200:
description: Success
content:
application/json:
schema:
$ref: "#/components/schemas/Resource"
components:
schemas:
Resource:
type: object
required:
- id
properties:
id:
type: string
Loading
Loading