Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/OpenApi/sample/Controllers/TestController.cs
Original file line number Diff line number Diff line change
@@ -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]
@@ -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);
}
16 changes: 14 additions & 2 deletions src/OpenApi/sample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using System.Text.Json.Serialization;
using Sample.Transformers;

@@ -23,11 +24,13 @@
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;
});
@@ -37,6 +40,15 @@
builder.Services.AddOpenApi("forms");
builder.Services.AddOpenApi("schemas-by-ref");
builder.Services.AddOpenApi("xml");
builder.Services.AddOpenApi("localized", options =>
{
options.ShouldInclude = _ => true;
options.AddDocumentTransformer((document, context, token) =>
{
document.Info.Description = $"This is a localized OpenAPI document for {CultureInfo.CurrentUICulture.NativeName}.";
return Task.CompletedTask;
});
});

var app = builder.Build();

42 changes: 31 additions & 11 deletions src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs
Original file line number Diff line number Diff line change
@@ -90,22 +90,42 @@ internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable
}
else if (attribute is RangeAttribute rangeAttribute)
{
// Use InvariantCulture if explicitly requested or if the range has been set via the
// RangeAttribute(double, double) or RangeAttribute(int, int) constructors.
var targetCulture = rangeAttribute.ParseLimitsInInvariantCulture || rangeAttribute.Minimum is double || rangeAttribute.Maximum is int
? CultureInfo.InvariantCulture
: CultureInfo.CurrentCulture;
decimal? minDecimal = null;
decimal? maxDecimal = null;

var minString = 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)
Original file line number Diff line number Diff line change
@@ -52,7 +52,7 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e
var document = await documentService.GetOpenApiDocumentAsync(context.RequestServices, context.Request, context.RequestAborted);
var documentOptions = options.Get(lowercasedDocumentName);

using var textWriter = new Utf8BufferTextWriter();
using var textWriter = new Utf8BufferTextWriter(System.Globalization.CultureInfo.InvariantCulture);
textWriter.SetWriter(context.Response.BodyWriter);

string contentType;
10 changes: 10 additions & 0 deletions src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs
Original file line number Diff line number Diff line change
@@ -260,11 +260,21 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
var minimum = reader.GetDecimal();
schema.Minimum = minimum.ToString(CultureInfo.InvariantCulture);
break;
case OpenApiSchemaKeywords.ExclusiveMinimum:
reader.Read();
var exclusiveMinimum = reader.GetDecimal();
schema.ExclusiveMinimum = exclusiveMinimum.ToString(CultureInfo.InvariantCulture);
break;
case OpenApiSchemaKeywords.MaximumKeyword:
reader.Read();
var maximum = reader.GetDecimal();
schema.Maximum = maximum.ToString(CultureInfo.InvariantCulture);
break;
case OpenApiSchemaKeywords.ExclusiveMaximum:
reader.Read();
var exclusiveMaximum = reader.GetDecimal();
schema.ExclusiveMaximum = exclusiveMaximum.ToString(CultureInfo.InvariantCulture);
break;
case OpenApiSchemaKeywords.PatternKeyword:
reader.Read();
var pattern = reader.GetString();
2 changes: 2 additions & 0 deletions src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

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

var service = services.GetService(serviceType) ?? throw new InvalidOperationException("Could not resolve IDocumentProvider service.");
using var stream = new MemoryStream();
var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
using var writer = new StreamWriter(stream, encoding, bufferSize: 1024, leaveOpen: true);
using var writer = new FormattingStreamWriter(stream, CultureInfo.InvariantCulture) { AutoFlush = true };
var targetMethod = serviceType.GetMethod("GenerateAsync", [typeof(string), typeof(TextWriter)]) ?? throw new InvalidOperationException("Could not resolve GenerateAsync method.");
targetMethod.Invoke(service, ["v1", writer]);
stream.Position = 0;
Original file line number Diff line number Diff line change
@@ -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<string, bool, RangeAttribute, string, string> 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<string, bool, RangeAttribute, string, string>();

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<decimal>());
Assert.Equal(maximum, schema["exclusiveMaximum"].GetValue<decimal>());
Assert.False(schema.TryGetPropertyValue("minimum", out _));
Assert.False(schema.TryGetPropertyValue("maximum", out _));
}
else
{
Assert.Equal(minimum, schema["minimum"].GetValue<decimal>());
Assert.Equal(maximum, schema["maximum"].GetValue<decimal>());
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 _));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

// Runs requests with a culture that uses commas to format decimals to
// verify the invariant culture is used to generate the OpenAPI document.

public sealed class LocalizedSampleAppFixture : SampleAppFixture
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);

builder.ConfigureServices(services =>
{
services.AddTransient<IStartupFilter, AddLocalizationMiddlewareFilter>();
services.AddRequestLocalization((options) =>
{
options.DefaultRequestCulture = new("fr-FR");
options.SupportedCultures = [new("fr-FR")];
options.SupportedUICultures = [new("fr-FR")];
});
});
}

private sealed class AddLocalizationMiddlewareFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return (app) =>
{
app.UseRequestLocalization();
next(app);
};
}
}
}
Original file line number Diff line number Diff line change
@@ -4,26 +4,36 @@
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;

[UsesVerify]
public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) : IClassFixture<SampleAppFixture>
{
public static TheoryData<string, OpenApiSpecVersion> OpenApiDocuments()
{
OpenApiSpecVersion[] versions =
[
OpenApiSpecVersion.OpenApi3_0,
OpenApiSpecVersion.OpenApi3_1,
];

var testCases = new TheoryData<string, OpenApiSpecVersion>();

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<OpenApiDocumentService>(documentName);
@@ -34,7 +44,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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.InternalTesting;

[UsesVerify]
public sealed class OpenApiDocumentLocalizationTests(LocalizedSampleAppFixture fixture) : IClassFixture<LocalizedSampleAppFixture>
{
[Fact]
public async Task VerifyOpenApiDocumentIsInvariant()
{
using var client = fixture.CreateClient();
var json = await client.GetStringAsync("/openapi/localized.json");
var outputDirectory = SkipOnHelixAttribute.OnHelix()
? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "Integration", "snapshots")
: "snapshots";
await Verify(json)
.UseDirectory(outputDirectory);
}
}
Original file line number Diff line number Diff line change
@@ -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",
Original file line number Diff line number Diff line change
@@ -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",

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/SignalR/common/Shared/Utf8BufferTextWriter.cs
Original file line number Diff line number Diff line change
@@ -35,6 +35,12 @@ public Utf8BufferTextWriter()
_encoder = _utf8NoBom.GetEncoder();
}

public Utf8BufferTextWriter(IFormatProvider formatProvider)
: base(formatProvider)
{
_encoder = _utf8NoBom.GetEncoder();
}

public static Utf8BufferTextWriter Get(IBufferWriter<byte> bufferWriter)
{
var writer = _cachedInstance;
Original file line number Diff line number Diff line change
@@ -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
{