-
-
Notifications
You must be signed in to change notification settings - Fork 529
Fix empty object required 2nd param #1366
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"openapi-fetch": patch | ||
--- | ||
|
||
Fix empty object being required param |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"openapi-typescript-helpers": patch | ||
--- | ||
|
||
Add HasRequiredKeys<T> helper |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -48,7 +48,7 @@ describe("client", () => { | |
|
||
// data | ||
mockFetchOnce({ status: 200, body: JSON.stringify(["one", "two", "three"]) }); | ||
const dataRes = await client.GET("/string-array", {}); | ||
const dataRes = await client.GET("/string-array"); | ||
|
||
// … is initially possibly undefined | ||
// @ts-expect-error | ||
|
@@ -67,7 +67,7 @@ describe("client", () => { | |
|
||
// error | ||
mockFetchOnce({ status: 500, body: JSON.stringify({ code: 500, message: "Something went wrong" }) }); | ||
const errorRes = await client.GET("/string-array", {}); | ||
const errorRes = await client.GET("/string-array"); | ||
|
||
// … is initially possibly undefined | ||
// @ts-expect-error | ||
|
@@ -92,7 +92,7 @@ describe("client", () => { | |
|
||
// expect error on missing 'params' | ||
// @ts-expect-error | ||
await client.GET("/blogposts/{post_id}", {}); | ||
await client.GET("/blogposts/{post_id}"); | ||
|
||
// expect error on empty params | ||
// @ts-expect-error | ||
|
@@ -120,7 +120,7 @@ describe("client", () => { | |
|
||
// expet error on missing header | ||
// @ts-expect-error | ||
await client.GET("/header-params", {}); | ||
await client.GET("/header-params"); | ||
|
||
// expect error on incorrect header | ||
// @ts-expect-error | ||
|
@@ -235,7 +235,7 @@ describe("client", () => { | |
|
||
// expect error on missing `body` | ||
// @ts-expect-error | ||
await client.PUT("/blogposts", {}); | ||
await client.PUT("/blogposts"); | ||
|
||
// expect error on missing fields | ||
// @ts-expect-error | ||
|
@@ -271,7 +271,7 @@ describe("client", () => { | |
const client = createClient<paths>(); | ||
|
||
// assert missing `body` doesn’t raise a TS error | ||
await client.PUT("/blogposts-optional", {}); | ||
await client.PUT("/blogposts-optional"); | ||
|
||
// assert error on type mismatch | ||
// @ts-expect-error | ||
|
@@ -294,13 +294,13 @@ describe("client", () => { | |
it("respects baseUrl", async () => { | ||
let client = createClient<paths>({ baseUrl: "https://myapi.com/v1" }); | ||
mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); | ||
await client.GET("/self", {}); | ||
await client.GET("/self"); | ||
|
||
// assert baseUrl and path mesh as expected | ||
expect(fetchMocker.mock.calls[0][0]).toBe("https://myapi.com/v1/self"); | ||
|
||
client = createClient<paths>({ baseUrl: "https://myapi.com/v1/" }); | ||
await client.GET("/self", {}); | ||
await client.GET("/self"); | ||
// assert trailing '/' was removed | ||
expect(fetchMocker.mock.calls[1][0]).toBe("https://myapi.com/v1/self"); | ||
}); | ||
|
@@ -310,7 +310,7 @@ describe("client", () => { | |
|
||
const client = createClient<paths>({ headers }); | ||
mockFetchOnce({ status: 200, body: JSON.stringify({ email: "[email protected]" }) }); | ||
await client.GET("/self", {}); | ||
await client.GET("/self"); | ||
|
||
// assert default headers were passed | ||
const options = fetchMocker.mock.calls[0][1]; | ||
|
@@ -359,7 +359,7 @@ describe("client", () => { | |
const client = createClient<paths>({ | ||
fetch: async () => Promise.resolve(customFetch as Response), | ||
}); | ||
expect((await client.GET("/self", {})).data).toBe(data); | ||
expect((await client.GET("/self")).data).toBe(data); | ||
}); | ||
}); | ||
|
||
|
@@ -426,7 +426,7 @@ describe("client", () => { | |
it("treats `default` as an error", async () => { | ||
const client = createClient<paths>({ headers: { "Cache-Control": "max-age=10000000" } }); | ||
mockFetchOnce({ status: 500, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: 500, message: "An unexpected error occurred" }) }); | ||
const { error } = await client.GET("/default-as-error", {}); | ||
const { error } = await client.GET("/default-as-error"); | ||
|
||
// discard `data` object | ||
if (!error) throw new Error("treats `default` as an error: error response should be present"); | ||
|
@@ -471,7 +471,7 @@ describe("client", () => { | |
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.GET("/anyMethod", {}); | ||
await client.GET("/anyMethod"); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); | ||
}); | ||
|
||
|
@@ -547,7 +547,7 @@ describe("client", () => { | |
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.POST("/anyMethod", {}); | ||
await client.POST("/anyMethod"); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("POST"); | ||
}); | ||
|
||
|
@@ -599,7 +599,7 @@ describe("client", () => { | |
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.DELETE("/anyMethod", {}); | ||
await client.DELETE("/anyMethod"); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("DELETE"); | ||
}); | ||
|
||
|
@@ -640,7 +640,7 @@ describe("client", () => { | |
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.OPTIONS("/anyMethod", {}); | ||
await client.OPTIONS("/anyMethod"); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("OPTIONS"); | ||
}); | ||
}); | ||
|
@@ -649,7 +649,7 @@ describe("client", () => { | |
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.HEAD("/anyMethod", {}); | ||
await client.HEAD("/anyMethod"); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("HEAD"); | ||
}); | ||
}); | ||
|
@@ -658,7 +658,7 @@ describe("client", () => { | |
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.PATCH("/anyMethod", {}); | ||
await client.PATCH("/anyMethod"); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("PATCH"); | ||
}); | ||
}); | ||
|
@@ -667,7 +667,7 @@ describe("client", () => { | |
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.TRACE("/anyMethod", {}); | ||
await client.TRACE("/anyMethod"); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("TRACE"); | ||
}); | ||
}); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,9 @@ | ||
import type { ErrorResponse, HttpMethod, SuccessResponse, FilterKeys, MediaType, PathsWithMethod, ResponseObjectMap, OperationRequestBodyContent } from "openapi-typescript-helpers"; | ||
import type { ErrorResponse, HttpMethod, SuccessResponse, FilterKeys, MediaType, PathsWithMethod, ResponseObjectMap, OperationRequestBodyContent, HasRequiredKeys } from "openapi-typescript-helpers"; | ||
|
||
// settings & const | ||
const DEFAULT_HEADERS = { | ||
"Content-Type": "application/json", | ||
}; | ||
const TRAILING_SLASH_RE = /\/*$/; | ||
|
||
// Note: though "any" is considered bad practice in general, this library relies | ||
// on "any" for type inference only it can give. Same goes for the "{}" type. | ||
|
@@ -46,11 +45,16 @@ export type RequestOptions<T> = ParamsOption<T> & | |
export default function createClient<Paths extends {}>(clientOptions: ClientOptions = {}) { | ||
const { fetch = globalThis.fetch, querySerializer: globalQuerySerializer, bodySerializer: globalBodySerializer, ...options } = clientOptions; | ||
|
||
let baseUrl = options.baseUrl ?? ""; | ||
if (baseUrl.endsWith("/")) { | ||
baseUrl = baseUrl.slice(0, -1); // remove trailing slash | ||
} | ||
|
||
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>> { | ||
const { headers, body: requestBody, params = {}, parseAs = "json", querySerializer = globalQuerySerializer ?? defaultQuerySerializer, bodySerializer = globalBodySerializer ?? defaultBodySerializer, ...init } = fetchOptions || {}; | ||
|
||
// URL | ||
const finalURL = createFinalURL(url as string, { baseUrl: options.baseUrl, params, querySerializer }); | ||
const finalURL = createFinalURL(url as string, { baseUrl, params, querySerializer }); | ||
const finalHeaders = mergeHeaders(DEFAULT_HEADERS, clientOptions?.headers, headers, (params as any).header); | ||
|
||
// fetch! | ||
|
@@ -89,38 +93,55 @@ export default function createClient<Paths extends {}>(clientOptions: ClientOpti | |
return { error, response: response as any }; | ||
} | ||
|
||
type GetPaths = PathsWithMethod<Paths, "get">; | ||
type PutPaths = PathsWithMethod<Paths, "put">; | ||
type PostPaths = PathsWithMethod<Paths, "post">; | ||
type DeletePaths = PathsWithMethod<Paths, "delete">; | ||
type OptionsPaths = PathsWithMethod<Paths, "options">; | ||
type HeadPaths = PathsWithMethod<Paths, "head">; | ||
type PatchPaths = PathsWithMethod<Paths, "patch">; | ||
type TracePaths = PathsWithMethod<Paths, "trace">; | ||
type GetFetchOptions<P extends GetPaths> = FetchOptions<FilterKeys<Paths[P], "get">>; | ||
type PutFetchOptions<P extends PutPaths> = FetchOptions<FilterKeys<Paths[P], "put">>; | ||
type PostFetchOptions<P extends PostPaths> = FetchOptions<FilterKeys<Paths[P], "post">>; | ||
type DeleteFetchOptions<P extends DeletePaths> = FetchOptions<FilterKeys<Paths[P], "delete">>; | ||
type OptionsFetchOptions<P extends OptionsPaths> = FetchOptions<FilterKeys<Paths[P], "options">>; | ||
type HeadFetchOptions<P extends HeadPaths> = FetchOptions<FilterKeys<Paths[P], "head">>; | ||
type PatchFetchOptions<P extends PatchPaths> = FetchOptions<FilterKeys<Paths[P], "patch">>; | ||
type TraceFetchOptions<P extends TracePaths> = FetchOptions<FilterKeys<Paths[P], "trace">>; | ||
|
||
return { | ||
/** Call a GET endpoint */ | ||
async GET<P extends PathsWithMethod<Paths, "get">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "get">>) { | ||
return coreFetch<P, "get">(url, { ...init, method: "GET" } as any); | ||
async GET<P extends GetPaths>(url: P, ...init: HasRequiredKeys<GetFetchOptions<P>> extends never ? [GetFetchOptions<P>?] : [GetFetchOptions<P>]) { | ||
return coreFetch<P, "get">(url, { ...init[0], method: "GET" } as any); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fix was weird, and not straightforward. Essentially, it involved multiple complicated steps:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, all of this is very annoying, very strange TypeScript introspection. But that’s the name of the game here, so it’s all worth it… I guess. |
||
}, | ||
/** Call a PUT endpoint */ | ||
async PUT<P extends PathsWithMethod<Paths, "put">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "put">>) { | ||
return coreFetch<P, "put">(url, { ...init, method: "PUT" } as any); | ||
async PUT<P extends PutPaths>(url: P, ...init: HasRequiredKeys<PutFetchOptions<P>> extends never ? [PutFetchOptions<P>?] : [PutFetchOptions<P>]) { | ||
return coreFetch<P, "put">(url, { ...init[0], method: "PUT" } as any); | ||
}, | ||
/** Call a POST endpoint */ | ||
async POST<P extends PathsWithMethod<Paths, "post">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "post">>) { | ||
return coreFetch<P, "post">(url, { ...init, method: "POST" } as any); | ||
async POST<P extends PostPaths>(url: P, ...init: HasRequiredKeys<PostFetchOptions<P>> extends never ? [PostFetchOptions<P>?] : [PostFetchOptions<P>]) { | ||
return coreFetch<P, "post">(url, { ...init[0], method: "POST" } as any); | ||
}, | ||
/** Call a DELETE endpoint */ | ||
async DELETE<P extends PathsWithMethod<Paths, "delete">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "delete">>) { | ||
return coreFetch<P, "delete">(url, { ...init, method: "DELETE" } as any); | ||
async DELETE<P extends DeletePaths>(url: P, ...init: HasRequiredKeys<DeleteFetchOptions<P>> extends never ? [DeleteFetchOptions<P>?] : [DeleteFetchOptions<P>]) { | ||
return coreFetch<P, "delete">(url, { ...init[0], method: "DELETE" } as any); | ||
}, | ||
/** Call a OPTIONS endpoint */ | ||
async OPTIONS<P extends PathsWithMethod<Paths, "options">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "options">>) { | ||
return coreFetch<P, "options">(url, { ...init, method: "OPTIONS" } as any); | ||
async OPTIONS<P extends OptionsPaths>(url: P, ...init: HasRequiredKeys<OptionsFetchOptions<P>> extends never ? [OptionsFetchOptions<P>?] : [OptionsFetchOptions<P>]) { | ||
return coreFetch<P, "options">(url, { ...init[0], method: "OPTIONS" } as any); | ||
}, | ||
/** Call a HEAD endpoint */ | ||
async HEAD<P extends PathsWithMethod<Paths, "head">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "head">>) { | ||
return coreFetch<P, "head">(url, { ...init, method: "HEAD" } as any); | ||
async HEAD<P extends HeadPaths>(url: P, ...init: HasRequiredKeys<HeadFetchOptions<P>> extends never ? [HeadFetchOptions<P>?] : [HeadFetchOptions<P>]) { | ||
return coreFetch<P, "head">(url, { ...init[0], method: "HEAD" } as any); | ||
}, | ||
/** Call a PATCH endpoint */ | ||
async PATCH<P extends PathsWithMethod<Paths, "patch">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "patch">>) { | ||
return coreFetch<P, "patch">(url, { ...init, method: "PATCH" } as any); | ||
async PATCH<P extends PatchPaths>(url: P, ...init: HasRequiredKeys<PatchFetchOptions<P>> extends never ? [PatchFetchOptions<P>?] : [PatchFetchOptions<P>]) { | ||
return coreFetch<P, "patch">(url, { ...init[0], method: "PATCH" } as any); | ||
}, | ||
/** Call a TRACE endpoint */ | ||
async TRACE<P extends PathsWithMethod<Paths, "trace">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "trace">>) { | ||
return coreFetch<P, "trace">(url, { ...init, method: "TRACE" } as any); | ||
async TRACE<P extends TracePaths>(url: P, ...init: HasRequiredKeys<TraceFetchOptions<P>> extends never ? [TraceFetchOptions<P>?] : [TraceFetchOptions<P>]) { | ||
return coreFetch<P, "trace">(url, { ...init[0], method: "TRACE" } as any); | ||
}, | ||
}; | ||
} | ||
|
@@ -145,8 +166,8 @@ export function defaultBodySerializer<T>(body: T): string { | |
} | ||
|
||
/** Construct URL string from baseUrl and handle path and query params */ | ||
export function createFinalURL<O>(url: string, options: { baseUrl?: string; params: { query?: Record<string, unknown>; path?: Record<string, unknown> }; querySerializer: QuerySerializer<O> }): string { | ||
let finalURL = `${options.baseUrl ? options.baseUrl.replace(TRAILING_SLASH_RE, "") : ""}${url as string}`; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small micro-optimization: we don’t need to be running a dynamic RegEx to remove a trailing slash. It’s much more performant to just remove a single character if needed (done above on |
||
export function createFinalURL<O>(pathname: string, options: { baseUrl: string; params: { query?: Record<string, unknown>; path?: Record<string, unknown> }; querySerializer: QuerySerializer<O> }): string { | ||
let finalURL = `${options.baseUrl}${pathname}`; | ||
if (options.params.path) { | ||
for (const [k, v] of Object.entries(options.params.path)) finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v))); | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: the removal of all
, {})
in the test suite is a good sign. The test suite has quite a few// @ts-expect-error
comments which are vital to testing the type assertions.But in addition, the test suite also has runtime tests to ensure the function is behaving as-expected. The fact that no tests were modified, while still passing, is a great sign.