Skip to content

Commit 0570b32

Browse files
author
GitHub Copilot
committed
correct TypeScript deserialization for discriminated union property types
When a model property is typed as a named oneOf schema that carries a discriminator (e.g. WeatherForecast = oneOf[RainyDayForecast, SunnyDayForecast] with discriminator), the generated TypeScript deserializer was incorrectly chaining individual subtype factory calls with ??: n.getCollectionOfObjectValues(createRainyDayForecastFromDiscriminatorValue) ?? n.getCollectionOfObjectValues(createSunnyDayForecastFromDiscriminatorValue) The fix detects when the composed property type has basic discriminator information and routes those properties through GetDeserializationMethodName on the base type, producing the correct output: n.getCollectionOfObjectValues<WeatherForecast>(createWeatherForecastFromDiscriminatorValue) The same correction applies to single-object properties. Closes #6615
1 parent 8d6ee41 commit 0570b32

4 files changed

Lines changed: 169 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### Changed
1515

1616
- Fixed a bug where required query parameters from one HTTP operation were leaking into the path-item-level URL template, making them appear required for sibling operations on the same path. [#7292](https://github.com/microsoft/kiota/issues/7292)
17+
- Fixed incorrect TypeScript deserialization for properties typed as a named discriminated union (oneOf with discriminator): the generated code now uses the base type's factory method (e.g. `createWeatherForecastFromDiscriminatorValue`) instead of chaining individual subtype factories with `??`. [#6615](https://github.com/microsoft/kiota/issues/6615)
1718
- Fixed a potential NullReferenceException in union model discriminator factory methods when a discriminator mapping key is null or empty across C#, Dart, Go, Java, PHP, and Python writers. [#7641](https://github.com/microsoft/kiota/pull/7641)
1819
- Fixed `kiota download` returning exit code 0 (success) when no results are found or multiple ambiguous matches exist. [#7643](https://github.com/microsoft/kiota/pull/7643)
1920
- Fixed incorrect command hints and telemetry in `kiota plugin generate` handler referencing "client" instead of "plugin". [#7642](https://github.com/microsoft/kiota/pull/7642)

src/Kiota.Builder/Writers/TypeScript/CodeFunctionWriter.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -725,8 +725,17 @@ private void WritePropertyDeserializationBlock(CodeProperty otherProp, CodeParam
725725
}
726726
else if (GetOriginalComposedType(otherProp.Type) is { } composedType)
727727
{
728-
var expression = string.Join(" ?? ", SortTypesByInheritance(composedType.Types).Select(codeType => $"n.{conventions.GetDeserializationMethodName(codeType, codeFile, composedType.IsCollection)}"));
729-
writer.WriteLine($"\"{otherProp.WireName.SanitizeDoubleQuote()}\": n => {{ {paramName}.{propName} = {expression};{suffix} }},");
728+
if (TypeHasBasicDiscriminatorInformation(otherProp.Type))
729+
{
730+
var deserializationMethodName = conventions.GetDeserializationMethodName(otherProp.Type, codeFile);
731+
var defaultValueSuffix = GetDefaultValueSuffix(otherProp);
732+
writer.WriteLine($"\"{otherProp.WireName.SanitizeDoubleQuote()}\": n => {{ {paramName}.{propName} = n.{deserializationMethodName}{defaultValueSuffix};{suffix} }},");
733+
}
734+
else
735+
{
736+
var expression = string.Join(" ?? ", SortTypesByInheritance(composedType.Types).Select(codeType => $"n.{conventions.GetDeserializationMethodName(codeType, codeFile, composedType.IsCollection)}"));
737+
writer.WriteLine($"\"{otherProp.WireName.SanitizeDoubleQuote()}\": n => {{ {paramName}.{propName} = {expression};{suffix} }},");
738+
}
730739
}
731740
else
732741
{
@@ -742,6 +751,14 @@ private static string GetDefaultValueSuffix(CodeProperty otherProp)
742751
return !string.IsNullOrEmpty(defaultValue) && !defaultValue.EqualsIgnoreCase("\"null\"") ? $" ?? {defaultValue}" : string.Empty;
743752
}
744753

754+
private static bool TypeHasBasicDiscriminatorInformation(CodeTypeBase codeType) =>
755+
codeType switch
756+
{
757+
CodeType { TypeDefinition: CodeInterface codeInterface } => codeInterface.OriginalClass?.DiscriminatorInformation.HasBasicDiscriminatorInformation == true,
758+
CodeType { TypeDefinition: CodeClass codeClass } => codeClass.DiscriminatorInformation.HasBasicDiscriminatorInformation,
759+
_ => false,
760+
};
761+
745762
private static string GetDefaultValueLiteralForProperty(CodeProperty codeProperty)
746763
{
747764
if (string.IsNullOrEmpty(codeProperty.DefaultValue)) return string.Empty;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
namespace Kiota.Builder.Tests.OpenApiSampleFiles;
2+
3+
public static class DiscriminatedUnionPropertySample
4+
{
5+
/**
6+
* An OpenAPI 3.0.0 sample document with a named oneOf schema (WeatherForecast) that uses a
7+
* discriminator. WeatherSummary is a container model that references WeatherForecast as both
8+
* a collection property and a single-object property. This validates that the TypeScript
9+
* deserializer uses the base type factory (createWeatherForecastFromDiscriminatorValue) rather
10+
* than chaining subtype factories with ??.
11+
*/
12+
public static readonly string OpenApiYaml = @"
13+
openapi: 3.0.0
14+
info:
15+
title: Forecast API
16+
version: 1.0.0
17+
paths:
18+
/forecast:
19+
get:
20+
summary: Get forecast summary
21+
responses:
22+
'200':
23+
description: A weather summary
24+
content:
25+
application/json:
26+
schema:
27+
$ref: '#/components/schemas/WeatherSummary'
28+
components:
29+
schemas:
30+
WeatherForecast:
31+
oneOf:
32+
- $ref: '#/components/schemas/RainyDayForecast'
33+
- $ref: '#/components/schemas/SunnyDayForecast'
34+
discriminator:
35+
propertyName: forecastType
36+
mapping:
37+
rain: '#/components/schemas/RainyDayForecast'
38+
sunny: '#/components/schemas/SunnyDayForecast'
39+
RainyDayForecast:
40+
type: object
41+
properties:
42+
forecastType:
43+
type: string
44+
rainAmount:
45+
type: number
46+
SunnyDayForecast:
47+
type: object
48+
properties:
49+
forecastType:
50+
type: string
51+
uvIndex:
52+
type: number
53+
WeatherSummary:
54+
type: object
55+
properties:
56+
forecasts:
57+
type: array
58+
items:
59+
$ref: '#/components/schemas/WeatherForecast'
60+
primaryForecast:
61+
$ref: '#/components/schemas/WeatherForecast'";
62+
}

tests/Kiota.Builder.Tests/Writers/TypeScript/CodeFunctionWriterTests.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,5 +2009,92 @@ public async Task WritesOneOfWithInheritanceDeserializationAsync()
20092009
Assert.True(deviceIndex > 0, "Should contain Device deserialization");
20102010
Assert.True(managedDeviceIndex < deviceIndex, "ManagedPrivilegedDevice should appear before Device in the deserialization chain");
20112011
}
2012+
2013+
[Fact]
2014+
public async Task Writes_DiscriminatedUnionPropertyType_UsesBaseFactoryMethodAsync()
2015+
{
2016+
var generationConfiguration = new GenerationConfiguration { Language = GenerationLanguage.TypeScript };
2017+
var modelNameSpace = root.AddNamespace("ApiSdk.models");
2018+
2019+
var baseClass = TestHelper.CreateModelClass(modelNameSpace, "WeatherForecast");
2020+
baseClass.DiscriminatorInformation.DiscriminatorPropertyName = "forecastType";
2021+
var rainyClass = TestHelper.CreateModelClass(modelNameSpace, "RainyDayForecast");
2022+
var sunnyClass = TestHelper.CreateModelClass(modelNameSpace, "SunnyDayForecast");
2023+
baseClass.DiscriminatorInformation.AddDiscriminatorMapping("rain", new CodeType { Name = "RainyDayForecast", TypeDefinition = rainyClass });
2024+
baseClass.DiscriminatorInformation.AddDiscriminatorMapping("sunny", new CodeType { Name = "SunnyDayForecast", TypeDefinition = sunnyClass });
2025+
2026+
var composedType = new CodeUnionType { Name = "WeatherForecast" };
2027+
composedType.AddType(
2028+
new CodeType { Name = "RainyDayForecast", TypeDefinition = rainyClass },
2029+
new CodeType { Name = "SunnyDayForecast", TypeDefinition = sunnyClass });
2030+
baseClass.OriginalComposedType = composedType;
2031+
2032+
var containerClass = TestHelper.CreateModelClass(modelNameSpace, "WeatherSummary");
2033+
containerClass.AddProperty(new CodeProperty
2034+
{
2035+
Name = "forecasts",
2036+
Type = new CodeType
2037+
{
2038+
Name = "WeatherForecast",
2039+
TypeDefinition = baseClass,
2040+
CollectionKind = CodeTypeBase.CodeTypeCollectionKind.Array,
2041+
},
2042+
});
2043+
containerClass.AddProperty(new CodeProperty
2044+
{
2045+
Name = "primaryForecast",
2046+
Type = new CodeType
2047+
{
2048+
Name = "WeatherForecast",
2049+
TypeDefinition = baseClass,
2050+
},
2051+
});
2052+
2053+
TestHelper.AddSerializationPropertiesToModelClass(containerClass);
2054+
await ILanguageRefiner.RefineAsync(generationConfiguration, root, cancellationToken: TestContext.Current.CancellationToken);
2055+
2056+
var deserializerFunction = root.FindChildByName<CodeFunction>($"DeserializeIntoWeatherSummary");
2057+
Assert.NotNull(deserializerFunction);
2058+
var parentNS = deserializerFunction.GetImmediateParentOfType<CodeNamespace>();
2059+
Assert.NotNull(parentNS);
2060+
parentNS.TryAddCodeFile("foo", deserializerFunction);
2061+
writer.Write(deserializerFunction);
2062+
var result = tw.ToString();
2063+
2064+
Assert.Contains("n.getCollectionOfObjectValues<WeatherForecast>(createWeatherForecastFromDiscriminatorValue)", result);
2065+
Assert.Contains("n.getObjectValue<WeatherForecast>(createWeatherForecastFromDiscriminatorValue)", result);
2066+
Assert.DoesNotContain("createRainyDayForecastFromDiscriminatorValue", result);
2067+
Assert.DoesNotContain("createSunnyDayForecastFromDiscriminatorValue", result);
2068+
AssertExtensions.CurlyBracesAreClosed(result, 1);
2069+
}
2070+
2071+
[Fact]
2072+
public async Task Writes_DiscriminatedUnionPropertyType_UsesBaseFactoryMethod_IntegrationAsync()
2073+
{
2074+
var generationConfiguration = new GenerationConfiguration { Language = GenerationLanguage.TypeScript };
2075+
var tempFilePath = Path.GetTempFileName();
2076+
await File.WriteAllTextAsync(tempFilePath, DiscriminatedUnionPropertySample.OpenApiYaml, cancellationToken: TestContext.Current.CancellationToken);
2077+
var mockLogger = new Mock<ILogger<KiotaBuilder>>();
2078+
var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "Forecast", Serializers = ["none"], Deserializers = ["none"] }, _httpClient);
2079+
await using var fs = new FileStream(tempFilePath, FileMode.Open);
2080+
var document = await builder.CreateOpenApiDocumentAsync(fs, cancellationToken: TestContext.Current.CancellationToken);
2081+
var node = builder.CreateUriSpace(document);
2082+
builder.SetApiRootUrl();
2083+
var codeModel = builder.CreateSourceModel(node);
2084+
var rootNS = codeModel.FindNamespaceByName("ApiSdk");
2085+
Assert.NotNull(rootNS);
2086+
await ILanguageRefiner.RefineAsync(generationConfiguration, rootNS, cancellationToken: TestContext.Current.CancellationToken);
2087+
File.Delete(tempFilePath);
2088+
2089+
var deserializerFunction = rootNS.FindChildByName<CodeFunction>("deserializeIntoWeatherSummary");
2090+
Assert.NotNull(deserializerFunction);
2091+
writer.Write(deserializerFunction);
2092+
var result = tw.ToString();
2093+
2094+
Assert.Contains("createWeatherForecastFromDiscriminatorValue", result);
2095+
Assert.DoesNotContain("createRainyDayForecastFromDiscriminatorValue", result);
2096+
Assert.DoesNotContain("createSunnyDayForecastFromDiscriminatorValue", result);
2097+
AssertExtensions.CurlyBracesAreClosed(result, 1);
2098+
}
20122099
}
20132100

0 commit comments

Comments
 (0)