diff --git a/src/Types.ts b/src/Types.ts index 3dfa113c4c..82d78d8f95 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -35,7 +35,12 @@ export type V2RuntimeSchema = | {kind: 'decimal_string'} | {kind: 'object'; fields: Record} | {kind: 'array'; element: V2RuntimeSchema} - | {kind: 'nullable'; inner: V2RuntimeSchema}; + | {kind: 'nullable'; inner: V2RuntimeSchema} + | { + kind: 'discriminatedUnion'; + discriminator: string; + variants: Record; + }; export type MethodSpec = { method: string; methodType?: string; diff --git a/src/V2Coercion.ts b/src/V2Coercion.ts index e6ab43a122..65ce5c4311 100644 --- a/src/V2Coercion.ts +++ b/src/V2Coercion.ts @@ -1,6 +1,42 @@ import {Decimal} from './Decimal.js'; import {V2RuntimeSchema} from './Types.js'; +const coerceV2RequestDiscriminatedUnion = ( + data: unknown, + schema: V2RuntimeSchema & {kind: 'discriminatedUnion'} +): unknown => { + if (typeof data !== 'object' || Array.isArray(data)) { + return data; + } + const obj = data as Record; + const discriminatorValue = obj[schema.discriminator]; + if ( + typeof discriminatorValue === 'string' && + discriminatorValue in schema.variants + ) { + return coerceV2RequestData(data, schema.variants[discriminatorValue]); + } + return data; +}; + +const coerceV2RequestObject = ( + data: unknown, + schema: V2RuntimeSchema & {kind: 'object'} +): unknown => { + if (typeof data !== 'object' || Array.isArray(data)) { + return data; + } + const obj = data as Record; + const result: Record = {}; + for (const key of Object.keys(obj)) { + const fieldSchema = schema.fields[key]; + result[key] = fieldSchema + ? coerceV2RequestData(obj[key], fieldSchema) + : obj[key]; + } + return result; +}; + /** * Coerces outbound V2 request data by converting bigint (or number) * int64_string fields to strings, matching the wire format expected by the API. @@ -30,18 +66,7 @@ export const coerceV2RequestData = ( : data; case 'object': { - if (typeof data !== 'object' || Array.isArray(data)) { - return data; - } - const obj = data as Record; - const result: Record = {}; - for (const key of Object.keys(obj)) { - const fieldSchema = schema.fields[key]; - result[key] = fieldSchema - ? coerceV2RequestData(obj[key], fieldSchema) - : obj[key]; - } - return result; + return coerceV2RequestObject(data, schema); } case 'array': { @@ -55,9 +80,51 @@ export const coerceV2RequestData = ( case 'nullable': return coerceV2RequestData(data, schema.inner); + + case 'discriminatedUnion': { + return coerceV2RequestDiscriminatedUnion(data, schema); + } } }; +// NOTE: these are separate from the request flavors above +// because the caller to coerceV2ResponseData expects data +// to be modified in place + +const coerceV2ResponseDiscriminatedUnion = ( + data: unknown, + schema: V2RuntimeSchema & {kind: 'discriminatedUnion'} +): unknown => { + if (typeof data !== 'object' || Array.isArray(data)) { + return data; + } + const obj = data as Record; + const discriminatorValue = obj[schema.discriminator]; + if ( + typeof discriminatorValue === 'string' && + discriminatorValue in schema.variants + ) { + return coerceV2ResponseData(data, schema.variants[discriminatorValue]); + } + return data; +}; + +const coerceV2ResponseObject = ( + data: unknown, + schema: V2RuntimeSchema & {kind: 'object'} +): unknown => { + if (typeof data !== 'object' || Array.isArray(data)) { + return data; + } + const obj = data as Record; + for (const key of Object.keys(schema.fields)) { + if (key in obj) { + obj[key] = coerceV2ResponseData(obj[key], schema.fields[key]); + } + } + return obj; +}; + /** * Coerces inbound V2 response data by converting string int64_string fields * to bigints, matching the SDK's public type contract. @@ -99,16 +166,7 @@ export const coerceV2ResponseData = ( return data; case 'object': { - if (typeof data !== 'object' || Array.isArray(data)) { - return data; - } - const obj = data as Record; - for (const key of Object.keys(schema.fields)) { - if (key in obj) { - obj[key] = coerceV2ResponseData(obj[key], schema.fields[key]); - } - } - return obj; + return coerceV2ResponseObject(data, schema); } case 'array': { @@ -123,5 +181,9 @@ export const coerceV2ResponseData = ( case 'nullable': return coerceV2ResponseData(data, schema.inner); + + case 'discriminatedUnion': { + return coerceV2ResponseDiscriminatedUnion(data, schema); + } } }; diff --git a/test/DiscriminatedUnion.spec.ts b/test/DiscriminatedUnion.spec.ts new file mode 100644 index 0000000000..eec3185563 --- /dev/null +++ b/test/DiscriminatedUnion.spec.ts @@ -0,0 +1,257 @@ +// @ts-nocheck +import {expect} from 'chai'; + +// --------------------------------------------------------------------------- +// Type definitions mirroring the shapes the codegen produces for discriminated +// unions. These are hand-written here so the test file is self-contained, but +// they exactly match the object-literal typedef shape the Node generator emits +// for each variant. +// --------------------------------------------------------------------------- + +// --- standalone union (color model) ---------------------------------------- + +type RgbColorParams = { + model: 'rgb'; + rgb: string; +}; + +type HsvColorParams = { + model: 'hsv'; + hsv: string; +}; + +type HslColorParams = { + model: 'hsl'; + hsl: string; +}; + +type ColorParams = RgbColorParams | HsvColorParams | HslColorParams; + +// --- inline union (llama type) ---------------------------------------------- +// Inline unions appear at the parent object level: the discriminator field and +// variant-specific payload field are siblings, not nested inside a wrapper. + +type AlienLlamaVariant = { + type: 'alien_llama'; + alien_llama: { + planet: string; + }; +}; + +type EarthLlamaVariant = { + type: 'earth_llama'; + earth_llama: { + breed: string; + }; +}; + +type LlamaBaseParams = { + name: string; + color: ColorParams; +}; + +type CreateLlamaParams = LlamaBaseParams & + (AlienLlamaVariant | EarthLlamaVariant); + +// --- response object -------------------------------------------------------- +// The Node SDK returns plain objects from the API. A discriminated union +// response simply has the discriminator as a plain field. + +type RgbColorResponse = { + object: 'color'; + model: 'rgb'; + rgb: string; + id: string; +}; + +type HsvColorResponse = { + object: 'color'; + model: 'hsv'; + hsv: string; + id: string; +}; + +type ColorResponse = RgbColorResponse | HsvColorResponse; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Discriminated union type shapes', () => { + describe('standalone union params', () => { + it('rgb variant carries the discriminator and variant-specific field', () => { + const params: RgbColorParams = {model: 'rgb', rgb: '#ff8000'}; + + expect(params.model).to.equal('rgb'); + expect(params.rgb).to.equal('#ff8000'); + }); + + it('hsv variant carries the discriminator and variant-specific field', () => { + const params: HsvColorParams = {model: 'hsv', hsv: '30,100,100'}; + + expect(params.model).to.equal('hsv'); + expect(params.hsv).to.equal('30,100,100'); + }); + + it('hsl variant carries the discriminator and variant-specific field', () => { + const params: HslColorParams = {model: 'hsl', hsl: '30,100%,50%'}; + + expect(params.model).to.equal('hsl'); + expect(params.hsl).to.equal('30,100%,50%'); + }); + + it('ColorParams union accepts any variant', () => { + const variants: ColorParams[] = [ + {model: 'rgb', rgb: '#ff0000'}, + {model: 'hsv', hsv: '0,100,100'}, + {model: 'hsl', hsl: '0,100%,50%'}, + ]; + + expect(variants).to.have.length(3); + expect(variants.map((v) => v.model)).to.deep.equal(['rgb', 'hsv', 'hsl']); + }); + + it('discriminator can be used to narrow to the correct variant', () => { + const params: ColorParams = {model: 'rgb', rgb: '#00ff00'}; + + if (params.model === 'rgb') { + expect(params.rgb).to.equal('#00ff00'); + } else { + throw new Error('expected rgb branch'); + } + }); + + it('plain object matching the union shape passes through JSON serialization unchanged', () => { + const params: RgbColorParams = {model: 'rgb', rgb: '#0000ff'}; + const serialized = JSON.stringify(params); + const parsed = JSON.parse(serialized); + + expect(parsed.model).to.equal('rgb'); + expect(parsed.rgb).to.equal('#0000ff'); + }); + }); + + describe('inline union params', () => { + it('alien_llama variant has discriminator and nested payload', () => { + const params: CreateLlamaParams = { + name: 'Zyx', + color: {model: 'rgb', rgb: '#ffffff'}, + type: 'alien_llama', + alien_llama: {planet: 'Zorg'}, + }; + + expect(params.type).to.equal('alien_llama'); + expect((params as AlienLlamaVariant).alien_llama.planet).to.equal('Zorg'); + }); + + it('earth_llama variant has discriminator and nested payload', () => { + const params: CreateLlamaParams = { + name: 'Spot', + color: {model: 'hsv', hsv: '120,80,60'}, + type: 'earth_llama', + earth_llama: {breed: 'Huacaya'}, + }; + + expect(params.type).to.equal('earth_llama'); + expect((params as EarthLlamaVariant).earth_llama.breed).to.equal( + 'Huacaya' + ); + }); + + it('base fields are present alongside the variant-specific fields', () => { + const params: CreateLlamaParams = { + name: 'Bob', + color: {model: 'hsl', hsl: '200,50%,50%'}, + type: 'earth_llama', + earth_llama: {breed: 'Suri'}, + }; + + expect(params.name).to.equal('Bob'); + expect(params.color.model).to.equal('hsl'); + expect(params.type).to.equal('earth_llama'); + }); + + it('discriminator narrows the inline variant correctly', () => { + const params: CreateLlamaParams = { + name: 'Luna', + color: {model: 'rgb', rgb: '#aabbcc'}, + type: 'alien_llama', + alien_llama: {planet: 'Kepler-22b'}, + }; + + if (params.type === 'alien_llama') { + expect(params.alien_llama.planet).to.equal('Kepler-22b'); + } else { + throw new Error('expected alien_llama branch'); + } + }); + }); + + describe('response-side object', () => { + it('rgb color response exposes the discriminator and variant field', () => { + // Simulates a plain object returned from the Stripe API after + // JSON.parse() — no coercion, just field access. + const response: RgbColorResponse = { + object: 'color', + model: 'rgb', + rgb: '#123456', + id: 'col_abc', + }; + + expect(response.model).to.equal('rgb'); + expect(response.rgb).to.equal('#123456'); + }); + + it('hsv color response exposes the discriminator and variant field', () => { + const response: HsvColorResponse = { + object: 'color', + model: 'hsv', + hsv: '210,50,80', + id: 'col_def', + }; + + expect(response.model).to.equal('hsv'); + expect(response.hsv).to.equal('210,50,80'); + }); + + it('ColorResponse union allows narrowing via discriminator', () => { + const response: ColorResponse = { + object: 'color', + model: 'rgb', + rgb: '#abcdef', + id: 'col_ghi', + }; + + if (response.model === 'rgb') { + expect(response.rgb).to.equal('#abcdef'); + } else { + throw new Error('expected rgb branch'); + } + }); + + it('response object survives JSON round-trip with discriminator intact', () => { + const response: RgbColorResponse = { + object: 'color', + model: 'rgb', + rgb: '#ffffff', + id: 'col_jkl', + }; + + const roundTripped = JSON.parse(JSON.stringify(response)); + + expect(roundTripped.model).to.equal('rgb'); + expect(roundTripped.rgb).to.equal('#ffffff'); + expect(roundTripped.id).to.equal('col_jkl'); + }); + + it('can process multiple response objects with different variants', () => { + const responses: ColorResponse[] = [ + {object: 'color', model: 'rgb', rgb: '#ff0000', id: 'col_1'}, + {object: 'color', model: 'hsv', hsv: '0,100,100', id: 'col_2'}, + ]; + + const models = responses.map((r) => r.model); + expect(models).to.deep.equal(['rgb', 'hsv']); + }); + }); +}); diff --git a/test/V2Coercion.spec.ts b/test/V2Coercion.spec.ts index 2cb2217228..81066d5d2e 100644 --- a/test/V2Coercion.spec.ts +++ b/test/V2Coercion.spec.ts @@ -182,6 +182,73 @@ describe('V2Int64', () => { }); }); + describe('discriminatedUnion kind', () => { + const schema: V2RuntimeSchema = { + kind: 'discriminatedUnion', + discriminator: 'type', + variants: { + bank_transfer: { + kind: 'object', + fields: { + amount: {kind: 'int64_string'}, + }, + }, + card: { + kind: 'object', + fields: { + fee: {kind: 'int64_string'}, + }, + }, + }, + }; + + it('coerces fields in the matched variant', () => { + const result = coerceV2RequestData( + {type: 'bank_transfer', amount: 100n}, + schema + ); + expect(result).to.deep.equal({type: 'bank_transfer', amount: '100'}); + }); + + it('coerces a different variant', () => { + const result = coerceV2RequestData({type: 'card', fee: 250n}, schema); + expect(result).to.deep.equal({type: 'card', fee: '250'}); + }); + + it('returns data unchanged when discriminator value is not in variants', () => { + const input = {type: 'unknown_type', amount: 100n}; + const result = coerceV2RequestData(input, schema); + expect(result).to.deep.equal({type: 'unknown_type', amount: 100n}); + }); + + it('returns data unchanged when discriminator field is missing', () => { + const input = {amount: 100n}; + const result = coerceV2RequestData(input, schema); + expect(result).to.deep.equal({amount: 100n}); + }); + + it('returns data unchanged when discriminator value is not a string', () => { + const input = {type: 123, amount: 100n}; + const result = coerceV2RequestData(input, schema); + expect(result).to.deep.equal({type: 123, amount: 100n}); + }); + + it('passes null through', () => { + expect(coerceV2RequestData(null, schema)).to.equal(null); + }); + + it('handles non-object gracefully', () => { + expect(coerceV2RequestData('not an object', schema)).to.equal( + 'not an object' + ); + }); + + it('handles array gracefully', () => { + const input = [1, 2, 3]; + expect(coerceV2RequestData(input, schema)).to.deep.equal([1, 2, 3]); + }); + }); + describe('complex schema', () => { const schema: V2RuntimeSchema = { kind: 'object', @@ -354,6 +421,74 @@ describe('V2Int64', () => { }); }); + describe('discriminatedUnion kind', () => { + const schema: V2RuntimeSchema = { + kind: 'discriminatedUnion', + discriminator: 'type', + variants: { + bank_transfer: { + kind: 'object', + fields: { + amount: {kind: 'int64_string'}, + }, + }, + card: { + kind: 'object', + fields: { + fee: {kind: 'int64_string'}, + }, + }, + }, + }; + + it('coerces fields in the matched variant', () => { + const data = {type: 'bank_transfer', amount: '100'}; + coerceV2ResponseData(data, schema); + expect(data).to.deep.equal({type: 'bank_transfer', amount: 100n}); + }); + + it('coerces a different variant', () => { + const data = {type: 'card', fee: '250'}; + coerceV2ResponseData(data, schema); + expect(data).to.deep.equal({type: 'card', fee: 250n}); + }); + + it('returns data unchanged when discriminator value is not in variants', () => { + const data = {type: 'unknown_type', amount: '100'}; + const result = coerceV2ResponseData(data, schema); + expect(result).to.deep.equal({type: 'unknown_type', amount: '100'}); + }); + + it('returns data unchanged when discriminator field is missing', () => { + const data = {amount: '100'}; + const result = coerceV2ResponseData(data, schema); + expect(result).to.deep.equal({amount: '100'}); + }); + + it('returns data unchanged when discriminator value is not a string', () => { + const data = {type: 123, amount: '100'}; + const result = coerceV2ResponseData(data, schema); + expect(result).to.deep.equal({type: 123, amount: '100'}); + }); + + it('passes null through', () => { + expect(coerceV2ResponseData(null, schema)).to.equal(null); + }); + + it('handles non-object gracefully', () => { + expect(coerceV2ResponseData('not an object', schema)).to.equal( + 'not an object' + ); + }); + + it('mutates in-place like other response coercion', () => { + const data = {type: 'bank_transfer', amount: '100'}; + const result = coerceV2ResponseData(data, schema); + expect(result).to.equal(data); + expect(data.amount).to.equal(100n); + }); + }); + describe('complex schema', () => { const schema: V2RuntimeSchema = { kind: 'object',