diff --git a/CHANGELOG.md b/CHANGELOG.md index a24e5cc50e..8325fb11fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Numeric scalar unions with a format (e.g. `type: ["integer", "string"]` with `format: int32`, as emitted by ASP.NET Core's OpenAPI 3.1 generator under System.Text.Json's default `JsonNumberHandling.AllowReadingFromString`) now map to the numeric type instead of degrading to `UntypedNode`. [#6541](https://github.com/microsoft/kiota/issues/6541) - golang: generated code now always uses LF line endings, so `gofmt` no longer reports formatting differences when generating on Windows. - golang: make sure all generated code adheres to golangs coding standards - Fixed non-deterministic model class descriptions when a component schema is referenced from multiple properties with differing reference-level descriptions. [#7927](https://github.com/microsoft/kiota/issues/7927) diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 1045c042e0..7eeb64e1c7 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -1284,6 +1284,7 @@ openApiExtension is OpenApiPrimaryErrorMessageExtension primaryErrorMessageExten return prop; } private static readonly HashSet typeNamesToSkip = [JsonSchemaType.Object, JsonSchemaType.Array, JsonSchemaType.Object | JsonSchemaType.Null, JsonSchemaType.Array | JsonSchemaType.Null]; + internal static readonly HashSet numericFormats = new(StringComparer.OrdinalIgnoreCase) { "int8", "uint8", "int16", "uint16", "int32", "int64", "float", "double", "decimal" }; private static CodeType? GetPrimitiveType(IOpenApiSchema? typeSchema, string? childType = default) { if (typeSchema?.Items?.IsEnum() ?? false) @@ -1297,7 +1298,17 @@ openApiExtension is OpenApiPrimaryErrorMessageExtension primaryErrorMessageExten var typeName = typeNames.Find(static x => x is not null && !typeNamesToSkip.Contains(x.Value)); var format = typeSchema?.Format ?? typeSchema?.Items?.Format; - return (typeName & ~JsonSchemaType.Null, format?.ToLowerInvariant()) switch + var schemaType = typeName & ~JsonSchemaType.Null; + if (schemaType is { } schemaTypeValue && + format is not null && numericFormats.Contains(format) && + (schemaTypeValue & JsonSchemaType.String) is JsonSchemaType.String && + (schemaTypeValue & (JsonSchemaType.Integer | JsonSchemaType.Number)) != 0) + // System.Text.Json's JsonNumberHandling.AllowReadingFromString (the ASP.NET Core default) + // advertises numeric members as type ["integer"/"number", "string"] with a numeric format. + // Prefer the numeric type over a scalar union that would otherwise not be mappable and + // fall back to untyped. Unions with a non-numeric format keep the previous fallback. + schemaType = schemaTypeValue & ~JsonSchemaType.String; + return (schemaType, format?.ToLowerInvariant()) switch { // byte and binary can apply to any type (_, "byte") => new CodeType { Name = "base64", IsExternal = true }, diff --git a/src/Kiota.Builder/Validation/InconsistentTypeFormatPair.cs b/src/Kiota.Builder/Validation/InconsistentTypeFormatPair.cs index 7249685fcd..a8eace6906 100644 --- a/src/Kiota.Builder/Validation/InconsistentTypeFormatPair.cs +++ b/src/Kiota.Builder/Validation/InconsistentTypeFormatPair.cs @@ -51,8 +51,13 @@ public InconsistentTypeFormatPair() : base(nameof(InconsistentTypeFormatPair), s if (schema is null || !schema.Type.HasValue || string.IsNullOrEmpty(schema.Format) || KnownAndNotSupportedFormats.knownAndUnsupportedFormats.Contains(schema.Format) || escapedTypes.Contains(schema.Type.Value)) return; var sanitizedType = schema.Type.Value & ~JsonSchemaType.Null; + // mirrors KiotaBuilder.GetPrimitiveType: numeric|string unions with a numeric format map to the numeric type + if ((sanitizedType & JsonSchemaType.String) is JsonSchemaType.String && + (sanitizedType & (JsonSchemaType.Integer | JsonSchemaType.Number)) != 0 && + KiotaBuilder.numericFormats.Contains(schema.Format)) + sanitizedType &= ~JsonSchemaType.String; if (!validPairs.TryGetValue(sanitizedType, out var validFormats) || !validFormats.Contains(schema.Format)) - context.CreateWarning(nameof(InconsistentTypeFormatPair), $"The format {schema.Format} is not supported by Kiota for the type {sanitizedType} and the string type will be used."); + context.CreateWarning(nameof(InconsistentTypeFormatPair), $"The format {schema.Format} is not supported by Kiota for the type {sanitizedType} and will be ignored."); }) { } diff --git a/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs b/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs index 16a52c8fa8..37b4b36130 100644 --- a/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs +++ b/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs @@ -1904,6 +1904,76 @@ public void Object_Arrays_are_supported() Assert.NotNull(unknownProp); Assert.Equal(KiotaBuilder.UntypedNodeName, unknownProp.Type.Name);// left out property is an UntypedNode } + [Theory] + [InlineData(JsonSchemaType.Integer | JsonSchemaType.String, "int32", "integer")] + [InlineData(JsonSchemaType.Integer | JsonSchemaType.String, "int64", "int64")] + [InlineData(JsonSchemaType.Integer | JsonSchemaType.String | JsonSchemaType.Null, "int32", "integer")] + [InlineData(JsonSchemaType.Number | JsonSchemaType.String, "double", "double")] + [InlineData(JsonSchemaType.Number | JsonSchemaType.String, "float", "float")] + [InlineData(JsonSchemaType.Number | JsonSchemaType.String | JsonSchemaType.Null, "double", "double")] + [InlineData(JsonSchemaType.Integer | JsonSchemaType.String, "uint16", "integer")] + // without a format, the scalar union keeps being generated as a composed type wrapper + [InlineData(JsonSchemaType.Integer | JsonSchemaType.String, null, "forecastGetResponse_temperature")] + [InlineData(JsonSchemaType.Number | JsonSchemaType.String, null, "forecastGetResponse_temperature")] + // with a non-numeric format, the union is not mappable and keeps falling back to untyped + [InlineData(JsonSchemaType.Integer | JsonSchemaType.String, "uuid", KiotaBuilder.UntypedNodeName)] + [InlineData(JsonSchemaType.Number | JsonSchemaType.String, "date-time", KiotaBuilder.UntypedNodeName)] + public void NumericStringScalarUnionsMapToNumericTypes(JsonSchemaType schemaType, string? format, string expectedTypeName) + { + // System.Text.Json's JsonNumberHandling.AllowReadingFromString (the ASP.NET Core default) + // advertises numeric members as type ["integer"/"number", "string"] with a digit pattern. + // https://github.com/microsoft/kiota/issues/6541 + var document = new OpenApiDocument + { + Paths = new OpenApiPaths + { + ["forecast"] = new OpenApiPathItem + { + Operations = new() + { + [NetHttpMethod.Get] = new OpenApiOperation + { + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Content = new Dictionary() + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary { + { + "temperature", new OpenApiSchema { + Type = schemaType, + Format = format, + Pattern = "^-?(?:0|[1-9]\\d*)$" + } + } + } + } + } + } + } + } + } + } + }, + }, + }; + document.SetReferenceHostDocument(); + var mockLogger = new CountLogger(); + var builder = new KiotaBuilder(mockLogger, new GenerationConfiguration { ClientClassName = "Graph", ApiRootUrl = "https://localhost" }, _httpClient); + var node = builder.CreateUriSpace(document); + var codeModel = builder.CreateSourceModel(node); + var responseClass = codeModel.FindNamespaceByName("ApiSdk.forecast").FindChildByName("ForecastGetResponse", false); + Assert.NotNull(responseClass); + var temperatureProp = responseClass.FindChildByName("temperature", false); + Assert.NotNull(temperatureProp); + Assert.Equal(expectedTypeName, temperatureProp.Type.Name); + } [Fact] public void TextPlainEndpointsAreSupported() { diff --git a/tests/Kiota.Builder.Tests/Validation/InconsistentTypeFormatPairTests.cs b/tests/Kiota.Builder.Tests/Validation/InconsistentTypeFormatPairTests.cs index ce4b49df95..443f50d665 100644 --- a/tests/Kiota.Builder.Tests/Validation/InconsistentTypeFormatPairTests.cs +++ b/tests/Kiota.Builder.Tests/Validation/InconsistentTypeFormatPairTests.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Text; using System.Threading.Tasks; using Kiota.Builder.Validation; @@ -102,6 +103,60 @@ public async Task DoesntAddWarningOnNullable() var diagnostic = await GetDiagnosticFromDocumentAsync(documentTxt); Assert.Empty(diagnostic.Warnings); } + [Theory] + [InlineData("[integer, string]", "int32")] + [InlineData("[integer, string, 'null']", "int64")] + [InlineData("[number, string]", "float")] + [InlineData("[number, string, 'null']", "double")] + [InlineData("[integer, string]", "uint16")] + public async Task DoesntAddAWarningWhenNumericStringUnionWithNumericFormat(string type, string format) + { + var documentTxt = $""" +openapi: 3.1.1 +info: + title: OData Service for namespace microsoft.graph + description: This OData service is located at https://graph.microsoft.com/v1.0 + version: 1.0.1 +paths: + /enumeration: + get: + responses: + '200': + description: some description + content: + application/json: + schema: + type: {type} + format: {format} +"""; + var diagnostic = await GetDiagnosticFromDocumentAsync(documentTxt); + Assert.Empty(diagnostic.Warnings); + } + [Fact] + public async Task AddsAWarningWhenNumericStringUnionWithUnsupportedFormat() + { + var documentTxt = """ +openapi: 3.1.1 +info: + title: OData Service for namespace microsoft.graph + description: This OData service is located at https://graph.microsoft.com/v1.0 + version: 1.0.1 +paths: + /enumeration: + get: + responses: + '200': + description: some description + content: + application/json: + schema: + type: [integer, string] + format: date-time +"""; + var diagnostic = await GetDiagnosticFromDocumentAsync(documentTxt); + var warning = Assert.Single(diagnostic.Warnings); + Assert.EndsWith("will be ignored.", warning.Message, StringComparison.Ordinal); + } private static async Task GetDiagnosticFromDocumentAsync(string document) { var rule = new InconsistentTypeFormatPair();