Skip to content

Commit

Permalink
feat: add type inference for query parameters (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
christoph-fricke committed Feb 15, 2024
1 parent e3d43d6 commit 1f3958d
Show file tree
Hide file tree
Showing 10 changed files with 391 additions and 7 deletions.
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

0 comments on commit 1f3958d

Please sign in to comment.