diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c3d298be..e1e3386906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - 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) +- 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) - 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) - 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) - Fixed incorrect command hints and telemetry in `kiota plugin generate` handler referencing "client" instead of "plugin". [#7642](https://github.com/microsoft/kiota/pull/7642) diff --git a/src/Kiota.Builder/Writers/TypeScript/CodeFunctionWriter.cs b/src/Kiota.Builder/Writers/TypeScript/CodeFunctionWriter.cs index 78c850dd3e..799a67ca92 100644 --- a/src/Kiota.Builder/Writers/TypeScript/CodeFunctionWriter.cs +++ b/src/Kiota.Builder/Writers/TypeScript/CodeFunctionWriter.cs @@ -725,8 +725,17 @@ private void WritePropertyDeserializationBlock(CodeProperty otherProp, CodeParam } else if (GetOriginalComposedType(otherProp.Type) is { } composedType) { - var expression = string.Join(" ?? ", SortTypesByInheritance(composedType.Types).Select(codeType => $"n.{conventions.GetDeserializationMethodName(codeType, codeFile, composedType.IsCollection)}")); - writer.WriteLine($"\"{otherProp.WireName.SanitizeDoubleQuote()}\": n => {{ {paramName}.{propName} = {expression};{suffix} }},"); + if (TypeHasBasicDiscriminatorInformation(otherProp.Type)) + { + var deserializationMethodName = conventions.GetDeserializationMethodName(otherProp.Type, codeFile); + var defaultValueSuffix = GetDefaultValueSuffix(otherProp); + writer.WriteLine($"\"{otherProp.WireName.SanitizeDoubleQuote()}\": n => {{ {paramName}.{propName} = n.{deserializationMethodName}{defaultValueSuffix};{suffix} }},"); + } + else + { + var expression = string.Join(" ?? ", SortTypesByInheritance(composedType.Types).Select(codeType => $"n.{conventions.GetDeserializationMethodName(codeType, codeFile, composedType.IsCollection)}")); + writer.WriteLine($"\"{otherProp.WireName.SanitizeDoubleQuote()}\": n => {{ {paramName}.{propName} = {expression};{suffix} }},"); + } } else { @@ -742,6 +751,14 @@ private static string GetDefaultValueSuffix(CodeProperty otherProp) return !string.IsNullOrEmpty(defaultValue) && !defaultValue.EqualsIgnoreCase("\"null\"") ? $" ?? {defaultValue}" : string.Empty; } + private static bool TypeHasBasicDiscriminatorInformation(CodeTypeBase codeType) => + codeType switch + { + CodeType { TypeDefinition: CodeInterface codeInterface } => codeInterface.OriginalClass?.DiscriminatorInformation.HasBasicDiscriminatorInformation == true, + CodeType { TypeDefinition: CodeClass codeClass } => codeClass.DiscriminatorInformation.HasBasicDiscriminatorInformation, + _ => false, + }; + private static string GetDefaultValueLiteralForProperty(CodeProperty codeProperty) { if (string.IsNullOrEmpty(codeProperty.DefaultValue)) return string.Empty; diff --git a/tests/Kiota.Builder.Tests/OpenApiSampleFiles/DiscriminatedUnionPropertySample.cs b/tests/Kiota.Builder.Tests/OpenApiSampleFiles/DiscriminatedUnionPropertySample.cs new file mode 100644 index 0000000000..8da9b320c7 --- /dev/null +++ b/tests/Kiota.Builder.Tests/OpenApiSampleFiles/DiscriminatedUnionPropertySample.cs @@ -0,0 +1,62 @@ +namespace Kiota.Builder.Tests.OpenApiSampleFiles; + +public static class DiscriminatedUnionPropertySample +{ + /** + * An OpenAPI 3.0.0 sample document with a named oneOf schema (WeatherForecast) that uses a + * discriminator. WeatherSummary is a container model that references WeatherForecast as both + * a collection property and a single-object property. This validates that the TypeScript + * deserializer uses the base type factory (createWeatherForecastFromDiscriminatorValue) rather + * than chaining subtype factories with ??. + */ + public static readonly string OpenApiYaml = @" +openapi: 3.0.0 +info: + title: Forecast API + version: 1.0.0 +paths: + /forecast: + get: + summary: Get forecast summary + responses: + '200': + description: A weather summary + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherSummary' +components: + schemas: + WeatherForecast: + oneOf: + - $ref: '#/components/schemas/RainyDayForecast' + - $ref: '#/components/schemas/SunnyDayForecast' + discriminator: + propertyName: forecastType + mapping: + rain: '#/components/schemas/RainyDayForecast' + sunny: '#/components/schemas/SunnyDayForecast' + RainyDayForecast: + type: object + properties: + forecastType: + type: string + rainAmount: + type: number + SunnyDayForecast: + type: object + properties: + forecastType: + type: string + uvIndex: + type: number + WeatherSummary: + type: object + properties: + forecasts: + type: array + items: + $ref: '#/components/schemas/WeatherForecast' + primaryForecast: + $ref: '#/components/schemas/WeatherForecast'"; +} diff --git a/tests/Kiota.Builder.Tests/Writers/TypeScript/CodeFunctionWriterTests.cs b/tests/Kiota.Builder.Tests/Writers/TypeScript/CodeFunctionWriterTests.cs index f119633c6a..6d16a288fe 100644 --- a/tests/Kiota.Builder.Tests/Writers/TypeScript/CodeFunctionWriterTests.cs +++ b/tests/Kiota.Builder.Tests/Writers/TypeScript/CodeFunctionWriterTests.cs @@ -2009,5 +2009,92 @@ public async Task WritesOneOfWithInheritanceDeserializationAsync() Assert.True(deviceIndex > 0, "Should contain Device deserialization"); Assert.True(managedDeviceIndex < deviceIndex, "ManagedPrivilegedDevice should appear before Device in the deserialization chain"); } + + [Fact] + public async Task Writes_DiscriminatedUnionPropertyType_UsesBaseFactoryMethodAsync() + { + var generationConfiguration = new GenerationConfiguration { Language = GenerationLanguage.TypeScript }; + var modelNameSpace = root.AddNamespace("ApiSdk.models"); + + var baseClass = TestHelper.CreateModelClass(modelNameSpace, "WeatherForecast"); + baseClass.DiscriminatorInformation.DiscriminatorPropertyName = "forecastType"; + var rainyClass = TestHelper.CreateModelClass(modelNameSpace, "RainyDayForecast"); + var sunnyClass = TestHelper.CreateModelClass(modelNameSpace, "SunnyDayForecast"); + baseClass.DiscriminatorInformation.AddDiscriminatorMapping("rain", new CodeType { Name = "RainyDayForecast", TypeDefinition = rainyClass }); + baseClass.DiscriminatorInformation.AddDiscriminatorMapping("sunny", new CodeType { Name = "SunnyDayForecast", TypeDefinition = sunnyClass }); + + var composedType = new CodeUnionType { Name = "WeatherForecast" }; + composedType.AddType( + new CodeType { Name = "RainyDayForecast", TypeDefinition = rainyClass }, + new CodeType { Name = "SunnyDayForecast", TypeDefinition = sunnyClass }); + baseClass.OriginalComposedType = composedType; + + var containerClass = TestHelper.CreateModelClass(modelNameSpace, "WeatherSummary"); + containerClass.AddProperty(new CodeProperty + { + Name = "forecasts", + Type = new CodeType + { + Name = "WeatherForecast", + TypeDefinition = baseClass, + CollectionKind = CodeTypeBase.CodeTypeCollectionKind.Array, + }, + }); + containerClass.AddProperty(new CodeProperty + { + Name = "primaryForecast", + Type = new CodeType + { + Name = "WeatherForecast", + TypeDefinition = baseClass, + }, + }); + + TestHelper.AddSerializationPropertiesToModelClass(containerClass); + await ILanguageRefiner.RefineAsync(generationConfiguration, root, cancellationToken: TestContext.Current.CancellationToken); + + var deserializerFunction = root.FindChildByName($"DeserializeIntoWeatherSummary"); + Assert.NotNull(deserializerFunction); + var parentNS = deserializerFunction.GetImmediateParentOfType(); + Assert.NotNull(parentNS); + parentNS.TryAddCodeFile("foo", deserializerFunction); + writer.Write(deserializerFunction); + var result = tw.ToString(); + + Assert.Contains("n.getCollectionOfObjectValues(createWeatherForecastFromDiscriminatorValue)", result); + Assert.Contains("n.getObjectValue(createWeatherForecastFromDiscriminatorValue)", result); + Assert.DoesNotContain("createRainyDayForecastFromDiscriminatorValue", result); + Assert.DoesNotContain("createSunnyDayForecastFromDiscriminatorValue", result); + AssertExtensions.CurlyBracesAreClosed(result, 1); + } + + [Fact] + public async Task Writes_DiscriminatedUnionPropertyType_UsesBaseFactoryMethod_IntegrationAsync() + { + var generationConfiguration = new GenerationConfiguration { Language = GenerationLanguage.TypeScript }; + var tempFilePath = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempFilePath, DiscriminatedUnionPropertySample.OpenApiYaml, cancellationToken: TestContext.Current.CancellationToken); + var mockLogger = new Mock>(); + var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "Forecast", Serializers = ["none"], Deserializers = ["none"] }, _httpClient); + await using var fs = new FileStream(tempFilePath, FileMode.Open); + var document = await builder.CreateOpenApiDocumentAsync(fs, cancellationToken: TestContext.Current.CancellationToken); + var node = builder.CreateUriSpace(document); + builder.SetApiRootUrl(); + var codeModel = builder.CreateSourceModel(node); + var rootNS = codeModel.FindNamespaceByName("ApiSdk"); + Assert.NotNull(rootNS); + await ILanguageRefiner.RefineAsync(generationConfiguration, rootNS, cancellationToken: TestContext.Current.CancellationToken); + File.Delete(tempFilePath); + + var deserializerFunction = rootNS.FindChildByName("deserializeIntoWeatherSummary"); + Assert.NotNull(deserializerFunction); + writer.Write(deserializerFunction); + var result = tw.ToString(); + + Assert.Contains("createWeatherForecastFromDiscriminatorValue", result); + Assert.DoesNotContain("createRainyDayForecastFromDiscriminatorValue", result); + Assert.DoesNotContain("createSunnyDayForecastFromDiscriminatorValue", result); + AssertExtensions.CurlyBracesAreClosed(result, 1); + } }