diff --git a/.changeset/rare-grapes-explode.md b/.changeset/rare-grapes-explode.md new file mode 100644 index 000000000..8c9cf1b4d --- /dev/null +++ b/.changeset/rare-grapes-explode.md @@ -0,0 +1,7 @@ +--- +"openapi-typescript-helpers": patch +"openapi-react-query": patch +"openapi-fetch": patch +--- + +add `status` to openapi-fetch & type narrowing for `data` and `error` based on `status` diff --git a/docs/openapi-fetch/index.md b/docs/openapi-fetch/index.md index 844eb9dbc..b615bfdde 100644 --- a/docs/openapi-fetch/index.md +++ b/docs/openapi-fetch/index.md @@ -30,6 +30,7 @@ const client = createClient({ baseUrl: "https://myapi.dev/v1/" }); const { data, // only present if 2XX response error, // only present if 4XX or 5XX response + status, // HTTP Status code } = await client.GET("/blogposts/{post_id}", { params: { path: { post_id: "123" }, @@ -47,6 +48,8 @@ await client.PUT("/blogposts", { `data` and `error` are typechecked and expose their shapes to Intellisense in VS Code (and any other IDE with TypeScript support). Likewise, the request `body` will also typecheck its fields, erring if any required params are missing, or if there’s a type mismatch. +The `status` property is also available and contains the HTTP status code of the response (`response.status`). This property is useful for handling different status codes in your application and narrowing down the `data` and `error` properties. + `GET()`, `PUT()`, `POST()`, etc. are thin wrappers around the native [fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (which you can [swap for any call](/openapi-fetch/api#create-client)). Notice there are no generics, and no manual typing. Your endpoint’s request and response were inferred automatically. This is a huge improvement in the type safety of your endpoints because **every manual assertion could lead to a bug**! This eliminates all of the following: @@ -158,17 +161,18 @@ The `POST()` request required a `body` object that provided all necessary [reque ### Response -All methods return an object with **data**, **error**, and **response**. +All methods return an object with **data**, **error**, **status** and **response**. ```ts -const { data, error, response } = await client.GET("/url"); +const { data, error, status, response } = await client.GET("/url"); ``` -| Object | Response | -| :--------- | :-------------------------------------------------------------------------------------------------------------------------- | -| `data` | `2xx` response if OK; otherwise `undefined` | -| `error` | `5xx`, `4xx`, or `default` response if not OK; otherwise `undefined` | -| `response` | [The original Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) which contains `status`, `headers`, etc. | +| Object | Response | +|:-----------|:------------------------------------------------------------------------------------------------------------------------------| +| `data` | `2xx` response if OK; otherwise `undefined` | +| `error` | `5xx`, `4xx`, or `default` response if not OK; otherwise `undefined` | +| `status` | The HTTP response status code of [the original response](https://developer.mozilla.org/en-US/docs/Web/API/Response/status) | +| `response` | [The original Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) which contains `status`, `headers`, etc. | ### Path-property style diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 06d01d400..e1ec46cee 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -1,5 +1,4 @@ import type { - ErrorResponse, FilterKeys, HttpMethod, IsOperationRequestBodyOptional, @@ -8,7 +7,10 @@ import type { PathsWithMethod, RequiredKeysOf, ResponseObjectMap, - SuccessResponse, + GetResponseContent, + ErrorStatus, + OkStatus, + OpenApiStatusToHttpStatus, } from "openapi-typescript-helpers"; /** Options for each client instance */ @@ -100,17 +102,14 @@ export type RequestBodyOption = OperationRequestBodyContent extends never export type FetchOptions = RequestOptions & Omit; -export type FetchResponse, Options, Media extends MediaType> = - | { - data: ParseAsResponse, Media>, Options>; - error?: never; - response: Response; - } - | { - data?: never; - error: ErrorResponse, Media>; - response: Response; - }; +export type FetchResponse, Options, Media extends MediaType> = { + [S in keyof ResponseObjectMap]: { + response: Response; + status: OpenApiStatusToHttpStatus>; + data: S extends OkStatus ? ParseAsResponse, Media, S>, Options> : never; + error: S extends ErrorStatus ? GetResponseContent, Media, S> : never; + }; +}[keyof ResponseObjectMap]; export type RequestOptions = ParamsOption & RequestBodyOption & { diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index 5e0c2fcd4..2bf74749f 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -204,16 +204,18 @@ export default function createClient(clientOptions) { // handle empty content if (response.status === 204 || response.headers.get("Content-Length") === "0") { - return response.ok ? { data: undefined, response } : { error: undefined, response }; + return response.ok + ? { data: undefined, response, status: response.status } + : { error: undefined, response, status: response.status }; } // parse response (falling back to .text() when necessary) if (response.ok) { // if "stream", skip parsing entirely if (parseAs === "stream") { - return { data: response.body, response }; + return { data: response.body, response, status: response.status }; } - return { data: await response[parseAs](), response }; + return { data: await response[parseAs](), response, status: response.status }; } // handle errors @@ -223,7 +225,7 @@ export default function createClient(clientOptions) { } catch { // noop } - return { error, response }; + return { error, response, status: response.status }; } return { diff --git a/packages/openapi-fetch/test/common/response.test.ts b/packages/openapi-fetch/test/common/response.test.ts index 7a3aacf39..bed69258f 100644 --- a/packages/openapi-fetch/test/common/response.test.ts +++ b/packages/openapi-fetch/test/common/response.test.ts @@ -20,22 +20,38 @@ describe("response", () => { // 2. assert data is not undefined inside condition block if (result.data) { assertType>(result.data); - assertType(result.error); + // @ts-expect-error FIXME: This is a limitation within Typescript + assertType(result.error); } // 2b. inverse should work, too if (!result.error) { assertType>(result.data); - assertType(result.error); + assertType(result.error); + } + + if (result.status === 200) { + assertType>(result.data); + assertType(result.error); + } + + if (result.status === 500) { + assertType(result.data); + assertType(result.error); + } + + // @ts-expect-error 204 is not defined in the schema + if (result.status === 204) { } // 3. assert error is not undefined inside condition block if (result.error) { - assertType(result.data); + // @ts-expect-error FIXME: This is a limitation within Typescript + assertType(result.data); assertType>(result.error); } // 3b. inverse should work, too if (!result.data) { - assertType(result.data); + assertType(result.data); assertType>(result.error); } }); @@ -49,9 +65,8 @@ describe("response", () => { {}, ); - //@ts-expect-error impossible to determine data type for invalid path assertType(result.data); - assertType(result.error); + assertType(result.error); }); test("returns union for mismatched response", async () => { @@ -65,14 +80,14 @@ describe("response", () => { } }); - test("returns union for mismatched errors", async () => { + test("returns union for mismatched errors", async () => { const client = createObservedClient(); const result = await client.GET("/mismatched-errors"); if (result.data) { expectTypeOf(result.data).toEqualTypeOf(); expectTypeOf(result.data).toEqualTypeOf>(); } else { - expectTypeOf(result.data).toBeUndefined(); + expectTypeOf(result.data).toBeNever(); expectTypeOf(result.error).extract<{ code: number }>().toEqualTypeOf<{ code: number; message: string }>(); expectTypeOf(result.error).exclude<{ code: number }>().toEqualTypeOf(undefined); } diff --git a/packages/openapi-fetch/test/never-response/never-response.test.ts b/packages/openapi-fetch/test/never-response/never-response.test.ts index 43cdb9a4b..728bd35f0 100644 --- a/packages/openapi-fetch/test/never-response/never-response.test.ts +++ b/packages/openapi-fetch/test/never-response/never-response.test.ts @@ -140,4 +140,39 @@ describe("GET", () => { expect(data).toBeUndefined(); expect(error).toBe("Unauthorized"); }); + + test("type narrowing on status", async () => { + const mockData = { + id: 123, + title: "My Post", + }; + + let actualPathname = ""; + const client = createObservedClient({}, async (req) => { + actualPathname = new URL(req.url).pathname; + return Response.json(mockData); + }); + + const { data, error, status } = await client.GET("/posts/{id}", { + params: { path: { id: 123 } }, + }); + + if (status === 200) { + assertType(data); + assertType(error); + } else if (status === 204) { + assertType(data); + } else if (status === 400) { + assertType(error); + } else if (status === 201) { + // Grabs the 'default' response + assertType(error); + } else if (status === 500) { + assertType(data); + assertType(error); + } else { + // All other status codes are handles with the 'default' response + assertType(error); + } + }); }); diff --git a/packages/openapi-fetch/test/types.test.ts b/packages/openapi-fetch/test/types.test.ts index a44e437eb..186cb8b49 100644 --- a/packages/openapi-fetch/test/types.test.ts +++ b/packages/openapi-fetch/test/types.test.ts @@ -1,5 +1,11 @@ import { assertType, describe, test } from "vitest"; -import type { ErrorResponse, GetResponseContent, OkStatus, SuccessResponse } from "openapi-typescript-helpers"; +import type { + ErrorResponse, + GetResponseContent, + OkStatus, + OpenApiStatusToHttpStatus, + SuccessResponse, +} from "openapi-typescript-helpers"; describe("types", () => { describe("GetResponseContent", () => { @@ -280,4 +286,179 @@ describe("types", () => { assertType({ error: "default application/json" }); }); }); + + describe("OpenApiStatusToHttpStatus", () => { + test("returns numeric status code", () => { + assertType>(200); + assertType>(200); + assertType>(204); + + assertType>( + // @ts-expect-error 200 is not a valid + 200, + ); + assertType>( + // @ts-expect-error 200 is not a valid + 200, + ); + + assertType>(404); + }); + + test("returns default response", () => { + type Status = OpenApiStatusToHttpStatus<"default", 200 | 204 | 206 | 404 | 500 | "default">; + assertType( + // @ts-expect-error 200 has been manually defined + 200, + ); + assertType( + // @ts-expect-error 204 has been manually defined + 204, + ); + assertType(201); + assertType(504); + }); + + test("returns 200 likes response", () => { + type Status = OpenApiStatusToHttpStatus<"2XX", 200 | 204 | 206 | 404 | 500 | "default">; + assertType(200); + assertType(201); + assertType(202); + assertType(203); + assertType(204); + assertType( + // @ts-expect-error 205 is not a valid 2XX status code + 205, + ); + assertType(206); + assertType(207); + assertType( + // @ts-expect-error '2XX' is not a numeric status code + "2XX", + ); + assertType( + // @ts-expect-error 205 is not a valid 2XX status code + 208, + ); + + assertType( + // @ts-expect-error '4XX' is not a numeric status code + "4XX", + ); + assertType( + // @ts-expect-error '5XX' is not a numeric status code + "5XX", + ); + }); + + test("returns error responses for 4XX", () => { + type Status = OpenApiStatusToHttpStatus<"4XX", 200 | 204 | 206 | 404 | 500 | "default">; + assertType(400); + assertType(401); + assertType(402); + assertType(403); + assertType(404); + assertType(405); + assertType(406); + assertType(407); + assertType(408); + assertType(409); + assertType(410); + assertType(411); + assertType(412); + assertType(413); + assertType(414); + assertType(415); + assertType(416); + assertType(417); + assertType(418); + assertType(500); + assertType(501); + assertType(502); + assertType(503); + assertType(504); + assertType(505); + assertType(506); + assertType(507); + assertType(508); + assertType( + // @ts-expect-error 509 is not a valid error status code + 509, + ); + assertType(510); + assertType(511); + + assertType( + // @ts-expect-error 200 is not a valid error status code + 200, + ); + assertType( + // @ts-expect-error '2XX' is not a numeric status code + "2XX", + ); + assertType( + // @ts-expect-error '4XX' is not a numeric status code + "4XX", + ); + assertType( + // @ts-expect-error '5XX' is not a numeric status code + "5XX", + ); + }); + + test("returns error responses for 5XX", () => { + type Status = OpenApiStatusToHttpStatus<"5XX", 200 | 204 | 206 | 404 | 500 | "default">; + assertType(400); + assertType(401); + assertType(402); + assertType(403); + assertType(404); + assertType(405); + assertType(406); + assertType(407); + assertType(408); + assertType(409); + assertType(410); + assertType(411); + assertType(412); + assertType(413); + assertType(414); + assertType(415); + assertType(416); + assertType(417); + assertType(418); + assertType(500); + assertType(501); + assertType(502); + assertType(503); + assertType(504); + assertType(505); + assertType(506); + assertType(507); + assertType(508); + assertType( + // @ts-expect-error 509 is not a valid error status code + 509, + ); + assertType(510); + assertType(511); + + assertType( + // @ts-expect-error 200 is not a valid error status code + 200, + ); + assertType( + // @ts-expect-error '2XX' is not a numeric status code + "2XX", + ); + assertType( + // @ts-expect-error '4XX' is not a numeric status code + "4XX", + ); + assertType( + // @ts-expect-error '5XX' is not a numeric status code + "5XX", + ); + }); + }); }); diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 2a3e1b1cb..fd6987cba 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -41,7 +41,7 @@ export type QueryOptionsFunction, Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends FetchResponse, Options extends Omit< UseQueryOptions< Response["data"], @@ -83,7 +83,7 @@ export type UseQueryMethod>, Method extends HttpMethod, Path extends PathsWithMethod, Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends FetchResponse, Options extends Omit< UseQueryOptions< Response["data"], @@ -131,7 +131,7 @@ export type UseSuspenseQueryMethod, Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends FetchResponse, Options extends Omit< UseSuspenseQueryOptions< Response["data"], @@ -153,7 +153,7 @@ export type UseMutationMethod, Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends FetchResponse, Options extends Omit, "mutationKey" | "mutationFn">, >( method: Method, diff --git a/packages/openapi-typescript-helpers/index.d.ts b/packages/openapi-typescript-helpers/index.d.ts index 1715c07cc..dc6ad962a 100644 --- a/packages/openapi-typescript-helpers/index.d.ts +++ b/packages/openapi-typescript-helpers/index.d.ts @@ -7,6 +7,22 @@ export type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | "2XX"; // biome-ignore format: keep on one line export type ErrorStatus = 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511 | '5XX' | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 444 | 450 | 451 | 497 | 498 | 499 | '4XX' | "default"; +/** + * 'default' returns every not explicitly defined status code + * '2XX' returns all 2XX status codes + * '4XX' returns all 4XX status codes + * '5XX' returns all 5XX status codes + */ +export type OpenApiStatusToHttpStatus = Status extends number + ? Status + : Status extends "default" + ? Exclude, AllStatuses> + : Status extends "2XX" + ? Exclude + : Status extends "4XX" | "5XX" + ? Exclude + : never; + /** Get a union of OK Statuses */ export type OKStatusUnion = FilterKeys; @@ -122,21 +138,19 @@ export type SuccessResponse< Media extends MediaType = MediaType, > = GetResponseContent; -type GetResponseContent< +export type GetResponseContent< T extends Record, Media extends MediaType = MediaType, ResponseCode extends keyof T = keyof T, -> = ResponseCode extends keyof T - ? { - [K in ResponseCode]: T[K]["content"] extends Record - ? FilterKeys extends never - ? T[K]["content"] - : FilterKeys - : K extends keyof T - ? T[K]["content"] - : never; - }[ResponseCode] - : never; +> = { + [K in ResponseCode]: T[K]["content"] extends Record + ? FilterKeys extends never + ? T[K]["content"] + : FilterKeys + : K extends keyof T + ? T[K]["content"] + : never; +}[ResponseCode]; /** * Return all 5XX and 4XX responses (in that order) from a Response Object Map