From d90e35cd634c154b4d1d12a7d6e70d22f735dcbd Mon Sep 17 00:00:00 2001 From: illright Date: Sun, 7 Apr 2024 11:46:51 +0200 Subject: [PATCH 1/3] Add a failing type test for `openapi-fetch` --- packages/openapi-fetch/test/index.test-d.ts | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/openapi-fetch/test/index.test-d.ts diff --git a/packages/openapi-fetch/test/index.test-d.ts b/packages/openapi-fetch/test/index.test-d.ts new file mode 100644 index 000000000..74ec59930 --- /dev/null +++ b/packages/openapi-fetch/test/index.test-d.ts @@ -0,0 +1,66 @@ +import { test, expectTypeOf } from "vitest"; + +import createClient from "../src/index.js"; + +interface paths { + "/": { + get: operations["GetObjects"]; + }; +} + +interface operations { + GetObjects: { + parameters: {}; + responses: { + 200: components["responses"]["MultipleObjectsResponse"]; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["GenericError"]; + }; + }; +} + +interface components { + schemas: { + Object: { + id: string; + name: string; + }; + GenericErrorModel: { + errors: { + body: string[]; + }; + }; + }; + responses: { + MultipleObjectsResponse: { + content: { + "application/json": { + objects: components["schemas"]["Object"][]; + }; + }; + }; + /** @description Unauthorized */ + Unauthorized: { + content: {}; + }; + /** @description Unexpected error */ + GenericError: { + content: { + "application/json": components["schemas"]["GenericErrorModel"]; + }; + }; + }; +} + +const { GET } = createClient(); + +test("the error type works properly", async () => { + const value = await GET("/"); + + if (value.data) { + expectTypeOf(value.data).toEqualTypeOf({ objects: [{ id: "", name: "" }] }); + } else { + expectTypeOf(value.data).toBeUndefined(); + expectTypeOf(value.error).toEqualTypeOf({ errors: [""] }); + } +}); From 6722c9736ad702a6dfd0323ad95b04da8d088ded Mon Sep 17 00:00:00 2001 From: illright Date: Sun, 7 Apr 2024 22:31:17 +0200 Subject: [PATCH 2/3] Actually fix the issue --- packages/openapi-fetch/src/index.d.ts | 5 +- packages/openapi-fetch/test/fixtures/api.d.ts | 5 ++ packages/openapi-fetch/test/fixtures/api.yaml | 4 ++ packages/openapi-fetch/test/index.test-d.ts | 62 +++---------------- .../openapi-typescript-helpers/index.d.ts | 3 + 5 files changed, 25 insertions(+), 54 deletions(-) diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index e914ed77a..3c21c9425 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -1,6 +1,7 @@ import type { ErrorResponse, FilterKeys, + GetValueWithDefault, HasRequiredKeys, HttpMethod, MediaType, @@ -114,7 +115,7 @@ export type FetchOptions = RequestOptions & export type FetchResponse = | { data: ParseAsResponse< - FilterKeys>, Media>, + GetValueWithDefault>, Media, Record>, O >; error?: never; @@ -122,7 +123,7 @@ export type FetchResponse = } | { data?: never; - error: FilterKeys>, Media>; + error: GetValueWithDefault>, Media, Record>; response: Response; }; diff --git a/packages/openapi-fetch/test/fixtures/api.d.ts b/packages/openapi-fetch/test/fixtures/api.d.ts index 1ab3518ac..d37bd2e9f 100644 --- a/packages/openapi-fetch/test/fixtures/api.d.ts +++ b/packages/openapi-fetch/test/fixtures/api.d.ts @@ -24,6 +24,7 @@ export interface paths { }; responses: { 200: components["responses"]["AllPostsGet"]; + 401: components["responses"]["EmptyError"]; 500: components["responses"]["Error"]; }; }; @@ -457,6 +458,10 @@ export interface components { "text/html": string; }; }; + EmptyError: { + content: { + }; + }; Error: { content: { "application/json": { diff --git a/packages/openapi-fetch/test/fixtures/api.yaml b/packages/openapi-fetch/test/fixtures/api.yaml index 18afb82da..0310d7532 100644 --- a/packages/openapi-fetch/test/fixtures/api.yaml +++ b/packages/openapi-fetch/test/fixtures/api.yaml @@ -28,6 +28,8 @@ paths: responses: 200: $ref: '#/components/responses/AllPostsGet' + 401: + $ref: '#/components/responses/EmptyError' 500: $ref: '#/components/responses/Error' put: @@ -623,6 +625,8 @@ components: text/html: schema: type: string + EmptyError: + content: {} Error: content: application/json: diff --git a/packages/openapi-fetch/test/index.test-d.ts b/packages/openapi-fetch/test/index.test-d.ts index 74ec59930..72ba1e792 100644 --- a/packages/openapi-fetch/test/index.test-d.ts +++ b/packages/openapi-fetch/test/index.test-d.ts @@ -1,66 +1,24 @@ import { test, expectTypeOf } from "vitest"; import createClient from "../src/index.js"; +import type { paths } from "./fixtures/api.js"; -interface paths { - "/": { - get: operations["GetObjects"]; - }; -} - -interface operations { - GetObjects: { - parameters: {}; - responses: { - 200: components["responses"]["MultipleObjectsResponse"]; - 401: components["responses"]["Unauthorized"]; - 422: components["responses"]["GenericError"]; - }; - }; -} +const { GET } = createClient(); -interface components { - schemas: { - Object: { - id: string; - name: string; - }; - GenericErrorModel: { - errors: { - body: string[]; - }; - }; - }; - responses: { - MultipleObjectsResponse: { - content: { - "application/json": { - objects: components["schemas"]["Object"][]; - }; - }; - }; - /** @description Unauthorized */ - Unauthorized: { - content: {}; - }; - /** @description Unexpected error */ - GenericError: { - content: { - "application/json": components["schemas"]["GenericErrorModel"]; - }; - }; - }; +interface Blogpost { + title: string; + body: string; + publish_date?: number | undefined; } -const { GET } = createClient(); - test("the error type works properly", async () => { - const value = await GET("/"); + const value = await GET("/blogposts"); if (value.data) { - expectTypeOf(value.data).toEqualTypeOf({ objects: [{ id: "", name: "" }] }); + expectTypeOf(value.data).toEqualTypeOf>(); } else { expectTypeOf(value.data).toBeUndefined(); - expectTypeOf(value.error).toEqualTypeOf({ errors: [""] }); + expectTypeOf(value.error).extract<{ code: number }>().toEqualTypeOf<{ code: number; message: string }>(); + expectTypeOf(value.error).exclude<{ code: number }>().toEqualTypeOf>(); } }); diff --git a/packages/openapi-typescript-helpers/index.d.ts b/packages/openapi-typescript-helpers/index.d.ts index fc1df928c..e4215e458 100644 --- a/packages/openapi-typescript-helpers/index.d.ts +++ b/packages/openapi-typescript-helpers/index.d.ts @@ -88,6 +88,9 @@ export type RequestBodyJSON = JSONLike< /** Find first match of multiple keys */ export type FilterKeys = Obj[keyof Obj & Matchers]; +/** Get the type of a value of an input object with a given key. If the key is not found, return a default type. Works with unions of objects too. */ +export type GetValueWithDefault = Obj extends any ? (FilterKeys extends never ? Default : FilterKeys) : never; + /** Return any `[string]/[string]` media type (important because openapi-fetch allows any content response, not just JSON-like) */ export type MediaType = `${string}/${string}`; /** Return any media type containing "json" (works for "application/json", "application/vnd.api+json", "application/vnd.oai.openapi+json") */ From 7f75fa9ce9d8ed6d00d54845d287b659bc1a6da0 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Fri, 12 Apr 2024 18:20:57 +0200 Subject: [PATCH 3/3] Fix lint errors and add a changeset --- .changeset/gold-worms-wave.md | 6 ++++++ packages/openapi-fetch/src/index.d.ts | 12 ++++++++++-- packages/openapi-fetch/test/index.test-d.ts | 10 ++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .changeset/gold-worms-wave.md diff --git a/.changeset/gold-worms-wave.md b/.changeset/gold-worms-wave.md new file mode 100644 index 000000000..e8b5f3064 --- /dev/null +++ b/.changeset/gold-worms-wave.md @@ -0,0 +1,6 @@ +--- +"openapi-typescript-helpers": patch +"openapi-fetch": patch +--- + +Fix data/error discrimination when there are empty-body errors diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 3c21c9425..75bbb0054 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -115,7 +115,11 @@ export type FetchOptions = RequestOptions & export type FetchResponse = | { data: ParseAsResponse< - GetValueWithDefault>, Media, Record>, + GetValueWithDefault< + SuccessResponse>, + Media, + Record + >, O >; error?: never; @@ -123,7 +127,11 @@ export type FetchResponse = } | { data?: never; - error: GetValueWithDefault>, Media, Record>; + error: GetValueWithDefault< + ErrorResponse>, + Media, + Record + >; response: Response; }; diff --git a/packages/openapi-fetch/test/index.test-d.ts b/packages/openapi-fetch/test/index.test-d.ts index 72ba1e792..943135c76 100644 --- a/packages/openapi-fetch/test/index.test-d.ts +++ b/packages/openapi-fetch/test/index.test-d.ts @@ -11,6 +11,8 @@ interface Blogpost { publish_date?: number | undefined; } +// This is a type test that will not be executed +// eslint-disable-next-line vitest/expect-expect test("the error type works properly", async () => { const value = await GET("/blogposts"); @@ -18,7 +20,11 @@ test("the error type works properly", async () => { expectTypeOf(value.data).toEqualTypeOf>(); } else { expectTypeOf(value.data).toBeUndefined(); - expectTypeOf(value.error).extract<{ code: number }>().toEqualTypeOf<{ code: number; message: string }>(); - expectTypeOf(value.error).exclude<{ code: number }>().toEqualTypeOf>(); + expectTypeOf(value.error) + .extract<{ code: number }>() + .toEqualTypeOf<{ code: number; message: string }>(); + expectTypeOf(value.error) + .exclude<{ code: number }>() + .toEqualTypeOf>(); } });