Skip to content

Commit 5310b1b

Browse files
committed
Fix empty object required param
Fixes #1127
1 parent 27969ed commit 5310b1b

File tree

5 files changed

+73
-39
lines changed

5 files changed

+73
-39
lines changed

Diff for: .changeset/honest-yaks-smell.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": patch
3+
---
4+
5+
Fix empty object being required param

Diff for: .changeset/silent-carpets-grab.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-typescript-helpers": patch
3+
---
4+
5+
Add HasRequiredKeys<T> helper

Diff for: packages/openapi-fetch/src/index.test.ts

+18-18
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe("client", () => {
4848

4949
// data
5050
mockFetchOnce({ status: 200, body: JSON.stringify(["one", "two", "three"]) });
51-
const dataRes = await client.GET("/string-array", {});
51+
const dataRes = await client.GET("/string-array");
5252

5353
// … is initially possibly undefined
5454
// @ts-expect-error
@@ -67,7 +67,7 @@ describe("client", () => {
6767

6868
// error
6969
mockFetchOnce({ status: 500, body: JSON.stringify({ code: 500, message: "Something went wrong" }) });
70-
const errorRes = await client.GET("/string-array", {});
70+
const errorRes = await client.GET("/string-array");
7171

7272
// … is initially possibly undefined
7373
// @ts-expect-error
@@ -92,7 +92,7 @@ describe("client", () => {
9292

9393
// expect error on missing 'params'
9494
// @ts-expect-error
95-
await client.GET("/blogposts/{post_id}", {});
95+
await client.GET("/blogposts/{post_id}");
9696

9797
// expect error on empty params
9898
// @ts-expect-error
@@ -120,7 +120,7 @@ describe("client", () => {
120120

121121
// expet error on missing header
122122
// @ts-expect-error
123-
await client.GET("/header-params", {});
123+
await client.GET("/header-params");
124124

125125
// expect error on incorrect header
126126
// @ts-expect-error
@@ -235,7 +235,7 @@ describe("client", () => {
235235

236236
// expect error on missing `body`
237237
// @ts-expect-error
238-
await client.PUT("/blogposts", {});
238+
await client.PUT("/blogposts");
239239

240240
// expect error on missing fields
241241
// @ts-expect-error
@@ -271,7 +271,7 @@ describe("client", () => {
271271
const client = createClient<paths>();
272272

273273
// assert missing `body` doesn’t raise a TS error
274-
await client.PUT("/blogposts-optional", {});
274+
await client.PUT("/blogposts-optional");
275275

276276
// assert error on type mismatch
277277
// @ts-expect-error
@@ -294,13 +294,13 @@ describe("client", () => {
294294
it("respects baseUrl", async () => {
295295
let client = createClient<paths>({ baseUrl: "https://myapi.com/v1" });
296296
mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) });
297-
await client.GET("/self", {});
297+
await client.GET("/self");
298298

299299
// assert baseUrl and path mesh as expected
300300
expect(fetchMocker.mock.calls[0][0]).toBe("https://myapi.com/v1/self");
301301

302302
client = createClient<paths>({ baseUrl: "https://myapi.com/v1/" });
303-
await client.GET("/self", {});
303+
await client.GET("/self");
304304
// assert trailing '/' was removed
305305
expect(fetchMocker.mock.calls[1][0]).toBe("https://myapi.com/v1/self");
306306
});
@@ -310,7 +310,7 @@ describe("client", () => {
310310

311311
const client = createClient<paths>({ headers });
312312
mockFetchOnce({ status: 200, body: JSON.stringify({ email: "[email protected]" }) });
313-
await client.GET("/self", {});
313+
await client.GET("/self");
314314

315315
// assert default headers were passed
316316
const options = fetchMocker.mock.calls[0][1];
@@ -359,7 +359,7 @@ describe("client", () => {
359359
const client = createClient<paths>({
360360
fetch: async () => Promise.resolve(customFetch as Response),
361361
});
362-
expect((await client.GET("/self", {})).data).toBe(data);
362+
expect((await client.GET("/self")).data).toBe(data);
363363
});
364364
});
365365

@@ -426,7 +426,7 @@ describe("client", () => {
426426
it("treats `default` as an error", async () => {
427427
const client = createClient<paths>({ headers: { "Cache-Control": "max-age=10000000" } });
428428
mockFetchOnce({ status: 500, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: 500, message: "An unexpected error occurred" }) });
429-
const { error } = await client.GET("/default-as-error", {});
429+
const { error } = await client.GET("/default-as-error");
430430

431431
// discard `data` object
432432
if (!error) throw new Error("treats `default` as an error: error response should be present");
@@ -471,7 +471,7 @@ describe("client", () => {
471471
it("sends the correct method", async () => {
472472
const client = createClient<paths>();
473473
mockFetchOnce({ status: 200, body: "{}" });
474-
await client.GET("/anyMethod", {});
474+
await client.GET("/anyMethod");
475475
expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET");
476476
});
477477

@@ -547,7 +547,7 @@ describe("client", () => {
547547
it("sends the correct method", async () => {
548548
const client = createClient<paths>();
549549
mockFetchOnce({ status: 200, body: "{}" });
550-
await client.POST("/anyMethod", {});
550+
await client.POST("/anyMethod");
551551
expect(fetchMocker.mock.calls[0][1]?.method).toBe("POST");
552552
});
553553

@@ -599,7 +599,7 @@ describe("client", () => {
599599
it("sends the correct method", async () => {
600600
const client = createClient<paths>();
601601
mockFetchOnce({ status: 200, body: "{}" });
602-
await client.DELETE("/anyMethod", {});
602+
await client.DELETE("/anyMethod");
603603
expect(fetchMocker.mock.calls[0][1]?.method).toBe("DELETE");
604604
});
605605

@@ -640,7 +640,7 @@ describe("client", () => {
640640
it("sends the correct method", async () => {
641641
const client = createClient<paths>();
642642
mockFetchOnce({ status: 200, body: "{}" });
643-
await client.OPTIONS("/anyMethod", {});
643+
await client.OPTIONS("/anyMethod");
644644
expect(fetchMocker.mock.calls[0][1]?.method).toBe("OPTIONS");
645645
});
646646
});
@@ -649,7 +649,7 @@ describe("client", () => {
649649
it("sends the correct method", async () => {
650650
const client = createClient<paths>();
651651
mockFetchOnce({ status: 200, body: "{}" });
652-
await client.HEAD("/anyMethod", {});
652+
await client.HEAD("/anyMethod");
653653
expect(fetchMocker.mock.calls[0][1]?.method).toBe("HEAD");
654654
});
655655
});
@@ -658,7 +658,7 @@ describe("client", () => {
658658
it("sends the correct method", async () => {
659659
const client = createClient<paths>();
660660
mockFetchOnce({ status: 200, body: "{}" });
661-
await client.PATCH("/anyMethod", {});
661+
await client.PATCH("/anyMethod");
662662
expect(fetchMocker.mock.calls[0][1]?.method).toBe("PATCH");
663663
});
664664
});
@@ -667,7 +667,7 @@ describe("client", () => {
667667
it("sends the correct method", async () => {
668668
const client = createClient<paths>();
669669
mockFetchOnce({ status: 200, body: "{}" });
670-
await client.TRACE("/anyMethod", {});
670+
await client.TRACE("/anyMethod");
671671
expect(fetchMocker.mock.calls[0][1]?.method).toBe("TRACE");
672672
});
673673
});

Diff for: packages/openapi-fetch/src/index.ts

+42-21
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import type { ErrorResponse, HttpMethod, SuccessResponse, FilterKeys, MediaType, PathsWithMethod, ResponseObjectMap, OperationRequestBodyContent } from "openapi-typescript-helpers";
1+
import type { ErrorResponse, HttpMethod, SuccessResponse, FilterKeys, MediaType, PathsWithMethod, ResponseObjectMap, OperationRequestBodyContent, HasRequiredKeys } from "openapi-typescript-helpers";
22

33
// settings & const
44
const DEFAULT_HEADERS = {
55
"Content-Type": "application/json",
66
};
7-
const TRAILING_SLASH_RE = /\/*$/;
87

98
// Note: though "any" is considered bad practice in general, this library relies
109
// on "any" for type inference only it can give. Same goes for the "{}" type.
@@ -46,11 +45,16 @@ export type RequestOptions<T> = ParamsOption<T> &
4645
export default function createClient<Paths extends {}>(clientOptions: ClientOptions = {}) {
4746
const { fetch = globalThis.fetch, querySerializer: globalQuerySerializer, bodySerializer: globalBodySerializer, ...options } = clientOptions;
4847

48+
let baseUrl = options.baseUrl ?? "";
49+
if (baseUrl.endsWith("/")) {
50+
baseUrl = baseUrl.slice(0, -1); // remove trailing slash
51+
}
52+
4953
async function coreFetch<P extends keyof Paths, M extends HttpMethod>(url: P, fetchOptions: FetchOptions<M extends keyof Paths[P] ? Paths[P][M] : never>): Promise<FetchResponse<M extends keyof Paths[P] ? Paths[P][M] : unknown>> {
5054
const { headers, body: requestBody, params = {}, parseAs = "json", querySerializer = globalQuerySerializer ?? defaultQuerySerializer, bodySerializer = globalBodySerializer ?? defaultBodySerializer, ...init } = fetchOptions || {};
5155

5256
// URL
53-
const finalURL = createFinalURL(url as string, { baseUrl: options.baseUrl, params, querySerializer });
57+
const finalURL = createFinalURL(url as string, { baseUrl, params, querySerializer });
5458
const finalHeaders = mergeHeaders(DEFAULT_HEADERS, clientOptions?.headers, headers, (params as any).header);
5559

5660
// fetch!
@@ -89,38 +93,55 @@ export default function createClient<Paths extends {}>(clientOptions: ClientOpti
8993
return { error, response: response as any };
9094
}
9195

96+
type GetPaths = PathsWithMethod<Paths, "get">;
97+
type PutPaths = PathsWithMethod<Paths, "put">;
98+
type PostPaths = PathsWithMethod<Paths, "post">;
99+
type DeletePaths = PathsWithMethod<Paths, "delete">;
100+
type OptionsPaths = PathsWithMethod<Paths, "options">;
101+
type HeadPaths = PathsWithMethod<Paths, "head">;
102+
type PatchPaths = PathsWithMethod<Paths, "patch">;
103+
type TracePaths = PathsWithMethod<Paths, "trace">;
104+
type GetFetchOptions<P extends GetPaths> = FetchOptions<FilterKeys<Paths[P], "get">>;
105+
type PutFetchOptions<P extends PutPaths> = FetchOptions<FilterKeys<Paths[P], "put">>;
106+
type PostFetchOptions<P extends PostPaths> = FetchOptions<FilterKeys<Paths[P], "post">>;
107+
type DeleteFetchOptions<P extends DeletePaths> = FetchOptions<FilterKeys<Paths[P], "delete">>;
108+
type OptionsFetchOptions<P extends OptionsPaths> = FetchOptions<FilterKeys<Paths[P], "options">>;
109+
type HeadFetchOptions<P extends HeadPaths> = FetchOptions<FilterKeys<Paths[P], "head">>;
110+
type PatchFetchOptions<P extends PatchPaths> = FetchOptions<FilterKeys<Paths[P], "patch">>;
111+
type TraceFetchOptions<P extends TracePaths> = FetchOptions<FilterKeys<Paths[P], "trace">>;
112+
92113
return {
93114
/** Call a GET endpoint */
94-
async GET<P extends PathsWithMethod<Paths, "get">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "get">>) {
95-
return coreFetch<P, "get">(url, { ...init, method: "GET" } as any);
115+
async GET<P extends GetPaths>(url: P, ...init: HasRequiredKeys<GetFetchOptions<P>> extends never ? [GetFetchOptions<P>?] : [GetFetchOptions<P>]) {
116+
return coreFetch<P, "get">(url, { ...init[0], method: "GET" } as any);
96117
},
97118
/** Call a PUT endpoint */
98-
async PUT<P extends PathsWithMethod<Paths, "put">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "put">>) {
99-
return coreFetch<P, "put">(url, { ...init, method: "PUT" } as any);
119+
async PUT<P extends PutPaths>(url: P, ...init: HasRequiredKeys<PutFetchOptions<P>> extends never ? [PutFetchOptions<P>?] : [PutFetchOptions<P>]) {
120+
return coreFetch<P, "put">(url, { ...init[0], method: "PUT" } as any);
100121
},
101122
/** Call a POST endpoint */
102-
async POST<P extends PathsWithMethod<Paths, "post">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "post">>) {
103-
return coreFetch<P, "post">(url, { ...init, method: "POST" } as any);
123+
async POST<P extends PostPaths>(url: P, ...init: HasRequiredKeys<PostFetchOptions<P>> extends never ? [PostFetchOptions<P>?] : [PostFetchOptions<P>]) {
124+
return coreFetch<P, "post">(url, { ...init[0], method: "POST" } as any);
104125
},
105126
/** Call a DELETE endpoint */
106-
async DELETE<P extends PathsWithMethod<Paths, "delete">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "delete">>) {
107-
return coreFetch<P, "delete">(url, { ...init, method: "DELETE" } as any);
127+
async DELETE<P extends DeletePaths>(url: P, ...init: HasRequiredKeys<DeleteFetchOptions<P>> extends never ? [DeleteFetchOptions<P>?] : [DeleteFetchOptions<P>]) {
128+
return coreFetch<P, "delete">(url, { ...init[0], method: "DELETE" } as any);
108129
},
109130
/** Call a OPTIONS endpoint */
110-
async OPTIONS<P extends PathsWithMethod<Paths, "options">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "options">>) {
111-
return coreFetch<P, "options">(url, { ...init, method: "OPTIONS" } as any);
131+
async OPTIONS<P extends OptionsPaths>(url: P, ...init: HasRequiredKeys<OptionsFetchOptions<P>> extends never ? [OptionsFetchOptions<P>?] : [OptionsFetchOptions<P>]) {
132+
return coreFetch<P, "options">(url, { ...init[0], method: "OPTIONS" } as any);
112133
},
113134
/** Call a HEAD endpoint */
114-
async HEAD<P extends PathsWithMethod<Paths, "head">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "head">>) {
115-
return coreFetch<P, "head">(url, { ...init, method: "HEAD" } as any);
135+
async HEAD<P extends HeadPaths>(url: P, ...init: HasRequiredKeys<HeadFetchOptions<P>> extends never ? [HeadFetchOptions<P>?] : [HeadFetchOptions<P>]) {
136+
return coreFetch<P, "head">(url, { ...init[0], method: "HEAD" } as any);
116137
},
117138
/** Call a PATCH endpoint */
118-
async PATCH<P extends PathsWithMethod<Paths, "patch">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "patch">>) {
119-
return coreFetch<P, "patch">(url, { ...init, method: "PATCH" } as any);
139+
async PATCH<P extends PatchPaths>(url: P, ...init: HasRequiredKeys<PatchFetchOptions<P>> extends never ? [PatchFetchOptions<P>?] : [PatchFetchOptions<P>]) {
140+
return coreFetch<P, "patch">(url, { ...init[0], method: "PATCH" } as any);
120141
},
121142
/** Call a TRACE endpoint */
122-
async TRACE<P extends PathsWithMethod<Paths, "trace">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "trace">>) {
123-
return coreFetch<P, "trace">(url, { ...init, method: "TRACE" } as any);
143+
async TRACE<P extends TracePaths>(url: P, ...init: HasRequiredKeys<TraceFetchOptions<P>> extends never ? [TraceFetchOptions<P>?] : [TraceFetchOptions<P>]) {
144+
return coreFetch<P, "trace">(url, { ...init[0], method: "TRACE" } as any);
124145
},
125146
};
126147
}
@@ -145,8 +166,8 @@ export function defaultBodySerializer<T>(body: T): string {
145166
}
146167

147168
/** Construct URL string from baseUrl and handle path and query params */
148-
export function createFinalURL<O>(url: string, options: { baseUrl?: string; params: { query?: Record<string, unknown>; path?: Record<string, unknown> }; querySerializer: QuerySerializer<O> }): string {
149-
let finalURL = `${options.baseUrl ? options.baseUrl.replace(TRAILING_SLASH_RE, "") : ""}${url as string}`;
169+
export function createFinalURL<O>(pathname: string, options: { baseUrl: string; params: { query?: Record<string, unknown>; path?: Record<string, unknown> }; querySerializer: QuerySerializer<O> }): string {
170+
let finalURL = `${options.baseUrl}${pathname}`;
150171
if (options.params.path) {
151172
for (const [k, v] of Object.entries(options.params.path)) finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v)));
152173
}

Diff for: packages/openapi-typescript-helpers/index.d.ts

+3
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,6 @@ export type ErrorResponse<T> = FilterKeys<FilterKeys<T, ErrorStatus>, "content">
4747
export type FilterKeys<Obj, Matchers> = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj];
4848
/** Return any `[string]/[string]` media type (important because openapi-fetch allows any content response, not just JSON-like) */
4949
export type MediaType = `${string}/${string}`;
50+
/** Filter objects that have required keys */
51+
export type FindRequiredKeys<T, K extends keyof T> = K extends unknown ? (undefined extends T[K] ? never : K) : K;
52+
export type HasRequiredKeys<T> = FindRequiredKeys<T, keyof T>;

0 commit comments

Comments
 (0)