Skip to content

Commit

Permalink
fix(typescript-fetch): runtime validation works (#287)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mnahkies authored Dec 21, 2024
1 parent 2a610c5 commit cead53a
Show file tree
Hide file tree
Showing 10 changed files with 237 additions and 154 deletions.
2 changes: 2 additions & 0 deletions e2e/scripts/generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ yarn openapi-code-generator \
--template typescript-fetch \
--schema-builder zod \
--extract-inline-schemas \
--enable-runtime-response-validation \
--override-specification-title "E2E Test Client"

yarn openapi-code-generator \
Expand All @@ -24,4 +25,5 @@ yarn openapi-code-generator \
--template typescript-axios \
--schema-builder zod \
--extract-inline-schemas \
--enable-runtime-response-validation \
--override-specification-title "E2E Test Client"
20 changes: 17 additions & 3 deletions e2e/src/generated/client/axios/client.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions e2e/src/generated/client/axios/schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 21 additions & 3 deletions e2e/src/generated/client/fetch/client.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions e2e/src/generated/client/fetch/schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions packages/typescript-fetch-runtime/src/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type {Res, StatusCode} from "./types"

export function responseValidationFactoryFactory<Schema>(
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
parse: (schema: Schema, value: unknown) => any,
possibleResponses: [string, Schema][],
defaultResponse?: Schema,
) {
// Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx
possibleResponses.sort((x, y) => (x[0] < y[0] ? -1 : 1))

return async (
whenRes: Promise<Res<StatusCode, unknown>>,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
): Promise<any> => {
const res = await whenRes

const json = async () => {
const status = res.status
const value = await res.json()

for (const [match, schema] of possibleResponses) {
const isMatch =
(/^\d+$/.test(match) && String(status) === match) ||
(/^\d[xX]{2}$/.test(match) && String(status)[0] === match[0])

if (isMatch) {
return parse(schema, value)
}
}

if (defaultResponse) {
return parse(defaultResponse, value)
}

// TODO: throw on unmatched response?
return value
}

return new Proxy(res, {
get(target, prop, receiver) {
if (prop === "json") {
return json
}

return Reflect.get(target, prop, receiver)
},
})
}
}
62 changes: 14 additions & 48 deletions packages/typescript-fetch-runtime/src/joi.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,21 @@
import type {Schema as JoiSchema} from "joi"
import type {Res, StatusCode} from "./main"
import {responseValidationFactoryFactory} from "./common"

export function responseValidationFactory(
possibleResponses: [string, JoiSchema][],
defaultResponse?: JoiSchema,
) {
// Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx
possibleResponses.sort((x, y) => (x[0] < y[0] ? -1 : 1))

// TODO: avoid any
return async (
whenRes: Promise<Res<StatusCode, unknown>>,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
): Promise<any> => {
const res = await whenRes

return {
...res,
json: async () => {
const status = res.status
const value = await res.json()

for (const [match, schema] of possibleResponses) {
const isMatch =
(/^\d+$/.test(match) && String(status) === match) ||
(/^\d[xX]{2}$/.test(match) && String(status)[0] === match[0])

if (isMatch) {
const result = schema.validate(value)

if (result.error) {
throw result.error
}

return result.value
}
}

if (defaultResponse) {
const result = defaultResponse.validate(value)

if (result.error) {
throw result.error
}

return result.value
}

// TODO: throw on unmatched response?
return value
},
}
}
return responseValidationFactoryFactory(
(schema, value) => {
const result = schema.validate(value)

if (result.error) {
throw result.error
}

return result.value
},
possibleResponses,
defaultResponse,
)
}
74 changes: 10 additions & 64 deletions packages/typescript-fetch-runtime/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,14 @@
import qs from "qs"

// from https://stackoverflow.com/questions/39494689/is-it-possible-to-restrict-number-to-a-certain-range
type Enumerate<
N extends number,
Acc extends number[] = [],
> = Acc["length"] extends N
? Acc[number]
: Enumerate<N, [...Acc, Acc["length"]]>

type IntRange<F extends number, T extends number> = F extends T
? F
: Exclude<Enumerate<T>, Enumerate<F>> extends never
? never
: Exclude<Enumerate<T>, Enumerate<F>> | T

export type StatusCode1xx = IntRange<100, 199>
export type StatusCode2xx = IntRange<200, 299>
export type StatusCode3xx = IntRange<300, 399>
export type StatusCode4xx = IntRange<400, 499>
export type StatusCode5xx = IntRange<500, 599>
export type StatusCode =
| StatusCode1xx
| StatusCode2xx
| StatusCode3xx
| StatusCode4xx
| StatusCode5xx

export interface Res<Status extends StatusCode, JsonBody> extends Response {
status: Status
json: () => Promise<JsonBody>
}

export type Server<T> = string & {__server__: T}

export interface AbstractFetchClientConfig {
basePath: string
defaultHeaders?: Record<string, string>
defaultTimeout?: number
}

export type QueryParams = {
[name: string]:
| string
| number
| number[]
| boolean
| string[]
| undefined
| null
| QueryParams
| QueryParams[]
}

export type HeaderParams =
| Record<string, string | number | undefined | null>
| [string, string | number | undefined | null][]
| Headers

// fetch HeadersInit type
export type HeadersInit =
| string[][]
| readonly (readonly [string, string])[]
| Record<string, string | ReadonlyArray<string>>
| Headers
import type {
AbstractFetchClientConfig,
HeaderParams,
HeadersInit,
QueryParams,
Res,
StatusCode,
} from "./types"

export * from "./types"

export abstract class AbstractFetchClient {
protected readonly basePath: string
Expand Down
Loading

0 comments on commit cead53a

Please sign in to comment.