From 0be57ac9c8dfee0a4f6a5c2405521def328835fb 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 | 42 ++++++++++++------- ...ment_documentName=controllers.verified.txt | 31 ++++++++++++++ ...ment_documentName=controllers.verified.txt | 31 ++++++++++++++ .../common/Shared/Utf8BufferTextWriter.cs | 6 +++ 7 files changed, 131 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 cc1899e40482..cc1b6aaa1a54 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 Microsoft.OpenApi.Models; using Sample.Transformers; @@ -41,6 +42,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 95ef73102184..3404b554984c 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -54,7 +54,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 797b295c25a0..2929fff1800d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs @@ -6,26 +6,36 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; using Microsoft.OpenApi.Extensions; -using System.Text.RegularExpressions; [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); @@ -36,7 +46,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; From 2965ffa65fae966d1202e90dbd87ab1601879325 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 c67fd268503949dfc86ec9077045fab20b55436d 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 7bea1d44301bba2a44cfb367e1c5bfbda3d3a1a5 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 9d9326d2c53dde99bebe84b519d79b7ce675304e 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 | 9 ++------- ...OpenApiDocument_documentName=controllers.verified.txt | 7 ++++++- ...VerifyOpenApiDocument_documentName=forms.verified.txt | 7 ++++++- ...fyOpenApiDocument_documentName=responses.verified.txt | 7 ++++++- ...nApiDocument_documentName=schemas-by-ref.verified.txt | 7 ++++++- ...ts.VerifyOpenApiDocument_documentName=v1.verified.txt | 7 ++++++- ...ts.VerifyOpenApiDocument_documentName=v2.verified.txt | 7 ++++++- ...s.VerifyOpenApiDocument_documentName=xml.verified.txt | 7 ++++++- ...OpenApiDocument_documentName=controllers.verified.txt | 5 +++++ ...VerifyOpenApiDocument_documentName=forms.verified.txt | 5 +++++ ...fyOpenApiDocument_documentName=responses.verified.txt | 5 +++++ ...nApiDocument_documentName=schemas-by-ref.verified.txt | 5 +++++ ...ts.VerifyOpenApiDocument_documentName=v1.verified.txt | 5 +++++ ...ts.VerifyOpenApiDocument_documentName=v2.verified.txt | 5 +++++ ...s.VerifyOpenApiDocument_documentName=xml.verified.txt | 5 +++++ 15 files changed, 79 insertions(+), 14 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 2929fff1800d..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,10 +2,7 @@ // 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; -using Microsoft.OpenApi.Extensions; [UsesVerify] public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) : IClassFixture @@ -38,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 d512ac884eb9..f7c01bbc4a70 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 705e2527a13d..f7c01bbc4a70 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 d5724eb1ab6f3f30365b2e2c95b23f44780eca74 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 | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 777913ccc567..919675d580ab 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -97,8 +97,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 c2d09bdb5971..3f321dd217df 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; @@ -16,6 +17,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Writers; namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests; @@ -197,8 +199,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 6d64b726e47cac11c021338a041c423418aaa42f 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 | 60 ++++++++++++++++--- .../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, 77 insertions(+), 30 deletions(-) diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index cc1b6aaa1a54..b5dab6bc157f 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text.Json.Serialization; +using Microsoft.OpenApi; using Microsoft.OpenApi.Models; using Sample.Transformers; @@ -25,20 +26,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 f7c01bbc4a70..b14b690af447 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 f7c01bbc4a70..855f36fb8baa 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 ec9e760a1ab09f7e259d8f278e7d0285fb3fc0fd 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 919675d580ab..3fd345462819 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -97,8 +97,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 a1e87cac00321a560cf8b165dc83aa5239a5541e 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 3fd345462819..63cebbe87a0a 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -102,11 +102,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 1bf7ccd7921a..8f79bd7f2c1a 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -262,11 +262,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 ef75e0f4eaa4b931c5f404690991ff0798977746 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 63cebbe87a0a..adbb670bdc37 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -91,22 +91,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 8e60001a81e0745d0760c9138b109ad5b45bb0a9 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 adbb670bdc37..d1c4d6f007d6 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -103,7 +103,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 a8a50001e1d9005aaa895d64fa4772774899f426 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 fd357a56ed0a86e1b6698904db813d60393fb494 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 20 Jun 2025 09:42:01 +0100 Subject: [PATCH 13/13] Fix merge Remove now-redundant `using` statements. --- src/OpenApi/sample/Program.cs | 1 - .../SnapshotTestHelper.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index df7bdf0932d9..508a10c9e1b6 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Text.Json.Serialization; -using Microsoft.OpenApi; using Sample.Transformers; var builder = WebApplication.CreateBuilder(args); 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 5562c2dc70b5..4693176aedc3 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs @@ -16,7 +16,6 @@ using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.OpenApi.Writers; namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests;