diff --git a/examples/generated-rq/adyen-checkout/models.ts b/examples/generated-rq/adyen-checkout/models.ts index 00d5bdb..522b34b 100644 --- a/examples/generated-rq/adyen-checkout/models.ts +++ b/examples/generated-rq/adyen-checkout/models.ts @@ -365,7 +365,7 @@ export interface Agency { export interface Airline { agency?: Agency - boardingFee?: bigint + boardingFee?: number /* int64, precision limited to 2^53-1 */ code?: string computerizedReservationSystem?: string customerReferenceNumber?: string @@ -396,12 +396,12 @@ export interface AmazonPayDetails { export interface Amount { currency: string - value: bigint + value: number /* int64, precision limited to 2^53-1 */ } export interface Amounts { currency: string - values: bigint[] + values: number /* int64, precision limited to 2^53-1 */[] } export interface AncvDetails { @@ -1199,9 +1199,9 @@ export interface DokuDetails { export interface Donation { currency: string donationType: string - maxRoundupAmount?: bigint + maxRoundupAmount?: number /* int64, precision limited to 2^53-1 */ type: string - values?: bigint[] + values?: number /* int64, precision limited to 2^53-1 */[] } export interface DonationCampaign { @@ -1530,12 +1530,12 @@ export interface Item { export interface ItemDetailLine { commodityCode?: string description?: string - discountAmount?: bigint + discountAmount?: number /* int64, precision limited to 2^53-1 */ productCode?: string - quantity?: bigint - totalAmount?: bigint + quantity?: number /* int64, precision limited to 2^53-1 */ + totalAmount?: number /* int64, precision limited to 2^53-1 */ unitOfMeasure?: string - unitPrice?: bigint + unitPrice?: number /* int64, precision limited to 2^53-1 */ } export interface KlarnaDetails { @@ -1572,7 +1572,7 @@ export interface Leg { classOfTravel?: string dateOfTravel?: string /* date-time */ departureAirportCode?: string - departureTax?: bigint + departureTax?: number /* int64, precision limited to 2^53-1 */ destinationAirportCode?: string fareBasisCode?: string flightNumber?: string @@ -1582,17 +1582,17 @@ export interface Leg { export interface LevelTwoThree { customerReferenceNumber?: string destination?: Destination - dutyAmount?: bigint - freightAmount?: bigint + dutyAmount?: number /* int64, precision limited to 2^53-1 */ + freightAmount?: number /* int64, precision limited to 2^53-1 */ itemDetailLines?: ItemDetailLine[] orderDate?: string /* date */ shipFromPostalCode?: string - totalTaxAmount?: bigint + totalTaxAmount?: number /* int64, precision limited to 2^53-1 */ } export interface LineItem { - amountExcludingTax?: bigint - amountIncludingTax?: bigint + amountExcludingTax?: number /* int64, precision limited to 2^53-1 */ + amountIncludingTax?: number /* int64, precision limited to 2^53-1 */ brand?: string color?: string description?: string @@ -1602,12 +1602,12 @@ export interface LineItem { manufacturer?: string marketplaceSellerId?: string productUrl?: string - quantity?: bigint + quantity?: number /* int64, precision limited to 2^53-1 */ receiverEmail?: string size?: string sku?: string - taxAmount?: bigint - taxPercentage?: bigint + taxAmount?: number /* int64, precision limited to 2^53-1 */ + taxPercentage?: number /* int64, precision limited to 2^53-1 */ upc?: string } @@ -2550,7 +2550,7 @@ export interface PixPayByBankDetails { export interface PixPayByBankRiskSignals { confidenceScore?: ConfidenceScore - elapsedTimeSinceBoot?: bigint + elapsedTimeSinceBoot?: number /* int64, precision limited to 2^53-1 */ isRootedDevice?: boolean language?: string osVersion?: string @@ -2876,7 +2876,7 @@ export interface Split { export interface SplitAmount { currency?: string - value: bigint + value: number /* int64, precision limited to 2^53-1 */ } export interface StandalonePaymentCancelRequest { @@ -3018,7 +3018,7 @@ export interface SubMerchantInfo { } export interface Surcharge { - value: bigint + value: number /* int64, precision limited to 2^53-1 */ } export interface TaxTotal { diff --git a/examples/generated-rq/adyen-legal-entity/models.ts b/examples/generated-rq/adyen-legal-entity/models.ts index 347bf8f..51b1ad5 100644 --- a/examples/generated-rq/adyen-legal-entity/models.ts +++ b/examples/generated-rq/adyen-legal-entity/models.ts @@ -46,7 +46,7 @@ export interface Address { export interface Amount { currency?: string - value?: bigint + value?: number /* int64, precision limited to 2^53-1 */ } export interface Attachment { diff --git a/examples/generated-rq/devto/models.ts b/examples/generated-rq/devto/models.ts index 5617dc9..343c4d8 100644 --- a/examples/generated-rq/devto/models.ts +++ b/examples/generated-rq/devto/models.ts @@ -36,11 +36,11 @@ export interface ArticleIndex { export interface VideoArticle { type_of?: string - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ path?: string cloudinary_video_url?: string title?: string - user_id?: bigint + user_id?: number /* int64, precision limited to 2^53-1 */ video_duration_in_minutes?: string video_source_url?: string user?: { @@ -78,13 +78,13 @@ export interface Organization { } export interface FollowedTag { - id: bigint + id: number /* int64, precision limited to 2^53-1 */ name: string points: number } export interface Tag { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ name?: string bg_color_hex?: string | null text_color_hex?: string | null @@ -138,7 +138,7 @@ export interface SharedOrganization { export interface User { type_of?: string - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ username?: string name?: string summary?: string | null @@ -152,7 +152,7 @@ export interface User { export interface ExtendedUser { type_of?: string - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ username?: string name?: string summary?: string | null @@ -168,7 +168,7 @@ export interface ExtendedUser { export interface MyUser { type_of?: string - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ username?: string name?: string summary?: string | null @@ -261,7 +261,7 @@ export interface SegmentUserIds { } export interface AgentSessionIndex { - id: bigint + id: number /* int64, precision limited to 2^53-1 */ slug: string title: string tool_name: string @@ -273,7 +273,7 @@ export interface AgentSessionIndex { } export interface AgentSessionShow { - id: bigint + id: number /* int64, precision limited to 2^53-1 */ slug: string title: string tool_name: string @@ -290,7 +290,7 @@ export interface AgentSessionShow { export interface PollOption { type_of: 'poll_option' - id: bigint + id: number /* int64, precision limited to 2^53-1 */ markdown: string | null processed_html: string | null position: number @@ -300,7 +300,7 @@ export interface PollOption { export interface Poll { type_of: 'poll' - id: bigint + id: number /* int64, precision limited to 2^53-1 */ prompt_markdown: string | null prompt_html: string | null poll_type_of: 'single_choice' | 'multiple_choice' | 'scale' | 'text_input' @@ -317,7 +317,7 @@ export interface Poll { export interface Survey { type_of: 'survey' - id: bigint + id: number /* int64, precision limited to 2^53-1 */ title: string slug: string survey_type_of: 'community_pulse' | 'industry' | 'fun' @@ -334,10 +334,10 @@ export type SurveyWithPolls = Survey & { export interface PollVote { type_of: 'poll_vote' - id: bigint - poll_id: bigint - poll_option_id: bigint - user_id: bigint + id: number /* int64, precision limited to 2^53-1 */ + poll_id: number /* int64, precision limited to 2^53-1 */ + poll_option_id: number /* int64, precision limited to 2^53-1 */ + user_id: number /* int64, precision limited to 2^53-1 */ user_email: string session_start: number created_at: string /* date-time */ @@ -345,9 +345,9 @@ export interface PollVote { export interface PollTextResponse { type_of: 'poll_text_response' - id: bigint - poll_id: bigint - user_id: bigint + id: number /* int64, precision limited to 2^53-1 */ + poll_id: number /* int64, precision limited to 2^53-1 */ + user_id: number /* int64, precision limited to 2^53-1 */ user_email: string text_content: string session_start: number diff --git a/examples/generated-rq/openai/models.ts b/examples/generated-rq/openai/models.ts index 9e4f92b..2e34bd2 100644 --- a/examples/generated-rq/openai/models.ts +++ b/examples/generated-rq/openai/models.ts @@ -41,7 +41,7 @@ export interface AssignedRoleDetails { predefined_role: boolean description: string | null created_at: number | null - updated_at: bigint | null + updated_at: number /* int64, precision limited to 2^53-1 */ | null created_by: string | null created_by_user_obj: Record | null metadata: Record | null @@ -682,7 +682,7 @@ export interface Batch { export interface BatchFileExpirationAfter { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface Certificate { @@ -1288,7 +1288,7 @@ export interface CreateCompletionRequest { max_tokens?: number | null n?: number | null presence_penalty?: number | null - seed?: bigint | null + seed?: number /* int64, precision limited to 2^53-1 */ | null stop?: StopConfiguration stream?: boolean | null stream_options?: ChatCompletionStreamOptions @@ -2302,7 +2302,7 @@ export interface EvalStoredCompletionsSource { export interface FileExpirationAfter { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface FilePath { @@ -4498,7 +4498,7 @@ export interface RealtimeConversationItemWithReference { export interface RealtimeCreateClientSecretRequest { expires_after?: { anchor?: 'created_at' - seconds?: bigint + seconds?: number /* int64, precision limited to 2^53-1 */ } session?: RealtimeSessionCreateRequestGA | RealtimeTranscriptionSessionCreateRequestGA } @@ -5491,7 +5491,7 @@ export interface RealtimeTranslationClientEventSessionUpdate { export interface RealtimeTranslationClientSecretCreateRequest { expires_after?: { anchor?: 'created_at' - seconds?: bigint + seconds?: number /* int64, precision limited to 2^53-1 */ } session: RealtimeTranslationSessionCreateRequest } @@ -6944,7 +6944,7 @@ export interface UsageAudioSpeechesResult { export interface UsageAudioTranscriptionsResult { object: 'organization.usage.audio_transcriptions.result' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ num_model_requests: number project_id?: string | null user_id?: string | null @@ -8641,7 +8641,7 @@ export interface WorkflowParam { export interface ExpiresAfterParam { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface RateLimitsParam { diff --git a/examples/generated-rq/petstore-3.0/client.ts b/examples/generated-rq/petstore-3.0/client.ts index 8319eb0..a960764 100644 --- a/examples/generated-rq/petstore-3.0/client.ts +++ b/examples/generated-rq/petstore-3.0/client.ts @@ -182,7 +182,7 @@ export async function uploadFile( export async function getInventory( config?: Partial -): Promise> { +): Promise> { const res = await _request('GET', '/store/inventory', {}, config) return res.json() } diff --git a/examples/generated-rq/petstore-3.0/models.ts b/examples/generated-rq/petstore-3.0/models.ts index ca3e7ce..c427a86 100644 --- a/examples/generated-rq/petstore-3.0/models.ts +++ b/examples/generated-rq/petstore-3.0/models.ts @@ -1,8 +1,8 @@ // This file is auto-generated by openapi-zod-ts - do not edit export interface Order { - id?: bigint - petId?: bigint + id?: number /* int64, precision limited to 2^53-1 */ + petId?: number /* int64, precision limited to 2^53-1 */ quantity?: number shipDate?: string /* date-time */ status?: 'placed' | 'approved' | 'delivered' @@ -10,12 +10,12 @@ export interface Order { } export interface Category { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ name?: string } export interface User { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ username?: string firstName?: string lastName?: string @@ -26,12 +26,12 @@ export interface User { } export interface Tag { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ name?: string } export interface Pet { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ name: string category?: Category photoUrls: string[] diff --git a/examples/generated-rq/spotify/models.ts b/examples/generated-rq/spotify/models.ts index 7b4d8b6..86541ba 100644 --- a/examples/generated-rq/spotify/models.ts +++ b/examples/generated-rq/spotify/models.ts @@ -70,7 +70,7 @@ export interface CurrentlyPlayingContextObject { repeat_state?: string shuffle_state?: boolean context?: ContextObject - timestamp?: bigint + timestamp?: number /* int64, precision limited to 2^53-1 */ progress_ms?: number is_playing?: boolean item?: TrackObject | EpisodeObject @@ -128,7 +128,7 @@ export interface AudioAnalysisObject { platform?: string detailed_status?: string status_code?: number - timestamp?: bigint + timestamp?: number /* int64, precision limited to 2^53-1 */ analysis_time?: number input_process?: string } @@ -672,7 +672,7 @@ export interface ExplicitContentSettingsObject { export interface CurrentlyPlayingObject { context?: ContextObject - timestamp?: bigint + timestamp?: number /* int64, precision limited to 2^53-1 */ progress_ms?: number is_playing?: boolean item?: TrackObject | EpisodeObject diff --git a/examples/generated/openai/models.ts b/examples/generated/openai/models.ts index 9e4f92b..2e34bd2 100644 --- a/examples/generated/openai/models.ts +++ b/examples/generated/openai/models.ts @@ -41,7 +41,7 @@ export interface AssignedRoleDetails { predefined_role: boolean description: string | null created_at: number | null - updated_at: bigint | null + updated_at: number /* int64, precision limited to 2^53-1 */ | null created_by: string | null created_by_user_obj: Record | null metadata: Record | null @@ -682,7 +682,7 @@ export interface Batch { export interface BatchFileExpirationAfter { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface Certificate { @@ -1288,7 +1288,7 @@ export interface CreateCompletionRequest { max_tokens?: number | null n?: number | null presence_penalty?: number | null - seed?: bigint | null + seed?: number /* int64, precision limited to 2^53-1 */ | null stop?: StopConfiguration stream?: boolean | null stream_options?: ChatCompletionStreamOptions @@ -2302,7 +2302,7 @@ export interface EvalStoredCompletionsSource { export interface FileExpirationAfter { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface FilePath { @@ -4498,7 +4498,7 @@ export interface RealtimeConversationItemWithReference { export interface RealtimeCreateClientSecretRequest { expires_after?: { anchor?: 'created_at' - seconds?: bigint + seconds?: number /* int64, precision limited to 2^53-1 */ } session?: RealtimeSessionCreateRequestGA | RealtimeTranscriptionSessionCreateRequestGA } @@ -5491,7 +5491,7 @@ export interface RealtimeTranslationClientEventSessionUpdate { export interface RealtimeTranslationClientSecretCreateRequest { expires_after?: { anchor?: 'created_at' - seconds?: bigint + seconds?: number /* int64, precision limited to 2^53-1 */ } session: RealtimeTranslationSessionCreateRequest } @@ -6944,7 +6944,7 @@ export interface UsageAudioSpeechesResult { export interface UsageAudioTranscriptionsResult { object: 'organization.usage.audio_transcriptions.result' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ num_model_requests: number project_id?: string | null user_id?: string | null @@ -8641,7 +8641,7 @@ export interface WorkflowParam { export interface ExpiresAfterParam { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface RateLimitsParam { diff --git a/examples/generated/petstore-3.0/client.ts b/examples/generated/petstore-3.0/client.ts index e1fb681..972fe90 100644 --- a/examples/generated/petstore-3.0/client.ts +++ b/examples/generated/petstore-3.0/client.ts @@ -186,7 +186,7 @@ export async function uploadFile( export async function getInventory( config?: Partial -): Promise> { +): Promise> { const res = await _request('GET', '/store/inventory', {}, config) return res.json() } diff --git a/packages/openapi-zod-ts/src/__tests__/client.test.ts b/packages/openapi-zod-ts/src/__tests__/client.test.ts index b563116..8ee4929 100644 --- a/packages/openapi-zod-ts/src/__tests__/client.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/client.test.ts @@ -251,6 +251,73 @@ describe('inline response schemas', () => { }) }) +describe('error_body_type (#293): typed schema-less error bodies', () => { + const minimalSpec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/items': { + get: { + operationId: 'listItems', + responses: { + '200': { content: { 'application/json': { schema: { type: 'string' } } } }, + }, + }, + }, + }, + } + + it('without errorBodyType: error body cast uses no type annotation', () => { + const out = generateClient(minimalSpec).content + expect(out).toContain('await res.json().catch(() => null)') + expect(out).not.toContain(' as ') + }) + + it('errorBodyType laravel: LaravelValidationError type is emitted after ApiError', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'laravel' }).content + expect(out).toContain('export type LaravelValidationError = {') + expect(out).toContain('message: string') + expect(out).toContain('errors: Record') + }) + + it('errorBodyType laravel: error body is cast to LaravelValidationError', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'laravel' }).content + expect(out).toContain('await res.json().catch(() => null) as LaravelValidationError') + }) + + it('errorBodyType laravel: no import is emitted for LaravelValidationError', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'laravel' }).content + expect(out).not.toMatch(/import type \{.*LaravelValidationError/) + }) + + it('errorBodyType custom + importPath: import type is emitted at top', () => { + const out = generateClient(minimalSpec, { + errorBodyType: 'ApiErrorBody', + errorBodyTypeImport: './types/errors', + }).content + expect(out).toContain("import type { ApiErrorBody } from './types/errors'") + }) + + it('errorBodyType custom + importPath: error body is cast to custom type', () => { + const out = generateClient(minimalSpec, { + errorBodyType: 'ApiErrorBody', + errorBodyTypeImport: './types/errors', + }).content + expect(out).toContain('await res.json().catch(() => null) as ApiErrorBody') + }) + + it('errorBodyType custom without importPath: cast emitted, no import', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'GlobalErrorType' }).content + expect(out).toContain('await res.json().catch(() => null) as GlobalErrorType') + expect(out).not.toMatch(/import type \{.*GlobalErrorType/) + }) + + it('errorBodyType custom: LaravelValidationError type NOT emitted', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'ApiErrorBody' }).content + expect(out).not.toContain('LaravelValidationError') + }) +}) + describe('generateClient with empty paths', () => { it('handles spec with no paths gracefully', async () => { const spec = await parseSpec(join(__dirname, '../__fixtures__/specs/task-manager.json')) @@ -643,6 +710,120 @@ describe('coverage: deprecated + throws together (lines 321-329, 387-394)', () = }) }) +describe('inline additionalProperties response typing (#294)', () => { + it('operation response with additionalProperties schema object -> Record', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/metrics': { + get: { + operationId: 'getMetrics', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: { type: 'integer', format: 'int32' }, + }, + }, + }, + }, + }, + }, + }, + }, + } + const out = generateClient(spec as OpenAPIV3_1.Document).content + expect(out).toContain('Promise>') + expect(out).not.toContain('Record') + }) + + it('operation response with additionalProperties: true -> Record', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/data': { + get: { + operationId: 'getData', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: true, + }, + }, + }, + }, + }, + }, + }, + }, + } + const out = generateClient(spec as OpenAPIV3_1.Document).content + expect(out).toContain('Promise>') + }) + + it('operation response with object and no additionalProperties -> Record', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/plain': { + get: { + operationId: 'getPlain', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + }, + }, + }, + }, + }, + } + const out = generateClient(spec as OpenAPIV3_1.Document).content + expect(out).toContain('Promise>') + }) + + it('operation response with additionalProperties string schema -> Record', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/labels': { + get: { + operationId: 'getLabels', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + } + const out = generateClient(spec as OpenAPIV3_1.Document).content + expect(out).toContain('Promise>') + }) +}) + describe('coverage: inlineSchemaToTs edge cases (lines 28, 31-33, 37)', () => { it('response with nullable array type → string | null (line 28: Array.isArray(s.type) branch)', () => { // inlineSchemaToTs handles type: ['string', 'null'] → 'string | null' diff --git a/packages/openapi-zod-ts/src/__tests__/config.test.ts b/packages/openapi-zod-ts/src/__tests__/config.test.ts index bbde62d..fe015ab 100644 --- a/packages/openapi-zod-ts/src/__tests__/config.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/config.test.ts @@ -91,6 +91,48 @@ describe('loadConfig', () => { await expect(loadConfig(tmpDir)).rejects.toThrow('"server_client" must be a boolean') }) + it('loads error_body_type field', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/api', error_body_type: 'laravel' }) + const config = await loadConfig(tmpDir) + expect(config.error_body_type).toBe('laravel') + }) + + it('loads error_body_type with error_body_type_import', async () => { + writeConfig({ + input_openapi: 'openapi.json', + output: 'src/api', + error_body_type: 'ApiErrorBody', + error_body_type_import: './types/errors', + }) + const config = await loadConfig(tmpDir) + expect(config.error_body_type).toBe('ApiErrorBody') + expect(config.error_body_type_import).toBe('./types/errors') + }) + + it('error_body_type and error_body_type_import are undefined when absent', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/api' }) + const config = await loadConfig(tmpDir) + expect(config.error_body_type).toBeUndefined() + expect(config.error_body_type_import).toBeUndefined() + }) + + it('throws when error_body_type is an empty string', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/api', error_body_type: '' }) + await expect(loadConfig(tmpDir)).rejects.toThrow('"error_body_type" must be a non-empty string') + }) + + it('throws when error_body_type_import is an empty string', async () => { + writeConfig({ + input_openapi: 'openapi.json', + output: 'src/api', + error_body_type: 'MyError', + error_body_type_import: '', + }) + await expect(loadConfig(tmpDir)).rejects.toThrow( + '"error_body_type_import" must be a non-empty string' + ) + }) + it('ignores unknown config fields', async () => { writeConfig({ input_openapi: 'openapi.json', output: 'src/api', unknown_field: 'ignored' }) const config = await loadConfig(tmpDir) diff --git a/packages/openapi-zod-ts/src/__tests__/types-unit.test.ts b/packages/openapi-zod-ts/src/__tests__/types-unit.test.ts index e7c13cb..44cf810 100644 --- a/packages/openapi-zod-ts/src/__tests__/types-unit.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/types-unit.test.ts @@ -522,11 +522,11 @@ describe('coverage: generateTypes schema-enhanced mode with no schemas (types.ts }) }) -describe('int64 format -> bigint in types', () => { - it('integer with format int64 -> bigint TS type', () => { +describe('int64 format -> number in types', () => { + it('integer with format int64 -> number TS type with precision comment', () => { const out = genSingle('Id', { type: 'integer', format: 'int64' }) - expect(out).toContain('bigint') - expect(out).not.toContain('number') + expect(out).toContain('number /* int64, precision limited to 2^53-1 */') + expect(out).not.toContain('bigint') }) it('integer with no format -> number', () => { @@ -535,19 +535,21 @@ describe('int64 format -> bigint in types', () => { expect(out).not.toContain('bigint') }) - it('int64 as object property', () => { + it('int64 as object property -> number with precision comment', () => { const out = genSingle('Obj', { type: 'object', required: ['id'], properties: { id: { type: 'integer', format: 'int64' } }, }) - expect(out).toContain('id: bigint') + expect(out).toContain('id: number /* int64, precision limited to 2^53-1 */') + expect(out).not.toContain('bigint') }) - it('nullable int64 array type: [integer, null] with int64 format', () => { + it('nullable int64 array type: [integer, null] with int64 format -> number with precision comment | null', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out = genSingle('Id', { type: ['integer', 'null'], format: 'int64' } as any) - expect(out).toContain('bigint | null') + expect(out).toContain('number /* int64, precision limited to 2^53-1 */ | null') + expect(out).not.toContain('bigint') }) }) diff --git a/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts b/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts index 4596d04..517a226 100644 --- a/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts @@ -606,14 +606,15 @@ describe('coverage: multi-type array with null member (zod.ts line 118 TRUE bran }) }) -describe('int64 format -> bigint', () => { - it('integer with format int64 -> z.bigint()', () => { +describe('int64 format -> z.number()', () => { + it('integer with format int64 -> z.number()', () => { const out = genSingle('Id', { type: 'integer', format: 'int64' }) // Check the schema declaration line specifically to avoid matching header comments - expect(out).toContain('IdSchema = z.bigint()') + expect(out).toContain('IdSchema = z.number()') + expect(out).not.toContain('z.bigint()') }) - it('integer with format int32 -> z.number() (only int64 gets bigint)', () => { + it('integer with format int32 -> z.number()', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out = genSingle('Id', { type: 'integer', format: 'int32' } as any) expect(out).toContain('IdSchema = z.number()') @@ -626,19 +627,21 @@ describe('int64 format -> bigint', () => { expect(out).not.toContain('z.bigint()') }) - it('int64 as object property', () => { + it('int64 as object property -> z.number()', () => { const out = genSingle('Obj', { type: 'object', required: ['id'], properties: { id: { type: 'integer', format: 'int64' } }, }) - expect(out).toContain('id: z.bigint()') + expect(out).toContain('id: z.number()') + expect(out).not.toContain('z.bigint()') }) - it('nullable int64 array type: [integer, null] with int64 format', () => { + it('nullable int64 array type: [integer, null] with int64 format -> z.number().nullable()', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out = genSingle('Id', { type: ['integer', 'null'], format: 'int64' } as any) - expect(out).toContain('z.bigint().nullable()') + expect(out).toContain('z.number().nullable()') + expect(out).not.toContain('z.bigint()') }) }) diff --git a/packages/openapi-zod-ts/src/config.ts b/packages/openapi-zod-ts/src/config.ts index d76bed5..24e7d03 100644 --- a/packages/openapi-zod-ts/src/config.ts +++ b/packages/openapi-zod-ts/src/config.ts @@ -19,6 +19,20 @@ export interface Config { baseUrl?: string /** When true, generates server.ts with a createServerClient() factory for Next.js RSC (default: false) */ server_client?: boolean + /** + * When set, schema-less error bodies in the generated client are cast to this type. + * Use 'laravel' to emit the built-in LaravelValidationError type alongside ApiError. + * Any other non-empty string is used as a type name; pair with error_body_type_import + * to emit an import, or omit it to treat the type as ambient/global. + */ + error_body_type?: string + /** + * Module path for the custom error body type. Only used when error_body_type is set to a + * value other than 'laravel'. When provided, the generator emits + * `import type { TypeName } from 'importPath'` at the top of the generated client. + * Ignored when error_body_type is absent or 'laravel'. + */ + error_body_type_import?: string } /** @@ -47,6 +61,22 @@ export function defineProjects(configs: Config[]): { projects: Config[] } { return { projects: configs } } +/** Validate the error_body_type / error_body_type_import optional fields. */ +function validateErrorBodyConfig(raw: Record): void { + if ( + raw['error_body_type'] !== undefined && + (typeof raw['error_body_type'] !== 'string' || !raw['error_body_type']) + ) { + throw new Error('"error_body_type" must be a non-empty string when present') + } + if ( + raw['error_body_type_import'] !== undefined && + (typeof raw['error_body_type_import'] !== 'string' || !raw['error_body_type_import']) + ) { + throw new Error('"error_body_type_import" must be a non-empty string when present') + } +} + function parseConfig(raw: Record, base: import('./config-core.js').BaseConfig): Config { if ( raw['input_schema'] !== undefined && @@ -57,11 +87,14 @@ function parseConfig(raw: Record, base: import('./config-core.j if (raw['server_client'] !== undefined && typeof raw['server_client'] !== 'boolean') { throw new Error('"server_client" must be a boolean') } + validateErrorBodyConfig(raw) return { ...base, input_schema: raw['input_schema'] as string | undefined, baseUrl: typeof raw['baseUrl'] === 'string' ? raw['baseUrl'] : undefined, server_client: raw['server_client'] as boolean | undefined, + error_body_type: raw['error_body_type'] as string | undefined, + error_body_type_import: raw['error_body_type_import'] as string | undefined, } } diff --git a/packages/openapi-zod-ts/src/generator.ts b/packages/openapi-zod-ts/src/generator.ts index ecda10b..f104b66 100644 --- a/packages/openapi-zod-ts/src/generator.ts +++ b/packages/openapi-zod-ts/src/generator.ts @@ -80,7 +80,16 @@ async function generateOne( cookieAuth ? { defaultCredentials: 'include', authSchemes } : { authSchemes } ) ) - generatedFiles.push(generateClient(spec, undefined, writableVariantMap)) + generatedFiles.push( + generateClient( + spec, + { + errorBodyType: config.error_body_type, + errorBodyTypeImport: config.error_body_type_import, + }, + writableVariantMap + ) + ) generatedFiles.push(generateIndexBarrel()) console.log(`${prefix}Writing output to: ${outputDir}`) @@ -162,7 +171,12 @@ async function generateZodIntegration( ) const enhancedClient = generateClient( spec, - { schemaNames: exportedSchemas, schemaImportPath }, + { + schemaNames: exportedSchemas, + schemaImportPath, + errorBodyType: config.error_body_type, + errorBodyTypeImport: config.error_body_type_import, + }, writableVariantMap ) const enhancedTypesPath = join(outputDir, enhancedTypes.filename) diff --git a/packages/openapi-zod-ts/src/plugins/client.ts b/packages/openapi-zod-ts/src/plugins/client.ts index 3c2ac79..ae676d7 100644 --- a/packages/openapi-zod-ts/src/plugins/client.ts +++ b/packages/openapi-zod-ts/src/plugins/client.ts @@ -51,6 +51,31 @@ function resolveDeepRefToTs( return result } +/** + * Resolve the TypeScript Record type for an inline object schema. + * When additionalProperties is a schema object, recurse to produce Record. + * Otherwise fall back to Record. + */ +function inlineObjectSchemaToTs( + s: SchemaObject, + spec?: OpenAPIV3_1.Document, + visited?: Set +): string { + if ( + s.additionalProperties !== undefined && + s.additionalProperties !== true && + s.additionalProperties !== false + ) { + const valueType = inlineSchemaToTs( + s.additionalProperties as SchemaObject | ReferenceObject, + spec, + visited + ) + return `Record` + } + return 'Record' +} + function inlineSchemaToTs( schema: SchemaObject | ReferenceObject, spec?: OpenAPIV3_1.Document, @@ -73,7 +98,7 @@ function inlineSchemaToTs( if (items !== undefined) return `${inlineSchemaToTs(items, spec, visited)}[]` return 'unknown[]' } - if (s.type === 'object') return 'Record' + if (s.type === 'object') return inlineObjectSchemaToTs(s, spec, visited) if (s.type !== undefined) return primitiveToTs(s.type as string) return 'unknown' } @@ -823,6 +848,12 @@ interface HelperFeatures { hasMultipart: boolean /** Any endpoint uses application/x-www-form-urlencoded. Emits bodyEncoding branch in `_request`. */ hasFormUrlencoded: boolean + /** + * When set, schema-less error bodies are cast to this type name in the generated error handler. + * Use 'laravel' for the built-in LaravelValidationError type or any other string for an + * ambient/imported type (see ClientOptions.errorBodyTypeImport for the import path). + */ + errorBodyType?: string } /** @@ -990,10 +1021,22 @@ function emitAuthHeaderSpreads( } } -/** Emits the shared error-check + return block at the end of a helper function. */ -function emitErrorCheckAndReturn(lines: string[]): void { +/** + * Emits the shared error-check + return block at the end of a helper function. + * When errorBodyType is provided, the error body is cast to that type so callers + * get a typed body instead of unknown. The special value 'laravel' uses the built-in + * LaravelValidationError type emitted alongside ApiError; any other value is used as-is + * (caller is responsible for ensuring the type is in scope). + */ +function emitErrorCheckAndReturn(lines: string[], errorBodyType?: string): void { lines.push(` if (!res.ok) {`) - lines.push(` const err = new ApiError(res.status, await res.json().catch(() => null))`) + if (errorBodyType !== undefined) { + lines.push( + ` const err = new ApiError(res.status, await res.json().catch(() => null) as ${errorBodyType})` + ) + } else { + lines.push(` const err = new ApiError(res.status, await res.json().catch(() => null))`) + } lines.push(` onError?.(err)`) lines.push(` throw err`) lines.push(` }`) @@ -1116,7 +1159,7 @@ function emitRequestFunction( lines.push(` }`) emitOnRequestBlock(lines) emitSignalAndFetch(lines) - emitErrorCheckAndReturn(lines) + emitErrorCheckAndReturn(lines, features.errorBodyType) } /** Emits the _requestForm function into lines (up to and including the closing brace). */ @@ -1157,7 +1200,7 @@ function emitRequestFormFunction( lines.push(` }`) emitOnRequestBlock(lines) emitSignalAndFetch(lines) - emitErrorCheckAndReturn(lines) + emitErrorCheckAndReturn(lines, features.errorBodyType) } /** @@ -1437,6 +1480,20 @@ function generateFunctionCode( export interface ClientOptions { schemaNames?: Set schemaImportPath?: string + /** + * When set, schema-less error bodies are cast to this type in the generated client. + * Use 'laravel' to emit the built-in LaravelValidationError type + * ({ message: string; errors: Record }) alongside ApiError. + * Any other string is used as-is; pair it with errorBodyTypeImport to emit an import, + * or leave errorBodyTypeImport unset to treat it as an ambient/global type. + */ + errorBodyType?: string + /** + * When errorBodyType is set to a custom type name (not 'laravel'), provide the module + * path here and the generator will emit `import type { TypeName } from 'importPath'` + * at the top of the generated client. Ignored when errorBodyType is 'laravel' or absent. + */ + errorBodyTypeImport?: string } /** Built-in TypeScript types that must NOT be imported from ./models */ @@ -1637,6 +1694,18 @@ export function generateClient( lines.push(`import { ${sortedSchemas.join(', ')} } from '${options.schemaImportPath}'`) } + // When a custom error body type with an import path is configured, emit the import. + // The 'laravel' value uses a built-in type emitted inline and needs no import. + if ( + options?.errorBodyType !== undefined && + options.errorBodyType !== 'laravel' && + options.errorBodyTypeImport !== undefined + ) { + lines.push( + `import type { ${options.errorBodyType} } from '${options.errorBodyTypeImport}'` + ) + } + lines.push('') // ApiError class — generic so callers can type-narrow caught errors. @@ -1653,16 +1722,31 @@ export function generateClient( lines.push(` }`) lines.push(`}`) + // When error_body_type is 'laravel', emit the built-in LaravelValidationError type + // immediately after ApiError so it is available for the typed error body cast. + if (options?.errorBodyType === 'laravel') { + lines.push(``) + lines.push(`export type LaravelValidationError = {`) + lines.push(` message: string`) + lines.push(` errors: Record`) + lines.push(`}`) + } + // Shared private request helpers — emitted once, called by every endpoint function. // Code inside the helpers is feature-conditional: only emit auth/credentials/extraHeaders // when the spec actually declares those features. if (hasAnyEndpoints) { + // Resolve the actual TS type name for the error body cast. + // 'laravel' is a config shorthand for the built-in LaravelValidationError type. + const resolvedErrorBodyType = + options?.errorBodyType === 'laravel' ? 'LaravelValidationError' : options?.errorBodyType const helperFeatures: HelperFeatures = { authSchemes: detectAuthSchemes(spec), hasCookieAuth: hasCookieAuth(spec), hasHeaderParams: hasHeaderParamEndpoints, hasMultipart: hasMultipartEndpoints, hasFormUrlencoded: hasFormUrlencodedEndpoints, + errorBodyType: resolvedErrorBodyType, } lines.push('') lines.push(generateRequestHelpers(helperFeatures)) diff --git a/packages/openapi-zod-ts/src/plugins/types.ts b/packages/openapi-zod-ts/src/plugins/types.ts index 99e7ce8..a4fdbdf 100644 --- a/packages/openapi-zod-ts/src/plugins/types.ts +++ b/packages/openapi-zod-ts/src/plugins/types.ts @@ -201,8 +201,10 @@ function schemaToTypeString( /** * Map an OpenAPI primitive type to a TypeScript type. - * For integer with format int64, returns bigint instead of number - * to preserve precision for 64-bit IDs. + * For integer with format int64, returns number with an inline comment noting precision + * is limited to 2^53-1 (JS Number.MAX_SAFE_INTEGER). BigInt is avoided because + * JSON.stringify throws on bigint values and JSON.parse never produces bigint, making + * bigint unworkable for standard API client serialization. */ function primitiveToTs(type: string, format?: string): string { switch (type) { @@ -211,8 +213,7 @@ function primitiveToTs(type: string, format?: string): string { case 'number': return 'number' case 'integer': - // int64 requires bigint for precision-safe 64-bit IDs (JS number cannot represent >2^53) - return format === 'int64' ? 'bigint' : 'number' + return format === 'int64' ? 'number /* int64, precision limited to 2^53-1 */' : 'number' case 'boolean': return 'boolean' case 'null': diff --git a/packages/openapi-zod-ts/src/plugins/zod.ts b/packages/openapi-zod-ts/src/plugins/zod.ts index a454969..2fcc13f 100644 --- a/packages/openapi-zod-ts/src/plugins/zod.ts +++ b/packages/openapi-zod-ts/src/plugins/zod.ts @@ -81,8 +81,9 @@ function serializeLiteral(value: unknown): string { /** * Return the Zod v4 base expression for a primitive type. - * For integer with format int64, returns z.bigint() instead of z.number() - * to preserve precision for 64-bit IDs. + * For integer with format int64, returns z.number() instead of z.bigint() because + * JSON.stringify throws on bigint values and JSON.parse never produces bigint. Precision + * is limited to 2^53-1 (JS Number.MAX_SAFE_INTEGER) for int64 fields. */ function primitiveToZod(type: string, format?: string): string { switch (type) { @@ -91,8 +92,7 @@ function primitiveToZod(type: string, format?: string): string { case 'number': return 'z.number()' case 'integer': - // int64 requires bigint for precision-safe 64-bit IDs (JS number cannot represent >2^53) - return format === 'int64' ? 'z.bigint()' : 'z.number()' + return format === 'int64' ? 'z.number()' : 'z.number()' case 'boolean': return 'z.boolean()' case 'null': @@ -325,8 +325,8 @@ function objectSchemaToZod(schema: SchemaObject): string { /** * Handle scalar primitive types: string, integer, number, boolean, null. - * Applies format-based specialisation (stringSchemaExpr, int64 bigint) and - * numeric/string constraints before returning the Zod expression. + * Applies format-based specialisation (stringSchemaExpr, numeric constraints) and + * string/numeric constraints before returning the Zod expression. */ function primitiveTypeToZod(schema: SchemaObject): string { const type = schema.type as string @@ -335,9 +335,7 @@ function primitiveTypeToZod(schema: SchemaObject): string { base = stringSchemaExpr(schema) } else if (type === 'integer') { base = primitiveToZod('integer', schema.format as string | undefined) - if (base !== 'z.bigint()') { - base = applyNumberConstraints(base, schema) - } + base = applyNumberConstraints(base, schema) } else if (type === 'number') { base = primitiveToZod('number') base = applyNumberConstraints(base, schema)