diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index feb32d33b8e0..48c14321c062 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Constraints; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.OpenApi; @@ -170,7 +171,8 @@ internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable /// The produced by the underlying schema generator. /// An object representing the associated with the default value. /// The associated with the target type. - internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValue, JsonTypeInfo? jsonTypeInfo) + /// The logger to use for warning messages when default value type mismatches occur. + internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValue, JsonTypeInfo? jsonTypeInfo, ILogger? logger = null) { if (jsonTypeInfo is null) { @@ -183,7 +185,16 @@ internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValu } else { - schema[OpenApiSchemaKeywords.DefaultKeyword] = JsonSerializer.SerializeToNode(defaultValue, jsonTypeInfo); + try + { + schema[OpenApiSchemaKeywords.DefaultKeyword] = JsonSerializer.SerializeToNode(defaultValue, jsonTypeInfo); + } + catch (Exception ex) when (ex is InvalidCastException or NotSupportedException or InvalidOperationException) + { + // Log warning when there's a type mismatch that prevents serialization + logger?.DefaultValueTypeMismatch(defaultValue.GetType().Name, jsonTypeInfo.Type.Name); + // Do not apply the default value when there's a type mismatch + } } } @@ -305,7 +316,8 @@ internal static void ApplyRouteConstraints(this JsonNode schema, IEnumerableThe produced by the underlying schema generator. /// The associated with the . /// The associated with the . - internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescription parameterDescription, JsonTypeInfo? jsonTypeInfo) + /// The logger to use for warning messages when default value type mismatches occur. + internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescription parameterDescription, JsonTypeInfo? jsonTypeInfo, ILogger? logger = null) { // This is special handling for parameters that are not bound from the body but represented in a complex type. // For example: @@ -338,11 +350,11 @@ internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescri { if (parameterInfo.HasDefaultValue) { - schema.ApplyDefaultValue(parameterInfo.DefaultValue, jsonTypeInfo); + schema.ApplyDefaultValue(parameterInfo.DefaultValue, jsonTypeInfo, logger); } else if (parameterInfo.GetCustomAttributes().LastOrDefault() is { } defaultValueAttribute) { - schema.ApplyDefaultValue(defaultValueAttribute.Value, jsonTypeInfo); + schema.ApplyDefaultValue(defaultValueAttribute.Value, jsonTypeInfo, logger); } if (parameterInfo.GetCustomAttributes() is { } validationAttributes) diff --git a/src/OpenApi/src/Extensions/OpenApiLoggingExtensions.cs b/src/OpenApi/src/Extensions/OpenApiLoggingExtensions.cs new file mode 100644 index 000000000000..516b39b97c5f --- /dev/null +++ b/src/OpenApi/src/Extensions/OpenApiLoggingExtensions.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.OpenApi; + +internal static partial class OpenApiLoggingExtensions +{ + [LoggerMessage(1, LogLevel.Warning, "Failed to apply default value for parameter due to type mismatch. Default value type: '{DefaultValueType}', Parameter type: '{ParameterType}'. Default value will be omitted from the OpenAPI schema.", EventName = "DefaultValueTypeMismatch")] + public static partial void DefaultValueTypeMismatch(this ILogger logger, string defaultValueType, string parameterType); +} \ No newline at end of file diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 10737cbd5f20..8abae3195383 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.OpenApi; @@ -28,7 +29,8 @@ namespace Microsoft.AspNetCore.OpenApi; internal sealed class OpenApiSchemaService( [ServiceKey] string documentName, IOptions jsonOptions, - IOptionsMonitor optionsMonitor) + IOptionsMonitor optionsMonitor, + ILogger? logger = null) { private readonly ConcurrentDictionary _schemaIdCache = new(); private readonly OpenApiJsonSchemaContext _jsonSchemaContext = new(new(jsonOptions.Value.SerializerOptions)); @@ -105,7 +107,7 @@ internal sealed class OpenApiSchemaService( } if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DefaultValueAttribute defaultValueAttribute) { - schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); + schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo, logger); } if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute descriptionAttribute) { @@ -125,7 +127,7 @@ internal async Task GetOrCreateUnresolvedSchemaAsync(OpenApiDocum var schemaAsJsonObject = CreateSchema(key); if (parameterDescription is not null) { - schemaAsJsonObject.ApplyParameterInfo(parameterDescription, _jsonSerializerOptions.GetTypeInfo(type)); + schemaAsJsonObject.ApplyParameterInfo(parameterDescription, _jsonSerializerOptions.GetTypeInfo(type), logger); } // Use _jsonSchemaContext constructed from _jsonSerializerOptions to respect shared config set by end-user, // particularly in the case of maxDepth. diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs index 9ab90d9f52a0..69dc5d698842 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs @@ -20,6 +20,8 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Constraints; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Moq; @@ -88,7 +90,8 @@ internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuild // Set strict number handling by default to make integer type checks more straightforward jsonOptions.SerializerOptions.NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.Strict; - var schemaService = new OpenApiSchemaService("Test", Options.Create(jsonOptions), openApiOptions.Object); + var logger = builder.ServiceProvider.GetService>() ?? NullLogger.Instance; + var schemaService = new OpenApiSchemaService("Test", Options.Create(jsonOptions), openApiOptions.Object, logger); ((TestServiceProvider)builder.ServiceProvider).TestSchemaService = schemaService; var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, openApiOptions.Object, builder.ServiceProvider, new OpenApiTestServer()); ((TestServiceProvider)builder.ServiceProvider).TestDocumentService = documentService; @@ -134,7 +137,8 @@ internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuild defaultJsonOptions.SerializerOptions.NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.Strict; var jsonOptions = builder.ServiceProvider.GetService>() ?? Options.Create(defaultJsonOptions); - var schemaService = new OpenApiSchemaService("Test", jsonOptions, options.Object); + var logger = builder.ServiceProvider.GetService>() ?? NullLogger.Instance; + var schemaService = new OpenApiSchemaService("Test", jsonOptions, options.Object, logger); ((TestServiceProvider)builder.ServiceProvider).TestSchemaService = schemaService; var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, options.Object, builder.ServiceProvider, new OpenApiTestServer()); ((TestServiceProvider)builder.ServiceProvider).TestDocumentService = documentService; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs index ede9a0b3d1b2..6156b2190c30 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; public partial class OpenApiSchemaServiceTests : OpenApiDocumentServiceTestBase { @@ -642,6 +643,50 @@ await VerifyOpenApiDocument(builder, document => }); } + public static object[][] RouteParametersWithDefaultValueTypeMismatch => + [ + // F# scenarios where type mismatch causes InvalidCastException and logging should occur + [([DefaultValue(10)] ulong id) => { }, (Action)((logMessages) => + { + Assert.Single(logMessages); + Assert.Contains("Failed to apply default value for parameter due to type mismatch", logMessages[0]); + Assert.Contains("Default value type: 'Int32', Parameter type: 'UInt64'", logMessages[0]); + })], + [([DefaultValue(10u)] ulong id) => { }, (Action)((logMessages) => + { + Assert.Single(logMessages); + Assert.Contains("Failed to apply default value for parameter due to type mismatch", logMessages[0]); + Assert.Contains("Default value type: 'UInt32', Parameter type: 'UInt64'", logMessages[0]); + })], + ]; + + [Theory] + [MemberData(nameof(RouteParametersWithDefaultValueTypeMismatch))] + public async Task GetOpenApiParameters_LogsWarningForDefaultValueTypeMismatch(Delegate requestHandler, Action assertLogMessages) + { + // Arrange + var logMessages = new List(); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(new TestLoggerProvider(logMessages)); + serviceCollection.AddLogging(); + + var builder = CreateBuilder(serviceCollection); + builder.MapGet("/api/{id}", requestHandler); + + // Act & Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/{id}"].Operations[HttpMethod.Get]; + var parameter = Assert.Single(operation.Parameters); + + // Verify that no default value is set when there's a type mismatch + Assert.Null(parameter.Schema.Default); + + // Verify the warning log was emitted + assertLogMessages(logMessages.ToArray()); + }); + } + public struct CustomType { } public class CustomTypeConverter : JsonConverter @@ -890,6 +935,48 @@ public override void Write(Utf8JsonWriter writer, EnumArrayType value, JsonSeria } } + public class TestLoggerProvider : ILoggerProvider + { + private readonly List _logMessages; + + public TestLoggerProvider(List logMessages) + { + _logMessages = logMessages; + } + + public ILogger CreateLogger(string categoryName) + { + return new TestLogger(_logMessages); + } + + public void Dispose() { } + } + + public class TestLogger : ILogger + { + private readonly List _logMessages; + + public TestLogger(List logMessages) + { + _logMessages = logMessages; + } + + public IDisposable BeginScope(TState state) => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + _logMessages.Add(formatter(state, exception)); + } + + private class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + public void Dispose() { } + } + } + [ApiController] [Route("[controller]/[action]")] private class TestFromQueryController : ControllerBase