Skip to content

Commit cead53a

Browse files
authored
fix(typescript-fetch): runtime validation works (#287)
solves #286 - Use `--enable-runtime-response-validation` for the E2E tests - Refactor `typescript-fetch` response validation factory to share most of the implementation between schema builders - Fix the returned `res` of the response validation factory to be a `Proxy` to the actual `res` object, only intercepting the `json` method
1 parent 2a610c5 commit cead53a

File tree

10 files changed

+237
-154
lines changed

10 files changed

+237
-154
lines changed

e2e/scripts/generate.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ yarn openapi-code-generator \
1616
--template typescript-fetch \
1717
--schema-builder zod \
1818
--extract-inline-schemas \
19+
--enable-runtime-response-validation \
1920
--override-specification-title "E2E Test Client"
2021

2122
yarn openapi-code-generator \
@@ -24,4 +25,5 @@ yarn openapi-code-generator \
2425
--template typescript-axios \
2526
--schema-builder zod \
2627
--extract-inline-schemas \
28+
--enable-runtime-response-validation \
2729
--override-specification-title "E2E Test Client"

e2e/src/generated/client/axios/client.ts

Lines changed: 17 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/src/generated/client/axios/schemas.ts

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/src/generated/client/fetch/client.ts

Lines changed: 21 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/src/generated/client/fetch/schemas.ts

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type {Res, StatusCode} from "./types"
2+
3+
export function responseValidationFactoryFactory<Schema>(
4+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
5+
parse: (schema: Schema, value: unknown) => any,
6+
possibleResponses: [string, Schema][],
7+
defaultResponse?: Schema,
8+
) {
9+
// Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx
10+
possibleResponses.sort((x, y) => (x[0] < y[0] ? -1 : 1))
11+
12+
return async (
13+
whenRes: Promise<Res<StatusCode, unknown>>,
14+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
15+
): Promise<any> => {
16+
const res = await whenRes
17+
18+
const json = async () => {
19+
const status = res.status
20+
const value = await res.json()
21+
22+
for (const [match, schema] of possibleResponses) {
23+
const isMatch =
24+
(/^\d+$/.test(match) && String(status) === match) ||
25+
(/^\d[xX]{2}$/.test(match) && String(status)[0] === match[0])
26+
27+
if (isMatch) {
28+
return parse(schema, value)
29+
}
30+
}
31+
32+
if (defaultResponse) {
33+
return parse(defaultResponse, value)
34+
}
35+
36+
// TODO: throw on unmatched response?
37+
return value
38+
}
39+
40+
return new Proxy(res, {
41+
get(target, prop, receiver) {
42+
if (prop === "json") {
43+
return json
44+
}
45+
46+
return Reflect.get(target, prop, receiver)
47+
},
48+
})
49+
}
50+
}
Lines changed: 14 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,21 @@
11
import type {Schema as JoiSchema} from "joi"
2-
import type {Res, StatusCode} from "./main"
2+
import {responseValidationFactoryFactory} from "./common"
33

44
export function responseValidationFactory(
55
possibleResponses: [string, JoiSchema][],
66
defaultResponse?: JoiSchema,
77
) {
8-
// Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx
9-
possibleResponses.sort((x, y) => (x[0] < y[0] ? -1 : 1))
10-
11-
// TODO: avoid any
12-
return async (
13-
whenRes: Promise<Res<StatusCode, unknown>>,
14-
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
15-
): Promise<any> => {
16-
const res = await whenRes
17-
18-
return {
19-
...res,
20-
json: async () => {
21-
const status = res.status
22-
const value = await res.json()
23-
24-
for (const [match, schema] of possibleResponses) {
25-
const isMatch =
26-
(/^\d+$/.test(match) && String(status) === match) ||
27-
(/^\d[xX]{2}$/.test(match) && String(status)[0] === match[0])
28-
29-
if (isMatch) {
30-
const result = schema.validate(value)
31-
32-
if (result.error) {
33-
throw result.error
34-
}
35-
36-
return result.value
37-
}
38-
}
39-
40-
if (defaultResponse) {
41-
const result = defaultResponse.validate(value)
42-
43-
if (result.error) {
44-
throw result.error
45-
}
46-
47-
return result.value
48-
}
49-
50-
// TODO: throw on unmatched response?
51-
return value
52-
},
53-
}
54-
}
8+
return responseValidationFactoryFactory(
9+
(schema, value) => {
10+
const result = schema.validate(value)
11+
12+
if (result.error) {
13+
throw result.error
14+
}
15+
16+
return result.value
17+
},
18+
possibleResponses,
19+
defaultResponse,
20+
)
5521
}

packages/typescript-fetch-runtime/src/main.ts

Lines changed: 10 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,14 @@
11
import qs from "qs"
2-
3-
// from https://stackoverflow.com/questions/39494689/is-it-possible-to-restrict-number-to-a-certain-range
4-
type Enumerate<
5-
N extends number,
6-
Acc extends number[] = [],
7-
> = Acc["length"] extends N
8-
? Acc[number]
9-
: Enumerate<N, [...Acc, Acc["length"]]>
10-
11-
type IntRange<F extends number, T extends number> = F extends T
12-
? F
13-
: Exclude<Enumerate<T>, Enumerate<F>> extends never
14-
? never
15-
: Exclude<Enumerate<T>, Enumerate<F>> | T
16-
17-
export type StatusCode1xx = IntRange<100, 199>
18-
export type StatusCode2xx = IntRange<200, 299>
19-
export type StatusCode3xx = IntRange<300, 399>
20-
export type StatusCode4xx = IntRange<400, 499>
21-
export type StatusCode5xx = IntRange<500, 599>
22-
export type StatusCode =
23-
| StatusCode1xx
24-
| StatusCode2xx
25-
| StatusCode3xx
26-
| StatusCode4xx
27-
| StatusCode5xx
28-
29-
export interface Res<Status extends StatusCode, JsonBody> extends Response {
30-
status: Status
31-
json: () => Promise<JsonBody>
32-
}
33-
34-
export type Server<T> = string & {__server__: T}
35-
36-
export interface AbstractFetchClientConfig {
37-
basePath: string
38-
defaultHeaders?: Record<string, string>
39-
defaultTimeout?: number
40-
}
41-
42-
export type QueryParams = {
43-
[name: string]:
44-
| string
45-
| number
46-
| number[]
47-
| boolean
48-
| string[]
49-
| undefined
50-
| null
51-
| QueryParams
52-
| QueryParams[]
53-
}
54-
55-
export type HeaderParams =
56-
| Record<string, string | number | undefined | null>
57-
| [string, string | number | undefined | null][]
58-
| Headers
59-
60-
// fetch HeadersInit type
61-
export type HeadersInit =
62-
| string[][]
63-
| readonly (readonly [string, string])[]
64-
| Record<string, string | ReadonlyArray<string>>
65-
| Headers
2+
import type {
3+
AbstractFetchClientConfig,
4+
HeaderParams,
5+
HeadersInit,
6+
QueryParams,
7+
Res,
8+
StatusCode,
9+
} from "./types"
10+
11+
export * from "./types"
6612

6713
export abstract class AbstractFetchClient {
6814
protected readonly basePath: string

0 commit comments

Comments
 (0)