From f6a3fe127962f8bc7279ab2ff3d1c660bdb06ce1 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 31 May 2025 15:53:51 +0100 Subject: [PATCH 01/13] [OpenAPI] Use invariant culture for TextWriter Ensure OpenAPI documents are written to a culture-invariant `TextWriter` implementation. Contributes to #60628. --- .../sample/Controllers/TestController.cs | 9 ++++ src/OpenApi/sample/Program.cs | 27 ++++++++++++ .../OpenApiEndpointRouteBuilderExtensions.cs | 2 +- .../OpenApiDocumentIntegrationTests.cs | 41 ++++++++++++------- ...ment_documentName=controllers.verified.txt | 31 ++++++++++++++ ...ment_documentName=controllers.verified.txt | 31 ++++++++++++++ .../common/Shared/Utf8BufferTextWriter.cs | 6 +++ src/submodules/googletest | 2 +- 8 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index fdc398987a35..4ae0d1ef5fa1 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -32,6 +32,13 @@ public IActionResult PostForm([FromForm] MvcTodo todo) return Ok(todo); } + [HttpGet] + [Route("/getcultureinvariant")] + public Ok PostTypedResult() + { + return TypedResults.Ok(new CurrentWeather(1.0f)); + } + public class RouteParamsContainer { [FromRoute] @@ -44,4 +51,6 @@ public class RouteParamsContainer } public record MvcTodo(string Title, string Description, bool IsCompleted); + + public record CurrentWeather([property: Range(-100.5f, 100.5f)] float Temperature = 0.1f); } diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index 773e89d28093..65739eb211ce 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Text.Json.Serialization; using Sample.Transformers; @@ -40,6 +41,32 @@ var app = builder.Build(); +// Run requests with a culture that uses commas to format decimals to +// verify the invariant culture is used to generate the OpenAPI document. +app.Use((next) => +{ + return async context => + { + var originalCulture = CultureInfo.CurrentCulture; + var originalUICulture = CultureInfo.CurrentUICulture; + + var newCulture = new CultureInfo("fr-FR"); + + try + { + CultureInfo.CurrentCulture = newCulture; + CultureInfo.CurrentUICulture = newCulture; + + await next(context); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUICulture; + } + }; +}); + app.MapOpenApi(); app.MapOpenApi("/openapi/{documentName}.yaml"); if (app.Environment.IsDevelopment()) diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs index 8fdbfa27d723..7028ae71600b 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -52,7 +52,7 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e var document = await documentService.GetOpenApiDocumentAsync(context.RequestServices, context.Request, context.RequestAborted); var documentOptions = options.Get(lowercasedDocumentName); - using var textWriter = new Utf8BufferTextWriter(); + using var textWriter = new Utf8BufferTextWriter(System.Globalization.CultureInfo.InvariantCulture); textWriter.SetWriter(context.Response.BodyWriter); string contentType; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs index 95959eb526d0..cd578e5fcffd 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs @@ -9,21 +9,32 @@ [UsesVerify] public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) : IClassFixture { + public static TheoryData OpenApiDocuments() + { + OpenApiSpecVersion[] versions = + [ + OpenApiSpecVersion.OpenApi3_0, + OpenApiSpecVersion.OpenApi3_1, + ]; + + var testCases = new TheoryData(); + + foreach (var version in versions) + { + testCases.Add("v1", version); + testCases.Add("v2", version); + testCases.Add("controllers", version); + testCases.Add("responses", version); + testCases.Add("forms", version); + testCases.Add("schemas-by-ref", version); + testCases.Add("xml", version); + } + + return testCases; + } + [Theory] - [InlineData("v1", OpenApiSpecVersion.OpenApi3_0)] - [InlineData("v2", OpenApiSpecVersion.OpenApi3_0)] - [InlineData("controllers", OpenApiSpecVersion.OpenApi3_0)] - [InlineData("responses", OpenApiSpecVersion.OpenApi3_0)] - [InlineData("forms", OpenApiSpecVersion.OpenApi3_0)] - [InlineData("schemas-by-ref", OpenApiSpecVersion.OpenApi3_0)] - [InlineData("xml", OpenApiSpecVersion.OpenApi3_0)] - [InlineData("v1", OpenApiSpecVersion.OpenApi3_1)] - [InlineData("v2", OpenApiSpecVersion.OpenApi3_1)] - [InlineData("controllers", OpenApiSpecVersion.OpenApi3_1)] - [InlineData("responses", OpenApiSpecVersion.OpenApi3_1)] - [InlineData("forms", OpenApiSpecVersion.OpenApi3_1)] - [InlineData("schemas-by-ref", OpenApiSpecVersion.OpenApi3_1)] - [InlineData("xml", OpenApiSpecVersion.OpenApi3_1)] + [MemberData(nameof(OpenApiDocuments))] public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion version) { var documentService = fixture.Services.GetRequiredKeyedService(documentName); @@ -34,7 +45,7 @@ public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion ? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "Integration", "snapshots") : "snapshots"; var outputDirectory = Path.Combine(baseSnapshotsDirectory, version.ToString()); - await Verifier.Verify(json) + await Verify(json) .UseDirectory(outputDirectory) .UseParameters(documentName); } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index ce5cd62b4ecf..5728ecec9c3c 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -105,10 +105,41 @@ } } } + }, + "/getcultureinvariant": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + } + } + } + } + } } }, "components": { "schemas": { + "CurrentWeather": { + "type": "object", + "properties": { + "temperature": { + "maximum": 100.5, + "minimum": -100.5, + "type": "number", + "format": "float", + "default": 0.1 + } + } + }, "MvcTodo": { "required": [ "title", diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index efb88cb71d5e..b6e1a4692d89 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -105,10 +105,41 @@ } } } + }, + "/getcultureinvariant": { + "get": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + } + } + } + } + } } }, "components": { "schemas": { + "CurrentWeather": { + "type": "object", + "properties": { + "temperature": { + "maximum": 100.5, + "minimum": -100.5, + "type": "number", + "format": "float", + "default": 0.1 + } + } + }, "MvcTodo": { "required": [ "title", diff --git a/src/SignalR/common/Shared/Utf8BufferTextWriter.cs b/src/SignalR/common/Shared/Utf8BufferTextWriter.cs index 6c993f11be7a..f86432af249a 100644 --- a/src/SignalR/common/Shared/Utf8BufferTextWriter.cs +++ b/src/SignalR/common/Shared/Utf8BufferTextWriter.cs @@ -35,6 +35,12 @@ public Utf8BufferTextWriter() _encoder = _utf8NoBom.GetEncoder(); } + public Utf8BufferTextWriter(IFormatProvider formatProvider) + : base(formatProvider) + { + _encoder = _utf8NoBom.GetEncoder(); + } + public static Utf8BufferTextWriter Get(IBufferWriter bufferWriter) { var writer = _cachedInstance; diff --git a/src/submodules/googletest b/src/submodules/googletest index a45468c0fcbe..175c1b55cfb3 160000 --- a/src/submodules/googletest +++ b/src/submodules/googletest @@ -1 +1 @@ -Subproject commit a45468c0fcbeda1588573a7d8283b320bf9970cb +Subproject commit 175c1b55cfb3dbed519b94a370c083aac605009f From a341e926d9344826fe9e62e1d8c869c07bdca7a0 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Sat, 31 May 2025 17:53:46 +0100 Subject: [PATCH 02/13] Update TestController.cs --- src/OpenApi/sample/Controllers/TestController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 4ae0d1ef5fa1..a1ad5359d1ea 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -34,7 +34,7 @@ public IActionResult PostForm([FromForm] MvcTodo todo) [HttpGet] [Route("/getcultureinvariant")] - public Ok PostTypedResult() + public Ok GetTypedResult() { return TypedResults.Ok(new CurrentWeather(1.0f)); } From 3958894b86f3ed6079790c7a5c753771e47fac50 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Sat, 31 May 2025 17:54:41 +0100 Subject: [PATCH 03/13] Update TestController.cs --- src/OpenApi/sample/Controllers/TestController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index a1ad5359d1ea..79784263fb0f 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -34,7 +34,7 @@ public IActionResult PostForm([FromForm] MvcTodo todo) [HttpGet] [Route("/getcultureinvariant")] - public Ok GetTypedResult() + public Ok GetCurrentWeather() { return TypedResults.Ok(new CurrentWeather(1.0f)); } From 03926c67b24004c3246791bd61274ad0f4b943b4 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 2 Jun 2025 18:01:14 +0100 Subject: [PATCH 04/13] Apply fix to OpenAPI document tool Apply the same fix to ensure invariant formatting to the OpenAPI document tool. Co-Authored-By: Sjoerd van der Meer <2460430+desjoerd@users.noreply.github.com> --- .../src/Commands/GetDocumentCommandWorker.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs b/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs index 9b8947f8da36..0bd4ed9be2dd 100644 --- a/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs +++ b/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs @@ -330,7 +330,7 @@ private string GetDocument( _reporter.WriteInformation(Resources.FormatGeneratingDocument(documentName)); using var stream = new MemoryStream(); - using (var writer = new StreamWriter(stream, _utf8EncodingWithoutBOM, bufferSize: 1024, leaveOpen: true)) + using (var writer = new InvariantStreamWriter(stream, _utf8EncodingWithoutBOM, bufferSize: 1024, leaveOpen: true)) { var targetMethod = generateWithVersionMethod ?? generateMethod; object[] arguments = [documentName, writer]; @@ -464,6 +464,12 @@ private object InvokeMethod(MethodInfo method, object instance, object[] argumen return result; } + private sealed class InvariantStreamWriter(Stream stream, Encoding? encoding = null, int bufferSize = -1, bool leaveOpen = false) + : StreamWriter(stream, encoding, bufferSize, leaveOpen) + { + public override IFormatProvider FormatProvider => System.Globalization.CultureInfo.InvariantCulture; + } + #if NET7_0_OR_GREATER private sealed class NoopHostLifetime : IHostLifetime { From d180e54cbdc8e1d20ecf314902823b79b2c54848 Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 4 Jun 2025 14:43:56 +0100 Subject: [PATCH 05/13] Repro issue Change the integration test to use HTTP client so that the middleware to write the OpenAPI document is invoked, which is where the bug is. Going through `OpenApiDocument.SerializeAsJsonAsync()` uses a different code path that already uses a culture-invariant `TextWriter` so wasn't susceptible to the bug. --- .../Integration/OpenApiDocumentIntegrationTests.cs | 8 ++------ ...yOpenApiDocument_documentName=controllers.verified.txt | 7 ++++++- ....VerifyOpenApiDocument_documentName=forms.verified.txt | 7 ++++++- ...ifyOpenApiDocument_documentName=responses.verified.txt | 7 ++++++- ...enApiDocument_documentName=schemas-by-ref.verified.txt | 7 ++++++- ...sts.VerifyOpenApiDocument_documentName=v1.verified.txt | 7 ++++++- ...sts.VerifyOpenApiDocument_documentName=v2.verified.txt | 7 ++++++- ...ts.VerifyOpenApiDocument_documentName=xml.verified.txt | 7 ++++++- ...yOpenApiDocument_documentName=controllers.verified.txt | 5 +++++ ....VerifyOpenApiDocument_documentName=forms.verified.txt | 5 +++++ ...ifyOpenApiDocument_documentName=responses.verified.txt | 5 +++++ ...enApiDocument_documentName=schemas-by-ref.verified.txt | 5 +++++ ...sts.VerifyOpenApiDocument_documentName=v1.verified.txt | 5 +++++ ...sts.VerifyOpenApiDocument_documentName=v2.verified.txt | 5 +++++ ...ts.VerifyOpenApiDocument_documentName=xml.verified.txt | 5 +++++ 15 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs index cd578e5fcffd..094535d767f1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.InternalTesting; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; [UsesVerify] @@ -37,10 +35,8 @@ public static TheoryData OpenApiDocuments() [MemberData(nameof(OpenApiDocuments))] public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion version) { - var documentService = fixture.Services.GetRequiredKeyedService(documentName); - var scopedServiceProvider = fixture.Services.CreateScope(); - var document = await documentService.GetOpenApiDocumentAsync(scopedServiceProvider.ServiceProvider); - var json = await document.SerializeAsJsonAsync(version); + using var client = fixture.CreateClient(); + var json = await client.GetStringAsync($"/openapi/{documentName}.json"); var baseSnapshotsDirectory = SkipOnHelixAttribute.OnHelix() ? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "Integration", "snapshots") : "snapshots"; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 5728ecec9c3c..22a4b1afa8cc 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -1,9 +1,14 @@ { - "openapi": "3.0.4", + "openapi": "3.1.1", "info": { "title": "Sample | controllers", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/getbyidandname/{id}/{name}": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index e4bbaf44a54a..dd9c09845ae7 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt @@ -1,9 +1,14 @@ { - "openapi": "3.0.4", + "openapi": "3.1.1", "info": { "title": "Sample | forms", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/forms/form-file": { "post": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index 96a3be6747cf..b7d8de460ab6 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -1,9 +1,14 @@ { - "openapi": "3.0.4", + "openapi": "3.1.1", "info": { "title": "Sample | responses", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/responses/200-add-xml": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 788ea787fa14..3bf506348815 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -1,9 +1,14 @@ { - "openapi": "3.0.4", + "openapi": "3.1.1", "info": { "title": "Sample | schemas-by-ref", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/schemas-by-ref/typed-results": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt index 98c81bc48fce..da59605957a1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt @@ -1,9 +1,14 @@ { - "openapi": "3.0.4", + "openapi": "3.1.1", "info": { "title": "Sample | v1", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/v1/array-of-guids": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt index 10aa7f3ec95f..4fcf63a9a628 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt @@ -1,5 +1,5 @@ { - "openapi": "3.0.4", + "openapi": "3.1.1", "info": { "title": "Sample | v2", "contact": { @@ -11,6 +11,11 @@ }, "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/v2/users": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt index 9eed2206b116..162ca0870436 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt @@ -1,9 +1,14 @@ { - "openapi": "3.0.4", + "openapi": "3.1.1", "info": { "title": "Sample | xml", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/xml/type-with-examples": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index b6e1a4692d89..22a4b1afa8cc 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -4,6 +4,11 @@ "title": "Sample | controllers", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/getbyidandname/{id}/{name}": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index c68e4d17c64d..dd9c09845ae7 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt @@ -4,6 +4,11 @@ "title": "Sample | forms", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/forms/form-file": { "post": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index 45a4660aa78c..b7d8de460ab6 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -4,6 +4,11 @@ "title": "Sample | responses", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/responses/200-add-xml": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 326da7fb0fb7..3bf506348815 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -4,6 +4,11 @@ "title": "Sample | schemas-by-ref", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/schemas-by-ref/typed-results": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt index abbe8732d74f..da59605957a1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt @@ -4,6 +4,11 @@ "title": "Sample | v1", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/v1/array-of-guids": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt index fed56ba97790..4fcf63a9a628 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt @@ -11,6 +11,11 @@ }, "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/v2/users": { "get": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt index 4a8829575928..162ca0870436 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt @@ -4,6 +4,11 @@ "title": "Sample | xml", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/xml/type-with-examples": { "get": { From d0cafe5886bb712cac0fa0461d10a6ee5f4483b1 Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 4 Jun 2025 16:03:21 +0100 Subject: [PATCH 06/13] [OpenAPI] Fix Culture formatting for [Range] Update OpenAPI range formatting to format using the target culture and update tests to write in the invariant culture. Co-Authored-By: Sjoerd van der Meer <2460430+desjoerd@users.noreply.github.com> --- src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs | 4 ++-- .../SnapshotTestHelper.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index bde53bfd7f2e..8adc5d6b67be 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -96,8 +96,8 @@ internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable ? CultureInfo.InvariantCulture : CultureInfo.CurrentCulture; - var minString = rangeAttribute.Minimum.ToString(); - var maxString = rangeAttribute.Maximum.ToString(); + var minString = string.Format(targetCulture, "{0}", rangeAttribute.Minimum); + var maxString = string.Format(targetCulture, "{0}", rangeAttribute.Maximum); if (decimal.TryParse(minString, NumberStyles.Any, targetCulture, out var minDecimal)) { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs index 6c4febb6beec..4693176aedc3 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Globalization; using System.Reflection; using System.Runtime.Loader; using System.Text; @@ -196,8 +197,7 @@ void OnEntryPointExit(Exception exception) var service = services.GetService(serviceType) ?? throw new InvalidOperationException("Could not resolve IDocumentProvider service."); using var stream = new MemoryStream(); - var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); - using var writer = new StreamWriter(stream, encoding, bufferSize: 1024, leaveOpen: true); + using var writer = new FormattingStreamWriter(stream, CultureInfo.InvariantCulture) { AutoFlush = true }; var targetMethod = serviceType.GetMethod("GenerateAsync", [typeof(string), typeof(TextWriter)]) ?? throw new InvalidOperationException("Could not resolve GenerateAsync method."); targetMethod.Invoke(service, ["v1", writer]); stream.Position = 0; From 6c00eab51b4183f63fe6a108c2fe24dddbbd0f4e Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 5 Jun 2025 10:16:33 +0100 Subject: [PATCH 07/13] Fix snapshot tests Make OpenAPI sample endpoints version-specific. --- src/OpenApi/sample/Program.cs | 59 ++++++++++++++++--- .../OpenApiDocumentIntegrationTests.cs | 5 +- ...ment_documentName=controllers.verified.txt | 4 +- ...piDocument_documentName=forms.verified.txt | 4 +- ...cument_documentName=responses.verified.txt | 4 +- ...t_documentName=schemas-by-ref.verified.txt | 4 +- ...enApiDocument_documentName=v1.verified.txt | 4 +- ...enApiDocument_documentName=v2.verified.txt | 4 +- ...nApiDocument_documentName=xml.verified.txt | 4 +- ...ment_documentName=controllers.verified.txt | 2 +- ...piDocument_documentName=forms.verified.txt | 2 +- ...cument_documentName=responses.verified.txt | 2 +- ...t_documentName=schemas-by-ref.verified.txt | 2 +- ...enApiDocument_documentName=v1.verified.txt | 2 +- ...enApiDocument_documentName=v2.verified.txt | 2 +- ...nApiDocument_documentName=xml.verified.txt | 2 +- 16 files changed, 76 insertions(+), 30 deletions(-) diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index 65739eb211ce..508a10c9e1b6 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -24,20 +24,65 @@ options.AddHeader("X-Version", "1.0"); options.AddDocumentTransformer(); }); -builder.Services.AddOpenApi("v2", options => { +builder.Services.AddOpenApi("v2", options => +{ options.AddSchemaTransformer(); options.AddOperationTransformer(); options.AddDocumentTransformer(new AddContactTransformer()); - options.AddDocumentTransformer((document, context, token) => { + options.AddDocumentTransformer((document, context, token) => + { document.Info.License = new OpenApiLicense { Name = "MIT" }; return Task.CompletedTask; }); }); -builder.Services.AddOpenApi("controllers"); -builder.Services.AddOpenApi("responses"); -builder.Services.AddOpenApi("forms"); -builder.Services.AddOpenApi("schemas-by-ref"); -builder.Services.AddOpenApi("xml"); + +var versions = new[] +{ + OpenApiSpecVersion.OpenApi3_0, + OpenApiSpecVersion.OpenApi3_1, +}; + +var documentNames = new[] +{ + "controllers", + "responses", + "forms", + "schemas-by-ref", + "xml", +}; + +foreach (var version in versions) +{ + builder.Services.AddOpenApi($"v1-{version}", options => + { + options.OpenApiVersion = version; + options.ShouldInclude = (description) => description.GroupName == null || description.GroupName == "v1"; + options.AddHeader("X-Version", "1.0"); + options.AddDocumentTransformer(); + }); + builder.Services.AddOpenApi($"v2-{version}", options => + { + options.OpenApiVersion = version; + options.ShouldInclude = (description) => description.GroupName == null || description.GroupName == "v2"; + options.AddSchemaTransformer(); + options.AddOperationTransformer(); + options.AddDocumentTransformer(new AddContactTransformer()); + options.AddDocumentTransformer((document, context, token) => + { + document.Info.License = new OpenApiLicense { Name = "MIT" }; + return Task.CompletedTask; + }); + }); + + foreach (var name in documentNames) + { + builder.Services.AddOpenApi($"{name}-{version}", options => + { + options.OpenApiVersion = version; + options.ShouldInclude = (description) => description.GroupName == null || description.GroupName == name; + }); + } +} var app = builder.Build(); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs index 094535d767f1..82cac17f664a 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs @@ -35,12 +35,13 @@ public static TheoryData OpenApiDocuments() [MemberData(nameof(OpenApiDocuments))] public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion version) { + var versionString = version.ToString(); using var client = fixture.CreateClient(); - var json = await client.GetStringAsync($"/openapi/{documentName}.json"); + var json = await client.GetStringAsync($"/openapi/{documentName}-{versionString}.json"); var baseSnapshotsDirectory = SkipOnHelixAttribute.OnHelix() ? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "Integration", "snapshots") : "snapshots"; - var outputDirectory = Path.Combine(baseSnapshotsDirectory, version.ToString()); + var outputDirectory = Path.Combine(baseSnapshotsDirectory, versionString); await Verify(json) .UseDirectory(outputDirectory) .UseParameters(documentName); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 22a4b1afa8cc..a10698391ea9 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -1,7 +1,7 @@ { - "openapi": "3.1.1", + "openapi": "3.0.4", "info": { - "title": "Sample | controllers", + "title": "Sample | controllers-openapi3_0", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index dd9c09845ae7..42ab8ec83af3 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt @@ -1,7 +1,7 @@ { - "openapi": "3.1.1", + "openapi": "3.0.4", "info": { - "title": "Sample | forms", + "title": "Sample | forms-openapi3_0", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index b7d8de460ab6..d0822b305a77 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -1,7 +1,7 @@ { - "openapi": "3.1.1", + "openapi": "3.0.4", "info": { - "title": "Sample | responses", + "title": "Sample | responses-openapi3_0", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 3bf506348815..faa88bcb0d4e 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -1,7 +1,7 @@ { - "openapi": "3.1.1", + "openapi": "3.0.4", "info": { - "title": "Sample | schemas-by-ref", + "title": "Sample | schemas-by-ref-openapi3_0", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt index da59605957a1..4dd0d79e8a60 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt @@ -1,7 +1,7 @@ { - "openapi": "3.1.1", + "openapi": "3.0.4", "info": { - "title": "Sample | v1", + "title": "Sample | v1-openapi3_0", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt index 4fcf63a9a628..290a0e6e25da 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt @@ -1,7 +1,7 @@ { - "openapi": "3.1.1", + "openapi": "3.0.4", "info": { - "title": "Sample | v2", + "title": "Sample | v2-openapi3_0", "contact": { "name": "OpenAPI Enthusiast", "email": "iloveopenapi@example.com" diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt index 162ca0870436..6c530bd4f319 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt @@ -1,7 +1,7 @@ { - "openapi": "3.1.1", + "openapi": "3.0.4", "info": { - "title": "Sample | xml", + "title": "Sample | xml-openapi3_0", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 22a4b1afa8cc..be8c79d8ce55 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -1,7 +1,7 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | controllers", + "title": "Sample | controllers-openapi3_1", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index dd9c09845ae7..b46bf04499fc 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt @@ -1,7 +1,7 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | forms", + "title": "Sample | forms-openapi3_1", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index b7d8de460ab6..c028db1ccc27 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -1,7 +1,7 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | responses", + "title": "Sample | responses-openapi3_1", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 3bf506348815..cef6da414a0c 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -1,7 +1,7 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | schemas-by-ref", + "title": "Sample | schemas-by-ref-openapi3_1", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt index da59605957a1..fba1d8824c49 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt @@ -1,7 +1,7 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | v1", + "title": "Sample | v1-openapi3_1", "version": "1.0.0" }, "servers": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt index 4fcf63a9a628..023ddfe89265 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt @@ -1,7 +1,7 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | v2", + "title": "Sample | v2-openapi3_1", "contact": { "name": "OpenAPI Enthusiast", "email": "iloveopenapi@example.com" diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt index 162ca0870436..04d6e1f8ac51 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt @@ -1,7 +1,7 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | xml", + "title": "Sample | xml-openapi3_1", "version": "1.0.0" }, "servers": [ From 41e899f982e98b473b286c178323ec6ddbf821a8 Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 5 Jun 2025 10:16:53 +0100 Subject: [PATCH 08/13] Refactor method Use `Convert.ToString()` instead of a format string. --- src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 8adc5d6b67be..4f6c7ed17635 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -96,8 +96,8 @@ internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable ? CultureInfo.InvariantCulture : CultureInfo.CurrentCulture; - var minString = string.Format(targetCulture, "{0}", rangeAttribute.Minimum); - var maxString = string.Format(targetCulture, "{0}", rangeAttribute.Maximum); + var minString = Convert.ToString(rangeAttribute.Minimum, targetCulture); + var maxString = Convert.ToString(rangeAttribute.Maximum, targetCulture); if (decimal.TryParse(minString, NumberStyles.Any, targetCulture, out var minDecimal)) { From 7e732bfe2a741d47db18a6734d8b5f46eb351a8f Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 6 Jun 2025 14:08:55 +0100 Subject: [PATCH 09/13] Support exclusive(Minimum|Maximum) - Add support for emitting `exclusive(Minimum|Maximum)` when `RangeAttribute.(Minimum|Maximum)IsExclusive` is set. - Add tests for `[Range]` attribute handling. --- .../Extensions/JsonNodeSchemaExtensions.cs | 4 +- .../src/Schemas/OpenApiJsonSchema.Helpers.cs | 10 ++ .../src/Schemas/OpenApiSchemaKeywords.cs | 2 + .../JsonNodeSchemaExtensionsTests.cs | 133 ++++++++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 4f6c7ed17635..47950144fd14 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -101,11 +101,11 @@ internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable if (decimal.TryParse(minString, NumberStyles.Any, targetCulture, out var minDecimal)) { - schema[OpenApiSchemaKeywords.MinimumKeyword] = minDecimal; + schema[rangeAttribute.MinimumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMinimum : OpenApiSchemaKeywords.MinimumKeyword] = minDecimal; } if (decimal.TryParse(maxString, NumberStyles.Any, targetCulture, out var maxDecimal)) { - schema[OpenApiSchemaKeywords.MaximumKeyword] = maxDecimal; + schema[rangeAttribute.MaximumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMaximum : OpenApiSchemaKeywords.MaximumKeyword] = maxDecimal; } } else if (attribute is RegularExpressionAttribute regularExpressionAttribute) diff --git a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs index 6633ddac3f65..3b0937db091e 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -260,11 +260,21 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName, var minimum = reader.GetDecimal(); schema.Minimum = minimum.ToString(CultureInfo.InvariantCulture); break; + case OpenApiSchemaKeywords.ExclusiveMinimum: + reader.Read(); + var exclusiveMinimum = reader.GetDecimal(); + schema.ExclusiveMinimum = exclusiveMinimum.ToString(CultureInfo.InvariantCulture); + break; case OpenApiSchemaKeywords.MaximumKeyword: reader.Read(); var maximum = reader.GetDecimal(); schema.Maximum = maximum.ToString(CultureInfo.InvariantCulture); break; + case OpenApiSchemaKeywords.ExclusiveMaximum: + reader.Read(); + var exclusiveMaximum = reader.GetDecimal(); + schema.ExclusiveMaximum = exclusiveMaximum.ToString(CultureInfo.InvariantCulture); + break; case OpenApiSchemaKeywords.PatternKeyword: reader.Read(); var pattern = reader.GetString(); diff --git a/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs b/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs index 255cfae73c1c..84f27500d135 100644 --- a/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs +++ b/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs @@ -19,7 +19,9 @@ internal class OpenApiSchemaKeywords public const string MaxLengthKeyword = "maxLength"; public const string PatternKeyword = "pattern"; public const string MinimumKeyword = "minimum"; + public const string ExclusiveMinimum = "exclusiveMinimum"; public const string MaximumKeyword = "maximum"; + public const string ExclusiveMaximum = "exclusiveMaximum"; public const string MinItemsKeyword = "minItems"; public const string MaxItemsKeyword = "maxItems"; public const string RefKeyword = "$ref"; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs new file mode 100644 index 000000000000..2cbc45d84cf7 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace Microsoft.AspNetCore.OpenApi.Tests; + +public static class JsonNodeSchemaExtensionsTests +{ + public static TheoryData TestCases() + { + bool[] isExclusive = [false, true]; + + string[] invariantOrEnglishCultures = + [ + string.Empty, + "en", + "en-AU", + "en-GB", + "en-US", + ]; + + string[] commaForDecimalCultures = + [ + "de-DE", + "fr-FR", + "sv-SE", + ]; + + Type[] fractionNumberTypes = + [ + typeof(float), + typeof(double), + typeof(decimal), + ]; + + var testCases = new TheoryData(); + + foreach (var culture in invariantOrEnglishCultures) + { + foreach (var exclusive in isExclusive) + { + testCases.Add(culture, exclusive, new(1, 1234) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1", "1234"); + testCases.Add(culture, exclusive, new(1d, 1234d) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1", "1234"); + testCases.Add(culture, exclusive, new(1.23, 4.56) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1.23", "4.56"); + + foreach (var type in fractionNumberTypes) + { + testCases.Add(culture, exclusive, new(type, "1.23", "4.56") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1.23", "4.56"); + testCases.Add(culture, exclusive, new(type, "1.23", "4.56") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive, ParseLimitsInInvariantCulture = true }, "1.23", "4.56"); + } + } + } + + foreach (var culture in commaForDecimalCultures) + { + foreach (var exclusive in isExclusive) + { + testCases.Add(culture, exclusive, new(1, 1234) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1", "1234"); + testCases.Add(culture, exclusive, new(1d, 1234d) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1", "1234"); + testCases.Add(culture, exclusive, new(1.23, 4.56) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1.23", "4.56"); + + foreach (var type in fractionNumberTypes) + { + testCases.Add(culture, exclusive, new(type, "1,23", "4,56") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1.23", "4.56"); + testCases.Add(culture, exclusive, new(type, "1.23", "4.56") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive, ParseLimitsInInvariantCulture = true }, "1.23", "4.56"); + } + } + } + + // Numbers using numeric format, such as with thousands separators + testCases.Add("en-GB", false, new(typeof(float), "-12,445.7", "12,445.7"), "-12445.7", "12445.7"); + testCases.Add("fr-FR", false, new(typeof(float), "-12 445,7", "12 445,7"), "-12445.7", "12445.7"); + testCases.Add("sv-SE", false, new(typeof(float), "-12 445,7", "12 445,7"), "-12445.7", "12445.7"); + + // Decimal value that would lose precision if parsed as a float or double + foreach (var exclusive in isExclusive) + { + testCases.Add("en-US", exclusive, new(typeof(decimal), "12345678901234567890.123456789", "12345678901234567890.123456789") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "12345678901234567890.123456789", "12345678901234567890.123456789"); + testCases.Add("en-US", exclusive, new(typeof(decimal), "12345678901234567890.123456789", "12345678901234567890.123456789") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive, ParseLimitsInInvariantCulture = true }, "12345678901234567890.123456789", "12345678901234567890.123456789"); + } + + return testCases; + } + + [Theory] + [MemberData(nameof(TestCases))] + public static void ApplyValidationAttributes_Handles_RangeAttribute_Correctly( + string cultureName, + bool isExclusive, + RangeAttribute rangeAttribute, + string expectedMinimum, + string expectedMaximum) + { + // Arrange + var minimum = decimal.Parse(expectedMinimum, CultureInfo.InvariantCulture); + var maximum = decimal.Parse(expectedMaximum, CultureInfo.InvariantCulture); + + var schema = new JsonObject(); + + // Act + var previous = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + + schema.ApplyValidationAttributes([rangeAttribute]); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + + // Assert + if (isExclusive) + { + Assert.Equal(minimum, schema["exclusiveMinimum"].GetValue()); + Assert.Equal(maximum, schema["exclusiveMaximum"].GetValue()); + Assert.False(schema.TryGetPropertyValue("minimum", out _)); + Assert.False(schema.TryGetPropertyValue("maximum", out _)); + } + else + { + Assert.Equal(minimum, schema["minimum"].GetValue()); + Assert.Equal(maximum, schema["maximum"].GetValue()); + Assert.False(schema.TryGetPropertyValue("exclusiveMinimum", out _)); + Assert.False(schema.TryGetPropertyValue("exclusiveMaximum", out _)); + } + } +} From 4ebfa21eafdcae5981d0bce4b7202ebbf4eaa3ef Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 6 Jun 2025 14:20:28 +0100 Subject: [PATCH 10/13] Avoid string parsing Do not round trip the values from `[Range]` if they were set using `RangeAttribute(int, int)`. --- .../Extensions/JsonNodeSchemaExtensions.cs | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 47950144fd14..53c67cd0ab95 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -90,22 +90,42 @@ internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable } else if (attribute is RangeAttribute rangeAttribute) { - // Use InvariantCulture if explicitly requested or if the range has been set via the - // RangeAttribute(double, double) or RangeAttribute(int, int) constructors. - var targetCulture = rangeAttribute.ParseLimitsInInvariantCulture || rangeAttribute.Minimum is double || rangeAttribute.Maximum is int - ? CultureInfo.InvariantCulture - : CultureInfo.CurrentCulture; + decimal? minDecimal = null; + decimal? maxDecimal = null; - var minString = Convert.ToString(rangeAttribute.Minimum, targetCulture); - var maxString = Convert.ToString(rangeAttribute.Maximum, targetCulture); + if (rangeAttribute.Minimum is int minimumInteger) + { + // The range was set with the RangeAttribute(int, int) constructor. + minDecimal = minimumInteger; + maxDecimal = (int)rangeAttribute.Maximum; + } + else + { + // Use InvariantCulture if explicitly requested or if the range has been set via the RangeAttribute(double, double) constructor. + var targetCulture = rangeAttribute.ParseLimitsInInvariantCulture || rangeAttribute.Minimum is double x + ? CultureInfo.InvariantCulture + : CultureInfo.CurrentCulture; + + var minString = Convert.ToString(rangeAttribute.Minimum, targetCulture); + var maxString = Convert.ToString(rangeAttribute.Maximum, targetCulture); + + if (decimal.TryParse(minString, NumberStyles.Any, targetCulture, out var value)) + { + minDecimal = value; + } + if (decimal.TryParse(maxString, NumberStyles.Any, targetCulture, out value)) + { + maxDecimal = value; + } + } - if (decimal.TryParse(minString, NumberStyles.Any, targetCulture, out var minDecimal)) + if (minDecimal is { } minValue) { - schema[rangeAttribute.MinimumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMinimum : OpenApiSchemaKeywords.MinimumKeyword] = minDecimal; + schema[rangeAttribute.MinimumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMinimum : OpenApiSchemaKeywords.MinimumKeyword] = minValue; } - if (decimal.TryParse(maxString, NumberStyles.Any, targetCulture, out var maxDecimal)) + if (maxDecimal is { } maxValue) { - schema[rangeAttribute.MaximumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMaximum : OpenApiSchemaKeywords.MaximumKeyword] = maxDecimal; + schema[rangeAttribute.MaximumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMaximum : OpenApiSchemaKeywords.MaximumKeyword] = maxValue; } } else if (attribute is RegularExpressionAttribute regularExpressionAttribute) From 7849027430760fc1b96647cc8658253bda4fdb19 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 6 Jun 2025 14:21:07 +0100 Subject: [PATCH 11/13] Remove unused local Leftover from refactoring. --- src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 53c67cd0ab95..c41a2d3441b0 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -102,7 +102,7 @@ internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable else { // Use InvariantCulture if explicitly requested or if the range has been set via the RangeAttribute(double, double) constructor. - var targetCulture = rangeAttribute.ParseLimitsInInvariantCulture || rangeAttribute.Minimum is double x + var targetCulture = rangeAttribute.ParseLimitsInInvariantCulture || rangeAttribute.Minimum is double ? CultureInfo.InvariantCulture : CultureInfo.CurrentCulture; From cb9da2bb7b32fa74fd3aba0090bd3955d0c3ad9c Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 6 Jun 2025 14:23:21 +0100 Subject: [PATCH 12/13] Add test for invalid ranges Add test to ensure no exception is thrown if a range contains invalid string values. --- .../Extensions/JsonNodeSchemaExtensionsTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs index 2cbc45d84cf7..bf4126735a9d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs @@ -130,4 +130,21 @@ public static void ApplyValidationAttributes_Handles_RangeAttribute_Correctly( Assert.False(schema.TryGetPropertyValue("exclusiveMaximum", out _)); } } + + [Fact] + public static void ApplyValidationAttributes_Handles_Invalid_RangeAttribute_Values() + { + // Arrange + var rangeAttribute = new RangeAttribute(typeof(int), "foo", "bar"); + var schema = new JsonObject(); + + // Act + schema.ApplyValidationAttributes([rangeAttribute]); + + // Assert + Assert.False(schema.TryGetPropertyValue("minimum", out _)); + Assert.False(schema.TryGetPropertyValue("maximum", out _)); + Assert.False(schema.TryGetPropertyValue("exclusiveMinimum", out _)); + Assert.False(schema.TryGetPropertyValue("exclusiveMaximum", out _)); + } } From 1ffc0cd01b7cec65ac3df6a4fac9b9826a984e57 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 1 Jul 2025 07:28:45 +0100 Subject: [PATCH 13/13] Revert submodule Rebase fail. --- src/submodules/googletest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/submodules/googletest b/src/submodules/googletest index 175c1b55cfb3..a45468c0fcbe 160000 --- a/src/submodules/googletest +++ b/src/submodules/googletest @@ -1 +1 @@ -Subproject commit 175c1b55cfb3dbed519b94a370c083aac605009f +Subproject commit a45468c0fcbeda1588573a7d8283b320bf9970cb