From 5310b1b111eb957014c2425599ebac04e935325d Mon Sep 17 00:00:00 2001 From: Drew Powers Date: Thu, 5 Oct 2023 16:18:48 -0600 Subject: [PATCH] Fix empty object required param Fixes #1127 --- .changeset/honest-yaks-smell.md | 5 ++ .changeset/silent-carpets-grab.md | 5 ++ packages/openapi-fetch/src/index.test.ts | 36 +++++------ packages/openapi-fetch/src/index.ts | 63 ++++++++++++------- .../openapi-typescript-helpers/index.d.ts | 3 + 5 files changed, 73 insertions(+), 39 deletions(-) create mode 100644 .changeset/honest-yaks-smell.md create mode 100644 .changeset/silent-carpets-grab.md diff --git a/.changeset/honest-yaks-smell.md b/.changeset/honest-yaks-smell.md new file mode 100644 index 000000000..df461d420 --- /dev/null +++ b/.changeset/honest-yaks-smell.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +Fix empty object being required param diff --git a/.changeset/silent-carpets-grab.md b/.changeset/silent-carpets-grab.md new file mode 100644 index 000000000..7ef565861 --- /dev/null +++ b/.changeset/silent-carpets-grab.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript-helpers": patch +--- + +Add HasRequiredKeys helper diff --git a/packages/openapi-fetch/src/index.test.ts b/packages/openapi-fetch/src/index.test.ts index 483e7da13..92127b9eb 100644 --- a/packages/openapi-fetch/src/index.test.ts +++ b/packages/openapi-fetch/src/index.test.ts @@ -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(); // 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({ 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({ 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({ headers }); mockFetchOnce({ status: 200, body: JSON.stringify({ email: "user@user.com" }) }); - 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({ 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({ 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(); 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(); 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(); 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(); 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(); 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(); 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(); mockFetchOnce({ status: 200, body: "{}" }); - await client.TRACE("/anyMethod", {}); + await client.TRACE("/anyMethod"); expect(fetchMocker.mock.calls[0][1]?.method).toBe("TRACE"); }); }); diff --git a/packages/openapi-fetch/src/index.ts b/packages/openapi-fetch/src/index.ts index cfa78d809..c0b69ac0c 100644 --- a/packages/openapi-fetch/src/index.ts +++ b/packages/openapi-fetch/src/index.ts @@ -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 = ParamsOption & export default function createClient(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

