Skip to content

Commit 80ac76c

Browse files
committed
Update openapi-fetch types
1 parent cc8817e commit 80ac76c

File tree

8 files changed

+124
-53
lines changed

8 files changed

+124
-53
lines changed

.changeset/beige-ligers-tell.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": patch
3+
---
4+
5+
Fix GET requests requiring 2nd param when it’s not needed

.changeset/rich-poems-swim.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-typescript": minor
3+
---
4+
5+
**Feature**: Added debugger that lets you profile performance and see more in-depth messages

docs/src/content/docs/openapi-fetch/index.md

+7-9
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ description: Get Started with openapi-fetch
77

88
openapi-fetch applies your OpenAPI types to the native fetch API via TypeScript. Weighs **2 kb** and has virtually zero runtime. Works with React, Vue, Svelte, or vanilla JS.
99

10-
| Library | Size (min) | “GET” request |
11-
| :------------------------- | ---------: | :------------------------ |
12-
| openapi-fetch | `2 kB` | `151k` ops/s (fastest) |
13-
| openapi-typescript-fetch | `4 kB` | `99k` ops/s (1.4× slower) |
14-
| axios | `32 kB` | `90k` ops/s (1.6× slower) |
15-
| superagent | `55 kB` | `42k` ops/s (3× slower) |
16-
| openapi-typescript-codegen | `367 kB` | `71k` ops/s (2× slower) |
10+
| Library | Size (min) | “GET” request |
11+
| :------------------------- | ---------: | :------------------------- |
12+
| openapi-fetch | `2 kB` | `168k` ops/s (fastest) |
13+
| openapi-typescript-fetch | `4 kB` | `97k` ops/s (1.7× slower) |
14+
| axios | `32 kB` | `109k` ops/s (1.5× slower) |
15+
| superagent | `55 kB` | `33k` ops/s (5× slower) |
16+
| openapi-typescript-codegen | `367 kB` | `69k` ops/s (2.4× slower) |
1717

1818
The syntax is inspired by popular libraries like react-query or Apollo client, but without all the bells and whistles and in a 2 kb package.
1919

@@ -69,8 +69,6 @@ Next, generate TypeScript types from your OpenAPI schema using openapi-typescrip
6969
npx openapi-typescript ./path/to/api/v1.yaml -o ./src/lib/api/v1.d.ts
7070
```
7171

72-
> ⚠️ Be sure to <a href="https://redocly.com/docs/cli/commands/lint/" target="_blank" rel="noopener noreferrer">validate your schemas</a>! openapi-typescript will err on invalid schemas.
73-
7472
Lastly, be sure to **run typechecking** in your project. This can be done by adding `tsc --noEmit` to your <a href="https://docs.npmjs.com/cli/v9/using-npm/scripts" target="_blank" rel="noopener noreferrer">npm scripts</a> like so:
7573

7674
```json

packages/openapi-fetch/src/index.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe("client", () => {
5151
status: 200,
5252
body: JSON.stringify(["one", "two", "three"]),
5353
});
54-
const dataRes = await client.GET("/string-array", {});
54+
const dataRes = await client.GET("/string-array");
5555

5656
// … is initially possibly undefined
5757
// @ts-expect-error
@@ -73,7 +73,7 @@ describe("client", () => {
7373
status: 500,
7474
body: JSON.stringify({ code: 500, message: "Something went wrong" }),
7575
});
76-
const errorRes = await client.GET("/string-array", {});
76+
const errorRes = await client.GET("/string-array");
7777

7878
// … is initially possibly undefined
7979
// @ts-expect-error

packages/openapi-fetch/src/index.ts

