Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/brave-plums-parse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kubb/plugin-fetch": patch
"@kubb/plugin-axios": patch
---

Response bodies with a `format: int64` field no longer lose precision before validation. `JSON.parse` always rounds an integer past `Number.MAX_SAFE_INTEGER` to the nearest representable `number`, so even after #797 coerced the field to `z.coerce.bigint()`, the value was already wrong by the time Zod saw it. Both clients now parse the response body with a bigint-safe `parseJson`, which reads an out-of-range integer literal straight into a `bigint` instead of through the lossy intermediate `number`. Ordinary payloads keep going through the native `JSON.parse` fast path.
29 changes: 27 additions & 2 deletions examples/advanced/src/gen/.kubb/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import axios from 'axios'
import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { applyHeaderStyles, defaultBodySerializer, defaultPathSerializer, defaultQuerySerializer, isDefaultJsonBody, serializeCookies } from './serializers'
import {
applyHeaderStyles,
defaultBodySerializer,
defaultPathSerializer,
defaultQuerySerializer,
isDefaultJsonBody,
parseJson,
serializeCookies,
} from './serializers'
import type { HeadersInit, PathParamStyle, PathSerializer, Serializers, Styles } from './serializers'
import { type StandardSchemaValidator, validateStandardSchema } from './standardSchema'

Expand Down Expand Up @@ -423,6 +431,22 @@ function resolveSerializers({ config, requestConfig }: { config: { serializer?:
}
}

/**
* Axios's own default `transformResponse`, with `JSON.parse` swapped for `parseJson` so `format:
* int64` fields survive the response the same way plugin-fetch's `parseResponse` already does. A
* regular function, not an arrow, so `this` resolves to the merged axios config the way axios calls it.
*/
function transformResponse(this: AxiosRequestConfig, data: unknown): unknown {
const jsonRequested = this.responseType === 'json'
if (typeof data !== 'string' || !data || (this.responseType !== undefined && !jsonRequested)) return data
try {
return parseJson(data)
} catch (error) {
if (jsonRequested) throw error
return data
}
}

/**
* Resolves everything a call needs before it touches axios: merged headers with the negotiated
* content type, auth on headers or query, serialized cookies, the validated and serialized body,
Expand Down Expand Up @@ -498,6 +522,7 @@ async function resolveRequest<TBody, TRequest, TResponse>({
paramsSerializer: (params) => querySerializer(params as Record<string, unknown>, requestConfig.styles?.query),
data: body,
transformRequest: (data) => data,
transformResponse,
signal: requestConfig.signal,
responseType: requestConfig.responseType,
validateStatus,
Expand Down Expand Up @@ -668,7 +693,7 @@ function parseEvent<TData>(raw: string): ServerSentEvent<TData> | undefined {
if (data.length) {
const joined = data.join('\n')
try {
event.data = JSON.parse(joined) as TData
event.data = parseJson(joined) as TData
} catch {
event.data = joined as TData
}
Expand Down
158 changes: 157 additions & 1 deletion examples/advanced/src/gen/.kubb/serializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ export function isDefaultJsonBody(body: unknown): boolean {

/**
* Emits a `bigint` (`format: int64`) as a JSON number, which `JSON.stringify` refuses to do itself.
* Past the safe-integer range it throws, so an id never goes out silently truncated.
* Past the safe-integer range it throws, so an id never goes out silently truncated. `parseJson`
* is the response-side counterpart, reading a `bigint` back out of the response body.
*/
function jsonReplacer(_key: string, value: unknown): unknown {
if (typeof value !== 'bigint') return value
Expand All @@ -137,6 +138,161 @@ function jsonReplacer(_key: string, value: unknown): unknown {
return Number(value)
}

type JsonScanner = { text: string; index: number }

// Below this many digits an integer literal is always within Number.MAX_SAFE_INTEGER, so `hasUnsafeInteger`
// can rule out the slow path without walking the whole string for the common case.
const unsafeIntegerPattern = /\d{16,}/

function isJsonDigit(char: string | undefined): boolean {
return char !== undefined && char >= '0' && char <= '9'
}

function isJsonWhitespace(char: string | undefined): boolean {
return char === ' ' || char === '\t' || char === '\n' || char === '\r'
}

function skipJsonWhitespace(scanner: JsonScanner): void {
while (isJsonWhitespace(scanner.text[scanner.index])) scanner.index++
}

const jsonEscapes: Record<string, string> = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }

function parseJsonString(scanner: JsonScanner): string {
scanner.index++ // opening quote
let result = ''
while (true) {
const char = scanner.text[scanner.index]
if (char === undefined) throw new SyntaxError('Unterminated string in JSON')
scanner.index++
if (char === '"') return result
if (char !== '\\') {
result += char
continue
}
const escape = scanner.text[scanner.index]
scanner.index++
if (escape === 'u') {
result += String.fromCharCode(Number.parseInt(scanner.text.slice(scanner.index, scanner.index + 4), 16))
scanner.index += 4
continue
}
const replacement = escape !== undefined ? jsonEscapes[escape] : undefined
if (replacement === undefined) throw new SyntaxError(`Invalid escape sequence in JSON at position ${scanner.index}`)
result += replacement
}
}

function parseJsonNumber(scanner: JsonScanner): number | bigint {
const start = scanner.index
if (scanner.text[scanner.index] === '-') scanner.index++
while (isJsonDigit(scanner.text[scanner.index])) scanner.index++
let isInteger = true
if (scanner.text[scanner.index] === '.') {
isInteger = false
scanner.index++
while (isJsonDigit(scanner.text[scanner.index])) scanner.index++
}
if (scanner.text[scanner.index] === 'e' || scanner.text[scanner.index] === 'E') {
isInteger = false
scanner.index++
if (scanner.text[scanner.index] === '+' || scanner.text[scanner.index] === '-') scanner.index++
while (isJsonDigit(scanner.text[scanner.index])) scanner.index++
}
const literal = scanner.text.slice(start, scanner.index)
return isInteger && !Number.isSafeInteger(Number(literal)) ? BigInt(literal) : Number(literal)
}

function parseJsonArray(scanner: JsonScanner): Array<unknown> {
scanner.index++ // '['
const result: Array<unknown> = []
skipJsonWhitespace(scanner)
if (scanner.text[scanner.index] === ']') {
scanner.index++
return result
}
while (true) {
skipJsonWhitespace(scanner)
result.push(parseJsonValue(scanner))
skipJsonWhitespace(scanner)
const next = scanner.text[scanner.index]
scanner.index++
if (next === ']') return result
if (next !== ',') throw new SyntaxError(`Expected ',' or ']' in JSON at position ${scanner.index}`)
}
}

function parseJsonObject(scanner: JsonScanner): Record<string, unknown> {
scanner.index++ // '{'
const result: Record<string, unknown> = {}
skipJsonWhitespace(scanner)
if (scanner.text[scanner.index] === '}') {
scanner.index++
return result
}
while (true) {
skipJsonWhitespace(scanner)
if (scanner.text[scanner.index] !== '"') throw new SyntaxError(`Expected a string key in JSON at position ${scanner.index}`)
const key = parseJsonString(scanner)
skipJsonWhitespace(scanner)
if (scanner.text[scanner.index] !== ':') throw new SyntaxError(`Expected ':' in JSON at position ${scanner.index}`)
scanner.index++
skipJsonWhitespace(scanner)
result[key] = parseJsonValue(scanner)
skipJsonWhitespace(scanner)
const next = scanner.text[scanner.index]
scanner.index++
if (next === '}') return result
if (next !== ',') throw new SyntaxError(`Expected ',' or '}' in JSON at position ${scanner.index}`)
}
}

function parseJsonValue(scanner: JsonScanner): unknown {
const char = scanner.text[scanner.index]
if (char === '{') return parseJsonObject(scanner)
if (char === '[') return parseJsonArray(scanner)
if (char === '"') return parseJsonString(scanner)
if (char === '-' || isJsonDigit(char)) return parseJsonNumber(scanner)
if (scanner.text.startsWith('true', scanner.index)) {
scanner.index += 4
return true
}
if (scanner.text.startsWith('false', scanner.index)) {
scanner.index += 5
return false
}
if (scanner.text.startsWith('null', scanner.index)) {
scanner.index += 4
return null
}
throw new SyntaxError(`Unexpected token in JSON at position ${scanner.index}`)
}

/**
* `JSON.parse`, except an integer literal outside the safe-integer range parses as a `bigint`
* instead of the `number` the built-in parser would round it to. `format: int64` fields validate
* against a `bigint` (see `jsonReplacer`'s request-side counterpart), and `JSON.parse` itself never
* hands back a `bigint`, so without this the value has already lost precision before validation runs.
*
* Scans the raw text once to check for a run of digits long enough to be unsafe; when none exists
* it defers to the native `JSON.parse` instead of paying for a hand-written parser on every call.
*
* @example
* ```ts
* parseJson('{"id":9007199254740993}') // { id: 9007199254740993n }
* parseJson('{"id":42}') // { id: 42 }
* ```
*/
export function parseJson(text: string): unknown {
if (!unsafeIntegerPattern.test(text)) return JSON.parse(text)
const scanner: JsonScanner = { text, index: 0 }
skipJsonWhitespace(scanner)
const value = parseJsonValue(scanner)
skipJsonWhitespace(scanner)
if (scanner.index < text.length) throw new SyntaxError(`Unexpected trailing content in JSON at position ${scanner.index}`)
return value
}

function appendFormDataValue({ formData, key, value, contentType }: { formData: FormData; key: string; value: unknown; contentType?: string }): void {
if (value === undefined || value === null) return
if (value instanceof Blob) formData.append(key, value)
Expand Down
29 changes: 27 additions & 2 deletions examples/axios/src/gen/.kubb/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import axios from 'axios'
import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { applyHeaderStyles, defaultBodySerializer, defaultPathSerializer, defaultQuerySerializer, isDefaultJsonBody, serializeCookies } from './serializers'
import {
applyHeaderStyles,
defaultBodySerializer,
defaultPathSerializer,
defaultQuerySerializer,
isDefaultJsonBody,
parseJson,
serializeCookies,
} from './serializers'
import type { HeadersInit, PathParamStyle, PathSerializer, Serializers, Styles } from './serializers'
import { type StandardSchemaValidator, validateStandardSchema } from './standardSchema'

Expand Down Expand Up @@ -423,6 +431,22 @@ function resolveSerializers({ config, requestConfig }: { config: { serializer?:
}
}

/**
* Axios's own default `transformResponse`, with `JSON.parse` swapped for `parseJson` so `format:
* int64` fields survive the response the same way plugin-fetch's `parseResponse` already does. A
* regular function, not an arrow, so `this` resolves to the merged axios config the way axios calls it.
*/
function transformResponse(this: AxiosRequestConfig, data: unknown): unknown {
const jsonRequested = this.responseType === 'json'
if (typeof data !== 'string' || !data || (this.responseType !== undefined && !jsonRequested)) return data
try {
return parseJson(data)
} catch (error) {
if (jsonRequested) throw error
return data
}
}

/**
* Resolves everything a call needs before it touches axios: merged headers with the negotiated
* content type, auth on headers or query, serialized cookies, the validated and serialized body,
Expand Down Expand Up @@ -498,6 +522,7 @@ async function resolveRequest<TBody, TRequest, TResponse>({
paramsSerializer: (params) => querySerializer(params as Record<string, unknown>, requestConfig.styles?.query),
data: body,
transformRequest: (data) => data,
transformResponse,
signal: requestConfig.signal,
responseType: requestConfig.responseType,
validateStatus,
Expand Down Expand Up @@ -668,7 +693,7 @@ function parseEvent<TData>(raw: string): ServerSentEvent<TData> | undefined {
if (data.length) {
const joined = data.join('\n')
try {
event.data = JSON.parse(joined) as TData
event.data = parseJson(joined) as TData
} catch {
event.data = joined as TData
}
Expand Down
Loading
Loading