(url: P, fetchOptions: FetchOptions): Promise> { 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(clientOptions: ClientOpti return { error, response: response as any }; } + type GetPaths = PathsWithMethod; + type PutPaths = PathsWithMethod; + type PostPaths = PathsWithMethod; + type DeletePaths = PathsWithMethod; + type OptionsPaths = PathsWithMethod; + type HeadPaths = PathsWithMethod; + type PatchPaths = PathsWithMethod; + type TracePaths = PathsWithMethod; + type GetFetchOptions

= FetchOptions>; + type PutFetchOptions

= FetchOptions>; + type PostFetchOptions

= FetchOptions>; + type DeleteFetchOptions

= FetchOptions>; + type OptionsFetchOptions

= FetchOptions>; + type HeadFetchOptions

= FetchOptions>; + type PatchFetchOptions

= FetchOptions>; + type TraceFetchOptions

= FetchOptions>; + return { /** Call a GET endpoint */ - async GET

>(url: P, init: FetchOptions>) { - return coreFetch(url, { ...init, method: "GET" } as any); + async GET

(url: P, ...init: HasRequiredKeys> extends never ? [GetFetchOptions

?] : [GetFetchOptions

]) { + return coreFetch(url, { ...init[0], method: "GET" } as any); }, /** Call a PUT endpoint */ - async PUT

>(url: P, init: FetchOptions>) { - return coreFetch(url, { ...init, method: "PUT" } as any); + async PUT

(url: P, ...init: HasRequiredKeys> extends never ? [PutFetchOptions

?] : [PutFetchOptions

]) { + return coreFetch(url, { ...init[0], method: "PUT" } as any); }, /** Call a POST endpoint */ - async POST

>(url: P, init: FetchOptions>) { - return coreFetch(url, { ...init, method: "POST" } as any); + async POST

(url: P, ...init: HasRequiredKeys> extends never ? [PostFetchOptions

?] : [PostFetchOptions

]) { + return coreFetch(url, { ...init[0], method: "POST" } as any); }, /** Call a DELETE endpoint */ - async DELETE

>(url: P, init: FetchOptions>) { - return coreFetch(url, { ...init, method: "DELETE" } as any); + async DELETE

(url: P, ...init: HasRequiredKeys> extends never ? [DeleteFetchOptions

?] : [DeleteFetchOptions

]) { + return coreFetch(url, { ...init[0], method: "DELETE" } as any); }, /** Call a OPTIONS endpoint */ - async OPTIONS

>(url: P, init: FetchOptions>) { - return coreFetch(url, { ...init, method: "OPTIONS" } as any); + async OPTIONS

(url: P, ...init: HasRequiredKeys> extends never ? [OptionsFetchOptions

?] : [OptionsFetchOptions

]) { + return coreFetch(url, { ...init[0], method: "OPTIONS" } as any); }, /** Call a HEAD endpoint */ - async HEAD

>(url: P, init: FetchOptions>) { - return coreFetch(url, { ...init, method: "HEAD" } as any); + async HEAD

(url: P, ...init: HasRequiredKeys> extends never ? [HeadFetchOptions

?] : [HeadFetchOptions

]) { + return coreFetch(url, { ...init[0], method: "HEAD" } as any); }, /** Call a PATCH endpoint */ - async PATCH

>(url: P, init: FetchOptions>) { - return coreFetch(url, { ...init, method: "PATCH" } as any); + async PATCH

(url: P, ...init: HasRequiredKeys> extends never ? [PatchFetchOptions

?] : [PatchFetchOptions

]) { + return coreFetch(url, { ...init[0], method: "PATCH" } as any); }, /** Call a TRACE endpoint */ - async TRACE

>(url: P, init: FetchOptions>) { - return coreFetch(url, { ...init, method: "TRACE" } as any); + async TRACE

(url: P, ...init: HasRequiredKeys> extends never ? [TraceFetchOptions

?] : [TraceFetchOptions

]) { + return coreFetch(url, { ...init[0], method: "TRACE" } as any); }, }; } @@ -145,8 +166,8 @@ export function defaultBodySerializer(body: T): string { } /** Construct URL string from baseUrl and handle path and query params */ -export function createFinalURL(url: string, options: { baseUrl?: string; params: { query?: Record; path?: Record }; querySerializer: QuerySerializer }): string { - let finalURL = `${options.baseUrl ? options.baseUrl.replace(TRAILING_SLASH_RE, "") : ""}${url as string}`; +export function createFinalURL(pathname: string, options: { baseUrl: string; params: { query?: Record; path?: Record }; querySerializer: QuerySerializer }): 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))); } diff --git a/packages/openapi-typescript-helpers/index.d.ts b/packages/openapi-typescript-helpers/index.d.ts index ce57c4297..c74b80161 100644 --- a/packages/openapi-typescript-helpers/index.d.ts +++ b/packages/openapi-typescript-helpers/index.d.ts @@ -47,3 +47,6 @@ export type ErrorResponse = FilterKeys, "content"> export type FilterKeys = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj]; /** Return any `[string]/[string]` media type (important because openapi-fetch allows any content response, not just JSON-like) */ export type MediaType = `${string}/${string}`; +/** Filter objects that have required keys */ +export type FindRequiredKeys = K extends unknown ? (undefined extends T[K] ? never : K) : K; +export type HasRequiredKeys = FindRequiredKeys;