Skip to content

Commit 06163a2

Browse files
authored
fix(openapi-fetch): Return union of possible responses (data & error) (openapi-ts#1937)
* Get all possible responses as a union * Add tests * lint fix * Add tests for standalone types * lint fix * changeset * Fix passing more keys (OkStatus) returning unknown * add test for non-existent media type * Update invalid path test * lint-fix * better @ts-expect-error scoping
1 parent 4e17f27 commit 06163a2

File tree

8 files changed

+692
-18
lines changed

8 files changed

+692
-18
lines changed

.changeset/small-jokes-wait.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"openapi-typescript-helpers": patch
3+
"openapi-fetch": patch
4+
---
5+
6+
client data & error now return a union of possible types

packages/openapi-fetch/src/index.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export type RequestBodyOption<T> = OperationRequestBodyContent<T> extends never
9898

9999
export type FetchOptions<T> = RequestOptions<T> & Omit<RequestInit, "body" | "headers">;
100100

101-
export type FetchResponse<T, Options, Media extends MediaType> =
101+
export type FetchResponse<T extends Record<string | number, any>, Options, Media extends MediaType> =
102102
| {
103103
data: ParseAsResponse<SuccessResponse<ResponseObjectMap<T>, Media>, Options>;
104104
error?: never;
@@ -187,7 +187,7 @@ export type ClientMethod<
187187
...init: InitParam<Init>
188188
) => Promise<FetchResponse<Paths[Path][Method], Init, Media>>;
189189

190-
export type ClientForPath<PathInfo, Media extends MediaType> = {
190+
export type ClientForPath<PathInfo extends Record<string | number, any>, Media extends MediaType> = {
191191
[Method in keyof PathInfo as Uppercase<string & Method>]: <Init extends MaybeOptionalInit<PathInfo, Method>>(
192192
...init: InitParam<Init>
193193
) => Promise<FetchResponse<PathInfo[Method], Init, Media>>;
@@ -234,7 +234,7 @@ export default function createClient<Paths extends {}, Media extends MediaType =
234234
clientOptions?: ClientOptions,
235235
): Client<Paths, Media>;
236236

237-
export type PathBasedClient<Paths, Media extends MediaType = MediaType> = {
237+
export type PathBasedClient<Paths extends Record<string | number, any>, Media extends MediaType = MediaType> = {
238238
[Path in keyof Paths]: ClientForPath<Paths[Path], Media>;
239239
};
240240

packages/openapi-fetch/test/common/response.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ describe("response", () => {
4949
{},
5050
);
5151

52-
assertType<undefined>(result.data);
53-
// @ts-expect-error: FIXME when #1723 is resolved; this shouldn’t throw an error
52+
//@ts-expect-error impossible to determine data type for invalid path
53+
assertType<never>(result.data);
5454
assertType<undefined>(result.error);
5555
});
5656

@@ -74,7 +74,7 @@ describe("response", () => {
7474
} else {
7575
expectTypeOf(result.data).toBeUndefined();
7676
expectTypeOf(result.error).extract<{ code: number }>().toEqualTypeOf<{ code: number; message: string }>();
77-
expectTypeOf(result.error).exclude<{ code: number }>().toEqualTypeOf<never>();
77+
expectTypeOf(result.error).exclude<{ code: number }>().toEqualTypeOf(undefined);
7878
}
7979
});
8080

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { assertType, describe, expect, test } from "vitest";
2+
import { createObservedClient } from "../helpers.js";
3+
import type { components, paths } from "./schemas/never-response.js";
4+
5+
describe("GET", () => {
6+
test("sends correct method", async () => {
7+
let method = "";
8+
const client = createObservedClient<paths>({}, async (req) => {
9+
method = req.method;
10+
return Response.json({});
11+
});
12+
await client.GET("/posts");
13+
expect(method).toBe("GET");
14+
});
15+
16+
test("sends correct options, returns success", async () => {
17+
const mockData = {
18+
id: 123,
19+
title: "My Post",
20+
};
21+
22+
let actualPathname = "";
23+
const client = createObservedClient<paths>({}, async (req) => {
24+
actualPathname = new URL(req.url).pathname;
25+
return Response.json(mockData);
26+
});
27+
28+
const { data, error, response } = await client.GET("/posts/{id}", {
29+
params: { path: { id: 123 } },
30+
});
31+
32+
assertType<typeof mockData | undefined>(data);
33+
34+
// assert correct URL was called
35+
expect(actualPathname).toBe("/posts/123");
36+
37+
// assert correct data was returned
38+
expect(data).toEqual(mockData);
39+
expect(response.status).toBe(200);
40+
41+
// assert error is empty
42+
expect(error).toBeUndefined();
43+
});
44+
45+
test("sends correct options, returns undefined on 204", async () => {
46+
let actualPathname = "";
47+
const client = createObservedClient<paths>({}, async (req) => {
48+
actualPathname = new URL(req.url).pathname;
49+
return new Response(null, { status: 204 });
50+
});
51+
52+
const { data, error, response } = await client.GET("/posts/{id}", {
53+
params: { path: { id: 123 } },
54+
});
55+
56+
assertType<components["schemas"]["Post"] | undefined>(data);
57+
58+
// assert correct URL was called
59+
expect(actualPathname).toBe("/posts/123");
60+
61+
// assert 204 to be transformed to empty object
62+
expect(data).toEqual({});
63+
expect(response.status).toBe(204);
64+
65+
// assert error is empty
66+
expect(error).toBeUndefined();
67+
});
68+
69+
test("sends correct options, returns error", async () => {
70+
const mockError = { code: 404, message: "Post not found" };
71+
72+
let method = "";
73+
let actualPathname = "";
74+
const client = createObservedClient<paths>({}, async (req) => {
75+
method = req.method;
76+
actualPathname = new URL(req.url).pathname;
77+
return Response.json(mockError, { status: 404 });
78+
});
79+
80+
const { data, error, response } = await client.GET("/posts/{id}", {
81+
params: { path: { id: 123 } },
82+
});
83+
84+
assertType<typeof mockError | undefined>(error);
85+
86+
// assert correct URL was called
87+
expect(actualPathname).toBe("/posts/123");
88+
89+
// assert correct method was called
90+
expect(method).toBe("GET");
91+
92+
// assert correct error was returned
93+
expect(error).toEqual(mockError);
94+
expect(response.status).toBe(404);
95+
96+
// assert data is empty
97+
expect(data).toBeUndefined();
98+
});
99+
100+
test("handles array-type responses", async () => {
101+
const client = createObservedClient<paths>({}, async () => Response.json([]));
102+
103+
const { data } = await client.GET("/posts", { params: {} });
104+
if (!data) {
105+
throw new Error("data empty");
106+
}
107+
108+
// assert array type (and only array type) was inferred
109+
expect(data.length).toBe(0);
110+
});
111+
112+
test("handles empty-array-type 204 response", async () => {
113+
let method = "";
114+
let actualPathname = "";
115+
const client = createObservedClient<paths>({}, async (req) => {
116+
method = req.method;
117+
actualPathname = new URL(req.url).pathname;
118+
return new Response(null, { status: 204 });
119+
});
120+
121+
const { data } = await client.GET("/posts", { params: {} });
122+
123+
assertType<components["schemas"]["Post"][] | unknown[] | undefined>(data);
124+
125+
// assert correct URL was called
126+
expect(actualPathname).toBe("/posts");
127+
128+
// assert correct method was called
129+
expect(method).toBe("GET");
130+
131+
// assert 204 to be transformed to empty object
132+
expect(data).toEqual({});
133+
});
134+
135+
test("gracefully handles invalid JSON for errors", async () => {
136+
const client = createObservedClient<paths>({}, async () => new Response("Unauthorized", { status: 401 }));
137+
138+
const { data, error } = await client.GET("/posts");
139+
140+
expect(data).toBeUndefined();
141+
expect(error).toBe("Unauthorized");
142+
});
143+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* This file was auto-generated by openapi-typescript.
3+
* Do not make direct changes to the file.
4+
*/
5+
6+
export interface paths {
7+
"/posts": {
8+
parameters: {
9+
query?: never;
10+
header?: never;
11+
path?: never;
12+
cookie?: never;
13+
};
14+
get: {
15+
parameters: {
16+
query?: never;
17+
header?: never;
18+
path?: never;
19+
cookie?: never;
20+
};
21+
requestBody?: never;
22+
responses: {
23+
/** @description OK */
24+
200: {
25+
headers: {
26+
[name: string]: unknown;
27+
};
28+
content: {
29+
"application/json": components["schemas"]["Post"][];
30+
};
31+
};
32+
/** @description No posts found, but it's OK */
33+
204: {
34+
headers: {
35+
[name: string]: unknown;
36+
};
37+
content: {
38+
"application/json": unknown[];
39+
};
40+
};
41+
/** @description Unexpected error */
42+
default: {
43+
headers: {
44+
[name: string]: unknown;
45+
};
46+
content: {
47+
"application/json": components["schemas"]["Error"];
48+
};
49+
};
50+
};
51+
};
52+
put?: never;
53+
post?: never;
54+
delete?: never;
55+
options?: never;
56+
head?: never;
57+
patch?: never;
58+
trace?: never;
59+
};
60+
"/posts/{id}": {
61+
parameters: {
62+
query?: never;
63+
header?: never;
64+
path: {
65+
id: number;
66+
};
67+
cookie?: never;
68+
};
69+
get: {
70+
parameters: {
71+
query?: never;
72+
header?: never;
73+
path: {
74+
id: number;
75+
};
76+
cookie?: never;
77+
};
78+
requestBody?: never;
79+
responses: {
80+
/** @description OK */
81+
200: {
82+
headers: {
83+
[name: string]: unknown;
84+
};
85+
content: {
86+
"application/json": components["schemas"]["Post"];
87+
};
88+
};
89+
/** @description No post found, but it's OK */
90+
204: {
91+
headers: {
92+
[name: string]: unknown;
93+
};
94+
content?: never;
95+
};
96+
/** @description A weird error happened */
97+
500: {
98+
headers: {
99+
[name: string]: unknown;
100+
};
101+
content?: never;
102+
};
103+
/** @description Unexpected error */
104+
default: {
105+
headers: {
106+
[name: string]: unknown;
107+
};
108+
content: {
109+
"application/json": components["schemas"]["Error"];
110+
};
111+
};
112+
};
113+
};
114+
put?: never;
115+
post?: never;
116+
delete?: never;
117+
options?: never;
118+
head?: never;
119+
patch?: never;
120+
trace?: never;
121+
};
122+
}
123+
export type webhooks = Record<string, never>;
124+
export interface components {
125+
schemas: {
126+
Error: {
127+
code: number;
128+
message: string;
129+
};
130+
Post: {
131+
id: number;
132+
title: string;
133+
};
134+
};
135+
responses: never;
136+
parameters: never;
137+
requestBodies: never;
138+
headers: never;
139+
pathItems: never;
140+
}
141+
export type $defs = Record<string, never>;
142+
export type operations = Record<string, never>;

0 commit comments

Comments
 (0)