Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,7 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole
{{/discriminator}}
{{^discriminator}}
{{#oneOfModels}}
{{#-first}}
if (typeof json !== 'object') {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not return early if json is not an object because there might be more conditions down below that operate on values that are not an object

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a good change to make sure that the verification doesn't exit prematurely if the schema consists of a model and a primitive.

return json;
}
{{/-first}}
if (instanceOf{{{.}}}(json)) {
if (typeof json === 'object' && instanceOf{{{.}}}(json)) {
return {{{.}}}FromJSONTyped(json, true);
}
{{/oneOfModels}}
Expand All @@ -61,7 +56,6 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole
}
{{#-last}}
}
return json;
}
{{/-last}}
{{/oneOfArrays}}
Expand All @@ -70,60 +64,36 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole
{{#items}}
{{#isDateType}}
if (Array.isArray(json)) {
if (json.every(item => !(isNaN(new Date(json).getTime()))) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it fixes a bug in how arrays of dates or date-times

return json.map(value => new Date(json);
if (json.every(item => !(isNaN(new Date(item).getTime())))) {
return json.map(item => new Date(item));
}
}
{{/isDateType}}
{{#isDateTimeType}}
if (Array.isArray(json)) {
if (json.every(item => !(isNaN(new Date(json).getTime()))) {
return json.map(value => new Date(json);
if (json.every(item => !(isNaN(new Date(item).getTime())))) {
return json.map(item => new Date(item));
}
}
{{/isDateTimeType}}
{{#isNumeric}}
if (Array.isArray(json)) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These conditions didn't transform the json value and are no longer needed, because if no condition is met, json will be returned at the end.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I follow why we wouldn't want to verify if the schema has a consistent array of primitive values (either numbers or enums represented as numbers). This code is doing validation in the case where the oneOf schema does not have a discriminator.

The Swagger OpenAPI documentation provides the following guidance:

oneOf – validates the value against exactly one of the subschemas

It also provides an example where the JSON is invalid for the oneOf schema.
This validation is echoed in the IETF JSON Schema Validation spec

I think there is some ambiguity in these specs about what should be done if a user defines a oneOf schema that can't be properly validated in the target language or is ambiguous.

But I don't think it's a good idea to get rid of the validation logic here. I think its arguable about what should be done if the JSON fails validation though. Throwing an Error is consistent with the client generators for other languages.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation is nice to have, but the old implementation returns {} for a lot of valid values, and does more harm than good in my opinion.

I also believe that this "return value as soon as one condition is met" approach is not suitable for oneOf validation. Take a look at the following definition and the code generated by the old implementation:

    TestPayload:
      oneOf:
        - type: object
          properties:
            foo:
              type: string
          required:
            - foo
        - type: object
          properties:
            bar:
              type: number
          required:
            - bar
export function TestPayloadFromJSONTyped(json: any, ignoreDiscriminator: boolean): TestPayload {
    if (json == null) {
        return json;
    }
    if (typeof json !== 'object') {
        return json;
    }
    if (instanceOfTestPayloadOneOf(json)) {
        return TestPayloadOneOfFromJSONTyped(json, true);
    }
    if (instanceOfTestPayloadOneOf1(json)) {
        return TestPayloadOneOf1FromJSONTyped(json, true);
    }
    return {} as any;
}

export function instanceOfTestPayloadOneOf(value: object): value is TestPayloadOneOf {
    if (!('foo' in value) || value['foo'] === undefined) return false;
    return true;
}

export function TestPayloadOneOfFromJSONTyped(json: any, ignoreDiscriminator: boolean): TestPayloadOneOf {
    if (json == null) {
        return json;
    }
    return {
        
        'foo': json['foo'],
    };
}

export function instanceOfTestPayloadOneOf1(value: object): value is TestPayloadOneOf1 {
    if (!('bar' in value) || value['bar'] === undefined) return false;
    return true;
}

export function TestPayloadOneOf1FromJSONTyped(json: any, ignoreDiscriminator: boolean): TestPayloadOneOf1 {
    if (json == null) {
        return json;
    }
    return {
        
        'bar': json['bar'],
    };
}

According to the openapi spec for oneOf a value should be valid if exactly one subschema matches. But this implementation will accept {foo: "foo", bar: 42} and transform it to {foo: "foo"} despite matching both subschemas. A correct validation implementation would need to validate the value against all conditions and only proceed if exactly one matches.

The validation is also incomplete, instanceOfTestPayloadOneOf and instanceOfTestPayloadOneOf1 do not check the type of the value, so both {foo: 36} and {bar: "bar"} will also pass the validation.

I get the impression that the original intent of the instanceOf{{Model}} and {{Model}}FromJSONTyped functions was not to validate values, but to discriminate between objects and drop unknown properties.

My main concern with this PR is to keep valid values unchanged. I can work with a client that does not validate request/response payloads, but I cannot work with a client that changes valid request/response payloads. To me the current validation implementation seems flawed, it is both rejecting valid values and accepting invalid ones. A working validation implementation requires a lot more work. I took a brief look at the other typescript client generators and could not find a validation implementation there either. Until we got a validation implementation that covers all possible cases we should not change values that could be valid.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can defiantly see that the generated code is not correctly implementing oneOf validation without a discriminator, though my understanding from the generator documentation it doesn't claim to support that case.

It also makes sense to me that the generator shouldn't attempt to support the generic oneOf case without a full valid implementation. The C-Sharp generator also doesn't claim to support oneOf but has a much better implementation which first checks that there is 1, and only 1, possible interpretation when deserializing JSON.

What I am concerned about is that the existing code has been added to address issues which prior authors encountered that may (or may not) be regressed by removing the code. Sadly unit test coverage for many changes is not the best. I think it is worth while to ensure that this change will not regress any scenarios that are unrelated to oneOf validation.
In most cases the PRs consist of updates to the template without adding unit tests to confirm behavior.

The original PR which added a return {} to the ToJSON code was #12513 by @Nukesor

The PRs which added validation code to use the instanceOf methods in the FromJSONTyped methods was #18702 followed by a revision in #19219 to add a return {} as any; if the method failed to identify a valid instance. Tagging @odiak

FWIF there was a change in PR #20983 to make the ToJSONTyped and FromJSONTyped methods not throw an Error if they were unable to find a valid instance when oneOf was used with a discriminator. I'm not sure I agree with the rationale for this change as it was done to address compatibility of an older client with a service when the oneOf contract was changed. @aeneasr

The bulk of the non-model validation code for the FromJSONTyped and ToJSONTyped methods came in this PR #21057 from @GregoryMerlet
Then validation for some primitives was added by @DavidGrath in #21259

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIF there was a change in PR #20983 to make the ToJSONTyped and FromJSONTyped methods not throw an Error if they were unable to find a valid instance when oneOf was used with a discriminator. I'm not sure I agree with the rationale for this change as it was done to address compatibility of an older client with a service when the oneOf contract was changed. @aeneasr

Imagine you use an OpenAPI generated client; the API changes (eg a new oneOf discriminator is added) and suddenly all existing clients throw exceptions because they can’t handle the new addition. It essentially means you can never update the API (it‘s not even breaking just adding a new type) without first forcing everyone to update to the new client. Always side with gracefulness

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aeneasr I believe that adding a new type to a oneOf spec is a breaking change as it breaks the contract between the server and the client established by the OAS. The OAS establishes a contract between the client/server in how they will communicate and the schema they will use. Changing the OAS breaks the contract. The way to handle that is by establishing a new endpoint / model that includes the new type and migrating clients to use the new endpoint.

Copy link
Author

@WIStudent WIStudent Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@btpnlsl

I can defiantly see that the generated code is not correctly implementing oneOf validation without a discriminator, though my understanding from the generator documentation it doesn't claim to support that case.

One thing that surprised me while looking into this issue is that apparently every typescript client generator has its own logic for model generation. I was already using the typescript-axios generator with

oneOf:
  - type: object
  - type: array
    items:
      nullable: true

for a long time without issues, but wanted to switch to typescript-fetch to get rid of the axios dependency. Because they are both typescript clients and the model for the request/response body is independent from the method to send requests I wasn't expecting such a big difference in the model generation.

But I just noticed that there is a withoutRuntimeChecks config option. Maybe turning that on solves my problem until the validation covers all oneOf cases.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting withoutRuntimeChecks to true did indeed solve my specific problem. However, there is one thing I find odd: If I set withoutRuntimeChecks to true, {type: string, format: date} gets mapped to string in an interface, but if I set withoutRuntimeChecks to false, it gets mapped to Date. This behavior was added in #11481, but from the description of the withoutRuntimeChecks option I wouldn't expect a difference in the interfaces:

Setting this property to true will remove any runtime checks on the request and response payloads. Payloads will be casted to their expected types

To me, this is another sign that value mapping and value validation should be separated.

if (json.every(item => typeof item === 'number'{{#isEnum}} && ({{#allowableValues}}{{#values}}item === {{.}}{{^-last}} || {{/-last}}{{/values}}{{/allowableValues}}){{/isEnum}})) {
return json;
}
}
{{/isNumeric}}
{{#isString}}
if (Array.isArray(json)) {
if (json.every(item => typeof item === 'string'{{#isEnum}} && ({{#allowableValues}}{{#values}}item === '{{.}}'{{^-last}} || {{/-last}}{{/values}}{{/allowableValues}}){{/isEnum}})) {
return json;
}
}
{{/isString}}
{{/items}}
{{/isArray}}
{{^isArray}}
{{#isDateType}}
if (!(isNaN(new Date(json).getTime()))) {
return {{^required}}json == null ? undefined : {{/required}}({{#required}}{{#isNullable}}json == null ? null : {{/isNullable}}{{/required}}new Date(json));
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These null-checks are unnecessary because the function already returns in the beginning when json is null.

return new Date(json);
}
{{/isDateType}}
{{^isDateType}}
{{#isDateTimeType}}
if (!(isNaN(new Date(json).getTime()))) {
return {{^required}}json == null ? undefined : {{/required}}({{#required}}{{#isNullable}}json == null ? null : {{/isNullable}}{{/required}}new Date(json));
return new Date(json);
}
{{/isDateTimeType}}
{{/isDateType}}
{{#isNumeric}}
if (typeof json === 'number'{{#isEnum}} && ({{#allowableValues}}{{#values}}json === {{.}}{{^-last}} || {{/-last}}{{/values}}{{/allowableValues}}){{/isEnum}}) {
return json;
}
{{/isNumeric}}
{{#isString}}
if (typeof json === 'string'{{#isEnum}} && ({{#allowableValues}}{{#values}}json === '{{.}}'{{^-last}} || {{/-last}}{{/values}}{{/allowableValues}}){{/isEnum}}) {
return json;
}
{{/isString}}
{{/isArray}}
{{/oneOfPrimitives}}
return {} as any;
return json;
{{/discriminator}}
}

Expand All @@ -147,12 +117,7 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis
{{/discriminator}}
{{^discriminator}}
{{#oneOfModels}}
{{#-first}}
if (typeof value !== 'object') {
return value;
}
{{/-first}}
if (instanceOf{{{.}}}(value)) {
if (typeof value === 'object' && instanceOf{{{.}}}(value)) {
return {{{.}}}ToJSON(value as {{{.}}});
}
{{/oneOfModels}}
Expand All @@ -166,7 +131,6 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis
}
{{#-last}}
}
return value;
}
{{/-last}}
{{/oneOfArrays}}
Expand All @@ -175,57 +139,33 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis
{{#items}}
{{#isDateType}}
if (Array.isArray(value)) {
if (value.every(item => item instanceof Date) {
return value.map(value => value.toISOString().substring(0,10)));
if (value.every(item => item instanceof Date)) {
return value.map(value => value.toISOString().substring(0,10));
}
}
{{/isDateType}}
{{#isDateTimeType}}
if (Array.isArray(value)) {
if (value.every(item => item instanceof Date) {
return value.map(value => value.toISOString();
if (value.every(item => item instanceof Date)) {
return value.map(value => value.toISOString());
}
}
{{/isDateTimeType}}
{{#isNumeric}}
if (Array.isArray(value)) {
if (value.every(item => typeof item === 'number'{{#isEnum}} && ({{#allowableValues}}{{#values}}item === {{.}}{{^-last}} || {{/-last}}{{/values}}{{/allowableValues}}){{/isEnum}})) {
return value;
}
}
{{/isNumeric}}
{{#isString}}
if (Array.isArray(value)) {
if (value.every(item => typeof item === 'string'{{#isEnum}} && ({{#allowableValues}}{{#values}}item === '{{.}}'{{^-last}} || {{/-last}}{{/values}}{{/allowableValues}}){{/isEnum}})) {
return value;
}
}
{{/isString}}
{{/items}}
{{/isArray}}
{{^isArray}}
{{#isDateType}}
if (value instanceof Date) {
return ((value{{#isNullable}} as any{{/isNullable}}){{^required}}{{#isNullable}}?{{/isNullable}}{{/required}}.toISOString().substring(0,10));
return value.toISOString().substring(0,10);
}
{{/isDateType}}
{{#isDateTimeType}}
if (value instanceof Date) {
return {{^required}}{{#isNullable}}value === null ? null : {{/isNullable}}{{^isNullable}}value == null ? undefined : {{/isNullable}}{{/required}}((value{{#isNullable}} as any{{/isNullable}}){{^required}}{{#isNullable}}?{{/isNullable}}{{/required}}.toISOString());
return value.toISOString();
}
{{/isDateTimeType}}
{{#isNumeric}}
if (typeof value === 'number'{{#isEnum}} && ({{#allowableValues}}{{#values}}value === {{.}}{{^-last}} || {{/-last}}{{/values}}{{/allowableValues}}){{/isEnum}}) {
return value;
}
{{/isNumeric}}
{{#isString}}
if (typeof value === 'string'{{#isEnum}} && ({{#allowableValues}}{{#values}}value === '{{.}}'{{^-last}} || {{/-last}}{{/values}}{{/allowableValues}}){{/isEnum}}) {
return value;
}
{{/isString}}
{{/isArray}}
{{/oneOfPrimitives}}
return {};
return value;
{{/discriminator}}
}
Original file line number Diff line number Diff line change
Expand Up @@ -358,21 +358,41 @@ public void givenSchemaIsOneOfAndComposedSchemasArePrimitiveThenReturnStatements

Path exampleModelPath = Paths.get(outputPath + "/models/MyCustomSpeed.ts");
//FromJSON
TestUtils.assertFileContains(exampleModelPath, "(typeof json !== 'object')");
TestUtils.assertFileContains(exampleModelPath, "(instanceOfMyNumericValue(json))");
TestUtils.assertFileContains(exampleModelPath, "(typeof json === 'number' && (json === 10 || json === 20 || json === 30))");
TestUtils.assertFileContains(exampleModelPath, "(typeof json === 'string' && (json === 'fixed-value-a' || json === 'fixed-value-b' || json === 'fixed-value-c'))");
TestUtils.assertFileContains(exampleModelPath, "(isNaN(new Date(json).getTime())");
TestUtils.assertFileContains(exampleModelPath, "(json.every(item => typeof item === 'number'))");
TestUtils.assertFileContains(exampleModelPath, "(json.every(item => typeof item === 'string' && (item === 'oneof-array-enum-a' || item === 'oneof-array-enum-b' || item === 'oneof-array-enum-c')))");
String expectedFromJSON =
"export function MyCustomSpeedFromJSONTyped(json: any, ignoreDiscriminator: boolean): MyCustomSpeed {" +
" if (json == null) {" +
" return json;" +
" }" +
" if (typeof json === 'object' && instanceOfMyNumericValue(json)) {" +
" return MyNumericValueFromJSONTyped(json, true);" +
" }" +
" if (!(isNaN(new Date(json).getTime()))) {" +
" return new Date(json);" +
" }" +
" if (!(isNaN(new Date(json).getTime()))) {" +
" return new Date(json);" +
" }" +
" return json;" +
"}";
TestUtils.assertFileContains(exampleModelPath, expectedFromJSON);
//ToJSON
TestUtils.assertFileContains(exampleModelPath, "(typeof value !== 'object')");
TestUtils.assertFileContains(exampleModelPath, "(instanceOfMyNumericValue(value))");
TestUtils.assertFileContains(exampleModelPath, "(typeof value === 'number' && (value === 10 || value === 20 || value === 30))");
TestUtils.assertFileContains(exampleModelPath, "(typeof value === 'string' && (value === 'fixed-value-a' || value === 'fixed-value-b' || value === 'fixed-value-c'))");
TestUtils.assertFileContains(exampleModelPath, "(value instanceof Date)");
TestUtils.assertFileContains(exampleModelPath, "(value.every(item => typeof item === 'number'))");
TestUtils.assertFileContains(exampleModelPath, "(value.every(item => typeof item === 'string' && (item === 'oneof-array-enum-a' || item === 'oneof-array-enum-b' || item === 'oneof-array-enum-c')))");
String expectedToJSON =
"export function MyCustomSpeedToJSONTyped(value?: MyCustomSpeed | null, ignoreDiscriminator: boolean = false): any {" +
" if (value == null) {" +
" return value;" +
" }" +
" if (typeof value === 'object' && instanceOfMyNumericValue(value)) {" +
" return MyNumericValueToJSON(value as MyNumericValue);" +
" }" +
" if (value instanceof Date) {" +
" return value.toISOString();" +
" }" +
" if (value instanceof Date) {" +
" return value.toISOString().substring(0,10);" +
" }" +
" return value;" +
"}";
TestUtils.assertFileContains(exampleModelPath, expectedToJSON);
}

/**
Expand All @@ -397,7 +417,10 @@ public void testOneOfModelsDoNotImportPrimitiveTypes() throws IOException {
TestUtils.assertFileContains(testArrayResponse, "import type { TestA } from './TestA'");
TestUtils.assertFileContains(testArrayResponse, "import type { TestB } from './TestB'");
TestUtils.assertFileNotContains(testResponse, "import type { string } from './string'");
TestUtils.assertFileContains(testArrayResponse, "export type TestArrayResponse = Array<TestA> | Array<TestB> | Array<string>");
TestUtils.assertFileNotContains(testResponse, "import type { boolean } from './boolean'");
TestUtils.assertFileNotContains(testResponse, "import type { number } from './number'");
TestUtils.assertFileNotContains(testResponse, "import type { object } from './object'");
TestUtils.assertFileContains(testArrayResponse, "export type TestArrayResponse = Array<TestA> | Array<TestB> | Array<boolean> | Array<number> | Array<object> | Array<string>");

Path testDiscriminatorResponse = Paths.get(output + "/models/TestDiscriminatorResponse.ts");
TestUtils.assertFileExists(testDiscriminatorResponse);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TestDiscriminatorResponse'
/test-primitive:
get:
operationId: testPrimitive
responses:
200:
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/TestPrimitiveResponse'
/test-with-date:
get:
operationId: testWithDate
responses:
200:
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/TestWithDateResponse'
components:
schemas:
TestArrayResponse:
Expand All @@ -48,6 +68,15 @@ components:
- type: array
items:
type: string
- type: array
items:
type: number
- type: array
items:
type: boolean
- type: array
items:
type: object
TestDiscriminatorResponse:
discriminator:
propertyName: discriminatorField
Expand All @@ -62,6 +91,26 @@ components:
- $ref: "#/components/schemas/TestA"
- $ref: "#/components/schemas/TestB"
- type: string
TestPrimitiveResponse:
oneOf:
- type: string
- type: number
- type: object
- type: boolean
- type: array
items: {}
TestWithDateResponse:
oneOf:
- type: string
format: date
- type: number
- type: array
items:
type: string
format: date
- type: array
items:
type: number
TestA:
type: object
properties:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ models/TestA.ts
models/TestArrayResponse.ts
models/TestB.ts
models/TestDiscriminatorResponse.ts
models/TestPrimitiveResponse.ts
models/TestResponse.ts
models/TestWithDateResponse.ts
models/index.ts
runtime.ts
Loading