Skip to content

Commit 0d0cfaa

Browse files
[OpenAPI] Use invariant culture for TextWriter (#62193)
Co-authored-by: Sjoerd van der Meer <[email protected]>
1 parent 9fd8cd6 commit 0d0cfaa

16 files changed

+2243
-33
lines changed

src/OpenApi/sample/Controllers/TestController.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ public IActionResult PostForm([FromForm] MvcTodo todo)
3232
return Ok(todo);
3333
}
3434

35+
[HttpGet]
36+
[Route("/getcultureinvariant")]
37+
public Ok<CurrentWeather> GetCurrentWeather()
38+
{
39+
return TypedResults.Ok(new CurrentWeather(1.0f));
40+
}
41+
3542
public class RouteParamsContainer
3643
{
3744
[FromRoute]
@@ -44,4 +51,6 @@ public class RouteParamsContainer
4451
}
4552

4653
public record MvcTodo(string Title, string Description, bool IsCompleted);
54+
55+
public record CurrentWeather([property: Range(-100.5f, 100.5f)] float Temperature = 0.1f);
4756
}

src/OpenApi/sample/Program.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Globalization;
45
using System.Text.Json.Serialization;
56
using Sample.Transformers;
67

@@ -23,11 +24,13 @@
2324
options.AddHeader("X-Version", "1.0");
2425
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
2526
});
26-
builder.Services.AddOpenApi("v2", options => {
27+
builder.Services.AddOpenApi("v2", options =>
28+
{
2729
options.AddSchemaTransformer<AddExternalDocsTransformer>();
2830
options.AddOperationTransformer<AddExternalDocsTransformer>();
2931
options.AddDocumentTransformer(new AddContactTransformer());
30-
options.AddDocumentTransformer((document, context, token) => {
32+
options.AddDocumentTransformer((document, context, token) =>
33+
{
3134
document.Info.License = new OpenApiLicense { Name = "MIT" };
3235
return Task.CompletedTask;
3336
});
@@ -37,6 +40,15 @@
3740
builder.Services.AddOpenApi("forms");
3841
builder.Services.AddOpenApi("schemas-by-ref");
3942
builder.Services.AddOpenApi("xml");
43+
builder.Services.AddOpenApi("localized", options =>
44+
{
45+
options.ShouldInclude = _ => true;
46+
options.AddDocumentTransformer((document, context, token) =>
47+
{
48+
document.Info.Description = $"This is a localized OpenAPI document for {CultureInfo.CurrentUICulture.NativeName}.";
49+
return Task.CompletedTask;
50+
});
51+
});
4052

4153
var app = builder.Build();
4254

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,22 +90,42 @@ internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable
9090
}
9191
else if (attribute is RangeAttribute rangeAttribute)
9292
{
93-
// Use InvariantCulture if explicitly requested or if the range has been set via the
94-
// RangeAttribute(double, double) or RangeAttribute(int, int) constructors.
95-
var targetCulture = rangeAttribute.ParseLimitsInInvariantCulture || rangeAttribute.Minimum is double || rangeAttribute.Maximum is int
96-
? CultureInfo.InvariantCulture
97-
: CultureInfo.CurrentCulture;
93+
decimal? minDecimal = null;
94+
decimal? maxDecimal = null;
9895

99-
var minString = rangeAttribute.Minimum.ToString();
100-
var maxString = rangeAttribute.Maximum.ToString();
96+
if (rangeAttribute.Minimum is int minimumInteger)
97+
{
98+
// The range was set with the RangeAttribute(int, int) constructor.
99+
minDecimal = minimumInteger;
100+
maxDecimal = (int)rangeAttribute.Maximum;
101+
}
102+
else
103+
{
104+
// Use InvariantCulture if explicitly requested or if the range has been set via the RangeAttribute(double, double) constructor.
105+
var targetCulture = rangeAttribute.ParseLimitsInInvariantCulture || rangeAttribute.Minimum is double
106+
? CultureInfo.InvariantCulture
107+
: CultureInfo.CurrentCulture;
108+
109+
var minString = Convert.ToString(rangeAttribute.Minimum, targetCulture);
110+
var maxString = Convert.ToString(rangeAttribute.Maximum, targetCulture);
111+
112+
if (decimal.TryParse(minString, NumberStyles.Any, targetCulture, out var value))
113+
{
114+
minDecimal = value;
115+
}
116+
if (decimal.TryParse(maxString, NumberStyles.Any, targetCulture, out value))
117+
{
118+
maxDecimal = value;
119+
}
120+
}
101121

102-
if (decimal.TryParse(minString, NumberStyles.Any, targetCulture, out var minDecimal))
122+
if (minDecimal is { } minValue)
103123
{
104-
schema[OpenApiSchemaKeywords.MinimumKeyword] = minDecimal;
124+
schema[rangeAttribute.MinimumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMinimum : OpenApiSchemaKeywords.MinimumKeyword] = minValue;
105125
}
106-
if (decimal.TryParse(maxString, NumberStyles.Any, targetCulture, out var maxDecimal))
126+
if (maxDecimal is { } maxValue)
107127
{
108-
schema[OpenApiSchemaKeywords.MaximumKeyword] = maxDecimal;
128+
schema[rangeAttribute.MaximumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMaximum : OpenApiSchemaKeywords.MaximumKeyword] = maxValue;
109129
}
110130
}
111131
else if (attribute is RegularExpressionAttribute regularExpressionAttribute)

src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e
5252
var document = await documentService.GetOpenApiDocumentAsync(context.RequestServices, context.Request, context.RequestAborted);
5353
var documentOptions = options.Get(lowercasedDocumentName);
5454

55-
using var textWriter = new Utf8BufferTextWriter();
55+
using var textWriter = new Utf8BufferTextWriter(System.Globalization.CultureInfo.InvariantCulture);
5656
textWriter.SetWriter(context.Response.BodyWriter);
5757

5858
string contentType;

src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,21 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
260260
var minimum = reader.GetDecimal();
261261
schema.Minimum = minimum.ToString(CultureInfo.InvariantCulture);
262262
break;
263+
case OpenApiSchemaKeywords.ExclusiveMinimum:
264+
reader.Read();
265+
var exclusiveMinimum = reader.GetDecimal();
266+
schema.ExclusiveMinimum = exclusiveMinimum.ToString(CultureInfo.InvariantCulture);
267+
break;
263268
case OpenApiSchemaKeywords.MaximumKeyword:
264269
reader.Read();
265270
var maximum = reader.GetDecimal();
266271
schema.Maximum = maximum.ToString(CultureInfo.InvariantCulture);
267272
break;
273+
case OpenApiSchemaKeywords.ExclusiveMaximum:
274+
reader.Read();
275+
var exclusiveMaximum = reader.GetDecimal();
276+
schema.ExclusiveMaximum = exclusiveMaximum.ToString(CultureInfo.InvariantCulture);
277+
break;
268278
case OpenApiSchemaKeywords.PatternKeyword:
269279
reader.Read();
270280
var pattern = reader.GetString();

src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ internal class OpenApiSchemaKeywords
1919
public const string MaxLengthKeyword = "maxLength";
2020
public const string PatternKeyword = "pattern";
2121
public const string MinimumKeyword = "minimum";
22+
public const string ExclusiveMinimum = "exclusiveMinimum";
2223
public const string MaximumKeyword = "maximum";
24+
public const string ExclusiveMaximum = "exclusiveMaximum";
2325
public const string MinItemsKeyword = "minItems";
2426
public const string MaxItemsKeyword = "maxItems";
2527
public const string RefKeyword = "$ref";

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics;
5+
using System.Globalization;
56
using System.Reflection;
67
using System.Runtime.Loader;
78
using System.Text;
@@ -196,8 +197,7 @@ void OnEntryPointExit(Exception exception)
196197

197198
var service = services.GetService(serviceType) ?? throw new InvalidOperationException("Could not resolve IDocumentProvider service.");
198199
using var stream = new MemoryStream();
199-
var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
200-
using var writer = new StreamWriter(stream, encoding, bufferSize: 1024, leaveOpen: true);
200+
using var writer = new FormattingStreamWriter(stream, CultureInfo.InvariantCulture) { AutoFlush = true };
201201
var targetMethod = serviceType.GetMethod("GenerateAsync", [typeof(string), typeof(TextWriter)]) ?? throw new InvalidOperationException("Could not resolve GenerateAsync method.");
202202
targetMethod.Invoke(service, ["v1", writer]);
203203
stream.Position = 0;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.ComponentModel.DataAnnotations;
5+
using System.Globalization;
6+
using System.Text.Json.Nodes;
7+
8+
namespace Microsoft.AspNetCore.OpenApi.Tests;
9+
10+
public static class JsonNodeSchemaExtensionsTests
11+
{
12+
public static TheoryData<string, bool, RangeAttribute, string, string> TestCases()
13+
{
14+
bool[] isExclusive = [false, true];
15+
16+
string[] invariantOrEnglishCultures =
17+
[
18+
string.Empty,
19+
"en",
20+
"en-AU",
21+
"en-GB",
22+
"en-US",
23+
];
24+
25+
string[] commaForDecimalCultures =
26+
[
27+
"de-DE",
28+
"fr-FR",
29+
"sv-SE",
30+
];
31+
32+
Type[] fractionNumberTypes =
33+
[
34+
typeof(float),
35+
typeof(double),
36+
typeof(decimal),
37+
];
38+
39+
var testCases = new TheoryData<string, bool, RangeAttribute, string, string>();
40+
41+
foreach (var culture in invariantOrEnglishCultures)
42+
{
43+
foreach (var exclusive in isExclusive)
44+
{
45+
testCases.Add(culture, exclusive, new(1, 1234) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1", "1234");
46+
testCases.Add(culture, exclusive, new(1d, 1234d) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1", "1234");
47+
testCases.Add(culture, exclusive, new(1.23, 4.56) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1.23", "4.56");
48+
49+
foreach (var type in fractionNumberTypes)
50+
{
51+
testCases.Add(culture, exclusive, new(type, "1.23", "4.56") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1.23", "4.56");
52+
testCases.Add(culture, exclusive, new(type, "1.23", "4.56") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive, ParseLimitsInInvariantCulture = true }, "1.23", "4.56");
53+
}
54+
}
55+
}
56+
57+
foreach (var culture in commaForDecimalCultures)
58+
{
59+
foreach (var exclusive in isExclusive)
60+
{
61+
testCases.Add(culture, exclusive, new(1, 1234) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1", "1234");
62+
testCases.Add(culture, exclusive, new(1d, 1234d) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1", "1234");
63+
testCases.Add(culture, exclusive, new(1.23, 4.56) { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1.23", "4.56");
64+
65+
foreach (var type in fractionNumberTypes)
66+
{
67+
testCases.Add(culture, exclusive, new(type, "1,23", "4,56") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "1.23", "4.56");
68+
testCases.Add(culture, exclusive, new(type, "1.23", "4.56") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive, ParseLimitsInInvariantCulture = true }, "1.23", "4.56");
69+
}
70+
}
71+
}
72+
73+
// Numbers using numeric format, such as with thousands separators
74+
testCases.Add("en-GB", false, new(typeof(float), "-12,445.7", "12,445.7"), "-12445.7", "12445.7");
75+
testCases.Add("fr-FR", false, new(typeof(float), "-12 445,7", "12 445,7"), "-12445.7", "12445.7");
76+
testCases.Add("sv-SE", false, new(typeof(float), "-12 445,7", "12 445,7"), "-12445.7", "12445.7");
77+
78+
// Decimal value that would lose precision if parsed as a float or double
79+
foreach (var exclusive in isExclusive)
80+
{
81+
testCases.Add("en-US", exclusive, new(typeof(decimal), "12345678901234567890.123456789", "12345678901234567890.123456789") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive }, "12345678901234567890.123456789", "12345678901234567890.123456789");
82+
testCases.Add("en-US", exclusive, new(typeof(decimal), "12345678901234567890.123456789", "12345678901234567890.123456789") { MaximumIsExclusive = exclusive, MinimumIsExclusive = exclusive, ParseLimitsInInvariantCulture = true }, "12345678901234567890.123456789", "12345678901234567890.123456789");
83+
}
84+
85+
return testCases;
86+
}
87+
88+
[Theory]
89+
[MemberData(nameof(TestCases))]
90+
public static void ApplyValidationAttributes_Handles_RangeAttribute_Correctly(
91+
string cultureName,
92+
bool isExclusive,
93+
RangeAttribute rangeAttribute,
94+
string expectedMinimum,
95+
string expectedMaximum)
96+
{
97+
// Arrange
98+
var minimum = decimal.Parse(expectedMinimum, CultureInfo.InvariantCulture);
99+
var maximum = decimal.Parse(expectedMaximum, CultureInfo.InvariantCulture);
100+
101+
var schema = new JsonObject();
102+
103+
// Act
104+
var previous = CultureInfo.CurrentCulture;
105+
106+
try
107+
{
108+
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName);
109+
110+
schema.ApplyValidationAttributes([rangeAttribute]);
111+
}
112+
finally
113+
{
114+
CultureInfo.CurrentCulture = previous;
115+
}
116+
117+
// Assert
118+
if (isExclusive)
119+
{
120+
Assert.Equal(minimum, schema["exclusiveMinimum"].GetValue<decimal>());
121+
Assert.Equal(maximum, schema["exclusiveMaximum"].GetValue<decimal>());
122+
Assert.False(schema.TryGetPropertyValue("minimum", out _));
123+
Assert.False(schema.TryGetPropertyValue("maximum", out _));
124+
}
125+
else
126+
{
127+
Assert.Equal(minimum, schema["minimum"].GetValue<decimal>());
128+
Assert.Equal(maximum, schema["maximum"].GetValue<decimal>());
129+
Assert.False(schema.TryGetPropertyValue("exclusiveMinimum", out _));
130+
Assert.False(schema.TryGetPropertyValue("exclusiveMaximum", out _));
131+
}
132+
}
133+
134+
[Fact]
135+
public static void ApplyValidationAttributes_Handles_Invalid_RangeAttribute_Values()
136+
{
137+
// Arrange
138+
var rangeAttribute = new RangeAttribute(typeof(int), "foo", "bar");
139+
var schema = new JsonObject();
140+
141+
// Act
142+
schema.ApplyValidationAttributes([rangeAttribute]);
143+
144+
// Assert
145+
Assert.False(schema.TryGetPropertyValue("minimum", out _));
146+
Assert.False(schema.TryGetPropertyValue("maximum", out _));
147+
Assert.False(schema.TryGetPropertyValue("exclusiveMinimum", out _));
148+
Assert.False(schema.TryGetPropertyValue("exclusiveMaximum", out _));
149+
}
150+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.AspNetCore.Hosting;
6+
using Microsoft.Extensions.DependencyInjection;
7+
8+
// Runs requests with a culture that uses commas to format decimals to
9+
// verify the invariant culture is used to generate the OpenAPI document.
10+
11+
public sealed class LocalizedSampleAppFixture : SampleAppFixture
12+
{
13+
protected override void ConfigureWebHost(IWebHostBuilder builder)
14+
{
15+
base.ConfigureWebHost(builder);
16+
17+
builder.ConfigureServices(services =>
18+
{
19+
services.AddTransient<IStartupFilter, AddLocalizationMiddlewareFilter>();
20+
services.AddRequestLocalization((options) =>
21+
{
22+
options.DefaultRequestCulture = new("fr-FR");
23+
options.SupportedCultures = [new("fr-FR")];
24+
options.SupportedUICultures = [new("fr-FR")];
25+
});
26+
});
27+
}
28+
29+
private sealed class AddLocalizationMiddlewareFilter : IStartupFilter
30+
{
31+
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
32+
{
33+
return (app) =>
34+
{
35+
app.UseRequestLocalization();
36+
next(app);
37+
};
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)