diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index fdc398987a35..79784263fb0f 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 GetCurrentWeather() + { + 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..b5dab6bc157f 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -1,7 +1,9 @@ // 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; using Microsoft.OpenApi.Models; using Sample.Transformers; @@ -24,23 +26,94 @@ 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(); +// 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/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 777913ccc567..d1c4d6f007d6 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 = rangeAttribute.Minimum.ToString(); - var maxString = rangeAttribute.Maximum.ToString(); + 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 + ? 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[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[OpenApiSchemaKeywords.MaximumKeyword] = maxDecimal; + schema[rangeAttribute.MaximumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMaximum : OpenApiSchemaKeywords.MaximumKeyword] = maxValue; } } else if (attribute is RegularExpressionAttribute regularExpressionAttribute) 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/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.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; 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..bf4126735a9d --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonNodeSchemaExtensionsTests.cs @@ -0,0 +1,150 @@ +// 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 _)); + } + } + + [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 _)); + } +} 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..82cac17f664a 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs @@ -2,41 +2,47 @@ // 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; -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); - var scopedServiceProvider = fixture.Services.CreateScope(); - var document = await documentService.GetOpenApiDocumentAsync(scopedServiceProvider.ServiceProvider); - var json = await document.SerializeAsJsonAsync(version); + var versionString = version.ToString(); + using var client = fixture.CreateClient(); + 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()); - await Verifier.Verify(json) + 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 ce5cd62b4ecf..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,9 +1,14 @@ { "openapi": "3.0.4", "info": { - "title": "Sample | controllers", + "title": "Sample | controllers-openapi3_0", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/getbyidandname/{id}/{name}": { "get": { @@ -105,10 +110,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_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..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,9 +1,14 @@ { "openapi": "3.0.4", "info": { - "title": "Sample | forms", + "title": "Sample | forms-openapi3_0", "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..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,9 +1,14 @@ { "openapi": "3.0.4", "info": { - "title": "Sample | responses", + "title": "Sample | responses-openapi3_0", "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..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,9 +1,14 @@ { "openapi": "3.0.4", "info": { - "title": "Sample | schemas-by-ref", + "title": "Sample | schemas-by-ref-openapi3_0", "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..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,9 +1,14 @@ { "openapi": "3.0.4", "info": { - "title": "Sample | v1", + "title": "Sample | v1-openapi3_0", "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..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.0.4", "info": { - "title": "Sample | v2", + "title": "Sample | v2-openapi3_0", "contact": { "name": "OpenAPI Enthusiast", "email": "iloveopenapi@example.com" @@ -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..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,9 +1,14 @@ { "openapi": "3.0.4", "info": { - "title": "Sample | xml", + "title": "Sample | xml-openapi3_0", "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 efb88cb71d5e..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,9 +1,14 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | controllers", + "title": "Sample | controllers-openapi3_1", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/getbyidandname/{id}/{name}": { "get": { @@ -105,10 +110,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=forms.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index c68e4d17c64d..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,9 +1,14 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | forms", + "title": "Sample | forms-openapi3_1", "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..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,9 +1,14 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | responses", + "title": "Sample | responses-openapi3_1", "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..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,9 +1,14 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | schemas-by-ref", + "title": "Sample | schemas-by-ref-openapi3_1", "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..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,9 +1,14 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | v1", + "title": "Sample | v1-openapi3_1", "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..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" @@ -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..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,9 +1,14 @@ { "openapi": "3.1.1", "info": { - "title": "Sample | xml", + "title": "Sample | xml-openapi3_1", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { "/xml/type-with-examples": { "get": { 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/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 {