+43-20
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import type {
1313
const DEFAULT_HEADERS = {
1414
"Content-Type": "application/json",
1515
};
16-
const TRAILING_SLASH_RE = /\/*$/;
1716

1817
// Note: though "any" is considered bad practice in general, this library relies
1918
// on "any" for type inference only it can give. Same goes for the "{}" type.
@@ -32,28 +31,46 @@ interface ClientOptions extends Omit<RequestInit, "headers"> {
3231
// headers override to make typing friendlier
3332
headers?: HeadersOptions;
3433
}
34+
3535
export type HeadersOptions =
3636
| HeadersInit
3737
| Record<string, string | number | boolean | null | undefined>;
38+
3839
export type QuerySerializer<T> = (
3940
query: T extends { parameters: any }
4041
? NonNullable<T["parameters"]["query"]>
4142
: Record<string, unknown>,
4243
) => string;
44+
4345
export type BodySerializer<T> = (body: OperationRequestBodyContent<T>) => any;
46+
4447
export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream";
48+
4549
export interface DefaultParamsOption {
4650
params?: { query?: Record<string, unknown> };
4751
}
52+
53+
export interface EmptyParameters {
54+
query?: never;
55+
header?: never;
56+
path?: never;
57+
cookie?: never;
58+
}
59+
4860
export type ParamsOption<T> = T extends { parameters: any }
49-
? { params: NonNullable<T["parameters"]> }
61+
? T["parameters"] extends EmptyParameters
62+
? DefaultParamsOption
63+
: { params: NonNullable<T["parameters"]> }
5064
: DefaultParamsOption;
65+
5166
export type RequestBodyOption<T> = OperationRequestBodyContent<T> extends never
5267
? { body?: never }
5368
: undefined extends OperationRequestBodyContent<T>
5469
? { body?: OperationRequestBodyContent<T> }
5570
: { body: OperationRequestBodyContent<T> };
71+
5672
export type FetchOptions<T> = RequestOptions<T> & Omit<RequestInit, "body">;
73+
5774
export type FetchResponse<T> =
5875
| {
5976
data: FilterKeys<SuccessResponse<ResponseObjectMap<T>>, MediaType>;
@@ -65,6 +82,7 @@ export type FetchResponse<T> =
6582
error: FilterKeys<ErrorResponse<ResponseObjectMap<T>>, MediaType>;
6683
response: Response;
6784
};
85+
6886
export type RequestOptions<T> = ParamsOption<T> &
6987
RequestBodyOption<T> & {
7088
querySerializer?: QuerySerializer<T>;
@@ -81,6 +99,10 @@ export default function createClient<Paths extends {}>(
8199
bodySerializer: globalBodySerializer,
82100
...options
83101
} = clientOptions;
102+
let baseUrl = options.baseUrl ?? "";
103+
if (baseUrl.endsWith("/")) {
104+
baseUrl = baseUrl.slice(0, -1);
105+
}
84106

85107
async function coreFetch<P extends keyof Paths, M extends HttpMethod>(
86108
url: P,
@@ -98,7 +120,7 @@ export default function createClient<Paths extends {}>(
98120

99121
// URL
100122
const finalURL = createFinalURL(url as string, {
101-
baseUrl: options.baseUrl,
123+
baseUrl,
102124
params,
103125
querySerializer,
104126
});
@@ -159,13 +181,20 @@ export default function createClient<Paths extends {}>(
159181
return { error, response: response as any };
160182
}
161183

184+
type GetPaths = PathsWithMethod<Paths, "get">;
185+
type GetFetchOptions<P extends GetPaths> = FetchOptions<
186+
FilterKeys<Paths[P], "get">
187+
>;
188+
162189
return {
163190
/** Call a GET endpoint */
164-
async GET<P extends PathsWithMethod<Paths, "get">>(
191+
async GET<P extends GetPaths>(
165192
url: P,
166-
init: FetchOptions<FilterKeys<Paths[P], "get">>,
193+
...init: GetFetchOptions<P> extends DefaultParamsOption // little hack to allow the 2nd param to be omitted if nothing is required (only for GET)
194+
? [GetFetchOptions<P>?]
195+
: [GetFetchOptions<P>]
167196
) {
168-
return coreFetch<P, "get">(url, { ...init, method: "GET" } as any);
197+
return coreFetch<P, "get">(url, { ...init[0], method: "GET" } as any);
169198
},
170199
/** Call a PUT endpoint */
171200
async PUT<P extends PathsWithMethod<Paths, "put">>(
@@ -245,26 +274,20 @@ export function defaultBodySerializer<T>(body: T): string {
245274

246275
/** Construct URL string from baseUrl and handle path and query params */
247276
export function createFinalURL<O>(
248-
url: string,
277+
pathname: string,
249278
options: {
250-
baseUrl?: string;
279+
baseUrl: string;
251280
params: { query?: Record<string, unknown>; path?: Record<string, unknown> };
252281
querySerializer: QuerySerializer<O>;
253282
},
254283
): string {
255-
let finalURL = `${
256-
options.baseUrl ? options.baseUrl.replace(TRAILING_SLASH_RE, "") : ""
257-
}${url as string}`;
258-
if (options.params.path) {
259-
for (const [k, v] of Object.entries(options.params.path)) {
260-
finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v)));
261-
}
284+
let finalURL = `${options.baseUrl}${pathname}`;
285+
for (const [k, v] of Object.entries(options.params.path ?? {})) {
286+
finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v)));
262287
}
263-
if (options.params.query) {
264-
const search = options.querySerializer(options.params.query as any);
265-
if (search) {
266-
finalURL += `?${search}`;
267-
}
288+
const search = options.querySerializer((options.params.query as any) ?? {});
289+
if (search) {
290+
finalURL += `?${search}`;
268291
}
269292
return finalURL;
270293
}

packages/openapi-fetch/test/index.bench.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ describe("setup", () => {
2626
fetcher.path("/pet/findByStatus").method("get").create();
2727
});
2828

29-
// axios: N/A
29+
bench("axios", async () => {
30+
axios.create({
31+
baseURL: "https://api.test.local",
32+
});
33+
});
3034

3135
// superagent: N/A
3236
});
@@ -53,7 +57,7 @@ describe("get (only URL)", () => {
5357
bench("axios", async () => {
5458
await axios.get("/url", {
5559
async adapter() {
56-
return "{}";
60+
return { data: {} };
5761
},
5862
});
5963
});
@@ -93,7 +97,7 @@ describe("get (headers)", () => {
9397
await axios.get(`${BASE_URL}/url`, {
9498
headers: { "x-header-1": 123, "x-header-2": 456 },
9599
async adapter() {
96-
return "{}";
100+
return { data: {} };
97101
},
98102
});
99103
});

packages/openapi-typescript-helpers/index.d.ts

+48-12
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
// HTTP types
44

5-
export type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace";
5+
export type HttpMethod =
6+
| "get"
7+
| "put"
8+
| "post"
9+
| "delete"
10+
| "options"
11+
| "head"
12+
| "patch"
13+
| "trace";
614
/** 2XX statuses */
715
export type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | "2XX";
816
// prettier-ignore
@@ -12,8 +20,15 @@ export type ErrorStatus = 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 |
1220
// OpenAPI type helpers
1321

1422
/** Given an OpenAPI **Paths Object**, find all paths that have the given method */
15-
export type PathsWithMethod<Paths extends Record<string, PathItemObject>, PathnameMethod extends HttpMethod> = {
16-
[Pathname in keyof Paths]: Paths[Pathname] extends { [K in PathnameMethod]: any } ? Pathname : never;
23+
export type PathsWithMethod<
24+
Paths extends Record<string, PathItemObject>,
25+
PathnameMethod extends HttpMethod,
26+
> = {
27+
[Pathname in keyof Paths]: Paths[Pathname] extends {
28+
[K in PathnameMethod]: any;
29+
}
30+
? Pathname
31+
: never;
1732
}[keyof Paths];
1833
/** DO NOT USE! Only used only for OperationObject type inference */
1934
export interface OperationObject {
@@ -23,27 +38,48 @@ export interface OperationObject {
2338
responses: any;
2439
}
2540
/** Internal helper used in PathsWithMethod */
26-
export type PathItemObject = { [M in HttpMethod]: OperationObject } & { parameters?: any };
41+
export type PathItemObject = {
42+
[M in HttpMethod]: OperationObject;
43+
} & { parameters?: any };
2744
/** Return `responses` for an Operation Object */
28-
export type ResponseObjectMap<T> = T extends { responses: any } ? T["responses"] : unknown;
45+
export type ResponseObjectMap<T> = T extends { responses: any }
46+
? T["responses"]
47+
: unknown;
2948
/** Return `content` for a Response Object */
30-
export type ResponseContent<T> = T extends { content: any } ? T["content"] : unknown;
49+
export type ResponseContent<T> = T extends { content: any }
50+
? T["content"]
51+
: unknown;
3152
/** Return `requestBody` for an Operation Object */
32-
export type OperationRequestBody<T> = T extends { requestBody?: any } ? T["requestBody"] : never;
53+
export type OperationRequestBody<T> = T extends { requestBody?: any }
54+
? T["requestBody"]
55+
: never;
3356
/** Internal helper used in OperationRequestBodyContent */
34-
export type OperationRequestBodyMediaContent<T> = undefined extends OperationRequestBody<T> ? FilterKeys<NonNullable<OperationRequestBody<T>>, "content"> | undefined : FilterKeys<OperationRequestBody<T>, "content">;
57+
export type OperationRequestBodyMediaContent<T> =
58+
undefined extends OperationRequestBody<T>
59+
? FilterKeys<NonNullable<OperationRequestBody<T>>, "content"> | undefined
60+
: FilterKeys<OperationRequestBody<T>, "content">;
3561
/** Return first `content` from a Request Object Mapping, allowing any media type */
36-
export type OperationRequestBodyContent<T> = FilterKeys<OperationRequestBodyMediaContent<T>, MediaType> extends never
37-
? FilterKeys<NonNullable<OperationRequestBodyMediaContent<T>>, MediaType> | undefined
62+
export type OperationRequestBodyContent<T> = FilterKeys<
63+
OperationRequestBodyMediaContent<T>,
64+
MediaType
65+
> extends never
66+
?
67+
| FilterKeys<NonNullable<OperationRequestBodyMediaContent<T>>, MediaType>
68+
| undefined
3869
: FilterKeys<OperationRequestBodyMediaContent<T>, MediaType>;
3970
/** Return first 2XX response from a Response Object Map */
4071
export type SuccessResponse<T> = FilterKeys<FilterKeys<T, OkStatus>, "content">;
4172
/** Return first 5XX or 4XX response (in that order) from a Response Object Map */
42-
export type ErrorResponse<T> = FilterKeys<FilterKeys<T, ErrorStatus>, "content">;
73+
export type ErrorResponse<T> = FilterKeys<
74+
FilterKeys<T, ErrorStatus>,
75+
"content"
76+
>;
4377

4478
// Generic TS utils
4579

4680
/** Find first match of multiple keys */
47-
export type FilterKeys<Obj, Matchers> = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj];
81+
export type FilterKeys<Obj, Matchers> = {
82+
[K in keyof Obj]: K extends Matchers ? Obj[K] : never;
83+
}[keyof Obj];
4884
/** Return any `[string]/[string]` media type (important because openapi-fetch allows any content response, not just JSON-like) */
4985
export type MediaType = `${string}/${string}`;

pnpm-lock.yaml

+7-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)