Skip to content

[OpenAPI] Use invariant culture for TextWriter #62193

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
9 changes: 9 additions & 0 deletions src/OpenApi/sample/Controllers/TestController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ public IActionResult PostForm([FromForm] MvcTodo todo)
return Ok(todo);
}

[HttpGet]
[Route("/getcultureinvariant")]
public Ok<CurrentWeather> GetCurrentWeather()
{
return TypedResults.Ok(new CurrentWeather(1.0f));
}

public class RouteParamsContainer
{
[FromRoute]
Expand All @@ -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);
}
87 changes: 80 additions & 7 deletions src/OpenApi/sample/Program.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -24,23 +26,94 @@
options.AddHeader("X-Version", "1.0");
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
});
builder.Services.AddOpenApi("v2", options => {
builder.Services.AddOpenApi("v2", options =>
{
options.AddSchemaTransformer<AddExternalDocsTransformer>();
options.AddOperationTransformer<AddExternalDocsTransformer>();
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<BearerSecuritySchemeTransformer>();
});
builder.Services.AddOpenApi($"v2-{version}", options =>
{
options.OpenApiVersion = version;
options.ShouldInclude = (description) => description.GroupName == null || description.GroupName == "v2";
options.AddSchemaTransformer<AddExternalDocsTransformer>();
options.AddOperationTransformer<AddExternalDocsTransformer>();
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())
Expand Down
42 changes: 31 additions & 11 deletions src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading