From d27777c83164adf21df1ca60bc836d3e82bbf493 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Tue, 26 Aug 2025 17:19:21 +0200 Subject: [PATCH 1/4] Move Schema Annotations handling to a schema transformer --- .../Extensions/OpenApiDocumentExtensions.cs | 3 + src/OpenApi/src/Services/OpenApiConstants.cs | 1 + src/OpenApi/src/Services/OpenApiOptions.cs | 3 +- .../Services/Schemas/OpenApiSchemaService.cs | 23 ------- ...riptionDataAnnotationsSchemaTransformer.cs | 66 +++++++++++++++++++ 5 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs diff --git a/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs b/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs index c09bd50dc67b..78e0f50e489f 100644 --- a/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs @@ -26,16 +26,19 @@ public static IOpenApiSchema AddOpenApiSchemaByReference(this OpenApiDocument do object? description = null; object? example = null; + object? defaultValue = null; if (schema is OpenApiSchema actualSchema) { actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefDescriptionAnnotation, out description); actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefExampleAnnotation, out example); + actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefDefaultAnnotation, out defaultValue); } return new OpenApiSchemaReference(schemaId, document) { Description = description as string, Examples = example is JsonNode exampleJson ? [exampleJson] : null, + Default = defaultValue is JsonNode defaultValueJson ? defaultValueJson : null, }; } } diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index df4228633556..433e71573eb9 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -13,6 +13,7 @@ internal static class OpenApiConstants internal const string DescriptionId = "x-aspnetcore-id"; internal const string SchemaId = "x-schema-id"; internal const string RefId = "x-ref-id"; + internal const string RefDefaultAnnotation = "x-ref-default"; internal const string RefDescriptionAnnotation = "x-ref-description"; internal const string RefExampleAnnotation = "x-ref-example"; internal const string RefKeyword = "$ref"; diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs index 6c0936515307..00b31c1253b5 100644 --- a/src/OpenApi/src/Services/OpenApiOptions.cs +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.OpenApi.Services.Schemas.Transformers; namespace Microsoft.AspNetCore.OpenApi; @@ -14,7 +15,7 @@ public sealed class OpenApiOptions { internal readonly List DocumentTransformers = []; internal readonly List OperationTransformers = []; - internal readonly List SchemaTransformers = []; + internal readonly List SchemaTransformers = [new DescriptionDataAnnotationsSchemaTransformer()]; /// /// A default implementation for creating a schema reference ID for a given . diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 12b0c1ed996c..8af192488add 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -101,10 +101,6 @@ internal sealed class OpenApiSchemaService( { schema.ApplyNullabilityContextInfo(jsonPropertyInfo); } - if (context.TypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } typeDescriptionAttribute) - { - schema[OpenApiSchemaKeywords.DescriptionKeyword] = typeDescriptionAttribute.Description; - } if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false); @@ -112,25 +108,6 @@ internal sealed class OpenApiSchemaService( { schema.ApplyValidationAttributes(validationAttributes); } - if (propertyAttributes.OfType().LastOrDefault() is { } defaultValueAttribute) - { - schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); - } - var isInlinedSchema = schema[OpenApiConstants.SchemaId] is null; - if (isInlinedSchema) - { - if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) - { - schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; - } - } - else - { - if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) - { - schema[OpenApiConstants.RefDescriptionAnnotation] = descriptionAttribute.Description; - } - } } schema.PruneNullTypeForComponentizedTypes(); return schema; diff --git a/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs b/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs new file mode 100644 index 000000000000..ddc1ffe6b4b7 --- /dev/null +++ b/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs @@ -0,0 +1,66 @@ +// 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; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.AspNetCore.OpenApi.Services.Schemas.Transformers; + +internal class DescriptionDataAnnotationsSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + schema.Metadata ??= new Dictionary(); + var isInlinedSchema = !schema.Metadata.ContainsKey(OpenApiConstants.SchemaId) || string.IsNullOrEmpty(schema.Metadata[OpenApiConstants.SchemaId] as string); + if (context.JsonTypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } typeDescriptionAttribute) + { + schema.Description = typeDescriptionAttribute.Description; + } + + if (context.JsonPropertyInfo?.AttributeProvider?.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } propertyDescriptionAttribute) + { + if (isInlinedSchema) + { + schema.Description = propertyDescriptionAttribute.Description; + } + else + { + schema.Metadata![OpenApiConstants.RefDescriptionAnnotation] = propertyDescriptionAttribute.Description; + } + } + + if (context.JsonTypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } typeDefaultValueAttribute) + { + schema.Default = GetDefaultValueAsJsonNode(typeDefaultValueAttribute, context.JsonTypeInfo); + } + + if (context.JsonPropertyInfo?.AttributeProvider?.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } propertyDefaultValueAttribute) + { + var defaultValueJson = GetDefaultValueAsJsonNode(propertyDefaultValueAttribute, context.JsonTypeInfo); + if (isInlinedSchema) + { + schema.Default = defaultValueJson; + } + else + { + schema.Metadata![OpenApiConstants.RefDefaultAnnotation] = defaultValueJson!; + } + } + + return Task.CompletedTask; + } + + private static JsonNode? GetDefaultValueAsJsonNode(DefaultValueAttribute defaultValueAttribute, JsonTypeInfo jsonTypeInfo) + { + if(defaultValueAttribute.Value is null) + { + return null; + } + + return JsonSerializer.SerializeToNode(defaultValueAttribute.Value, jsonTypeInfo); + } +} From cfc70d07a2af46272fcae932ccf0f1c02714ba0c Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Tue, 26 Aug 2025 17:24:51 +0200 Subject: [PATCH 2/4] Cleanup raw OpenApiJson schema reading. Remove special attribute RefDescription as it's in a transformer --- src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs index 877ac70010db..2050578f9f21 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -350,11 +350,6 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName, schema.Metadata ??= new Dictionary(); schema.Metadata[OpenApiConstants.RefId] = reader.GetString() ?? string.Empty; break; - case OpenApiConstants.RefDescriptionAnnotation: - reader.Read(); - schema.Metadata ??= new Dictionary(); - schema.Metadata[OpenApiConstants.RefDescriptionAnnotation] = reader.GetString() ?? string.Empty; - break; default: reader.Skip(); From f91659b78d5e2f51c46a24f4512bf7f1c61b6c6d Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Tue, 26 Aug 2025 22:56:36 +0200 Subject: [PATCH 3/4] Fix tests and lint errors --- .../Services/Schemas/OpenApiSchemaService.cs | 1 - ...riptionDataAnnotationsSchemaTransformer.cs | 1 - .../Transformers/OpenApiOptionsTests.cs | 22 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 8af192488add..141011e40d5a 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; -using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.IO.Pipelines; diff --git a/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs b/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs index ddc1ffe6b4b7..e6352802dbd0 100644 --- a/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs +++ b/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs @@ -1,7 +1,6 @@ // 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; using System.Linq; using System.Text.Json; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OpenApiOptionsTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OpenApiOptionsTests.cs index 5a55dbfd35d5..eb29f8f5304a 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OpenApiOptionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OpenApiOptionsTests.cs @@ -10,6 +10,8 @@ public void AddDocumentTransformer_WithDocumentTransformerDelegate() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); + var transformer = new Func((document, context, cancellationToken) => { document.Info.Title = "New Title"; @@ -32,6 +34,8 @@ public void AddDocumentTransformer_WithDocumentTransformerInstance() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); + var transformer = new TestOpenApiDocumentTransformer(); // Act @@ -50,6 +54,7 @@ public void AddDocumentTransformer_WithDocumentTransformerType() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); // Act var result = options.AddDocumentTransformer(); @@ -67,6 +72,8 @@ public void AddOperationTransformer_WithOperationTransformerDelegate() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); + var transformer = new Func((operation, context, cancellationToken) => { operation.Description = "New Description"; @@ -89,6 +96,8 @@ public void AddOperationTransformer_WithOperationTransformerInstance() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); + var transformer = new TestOpenApiOperationTransformer(); // Act @@ -107,6 +116,7 @@ public void AddOperationTransformer_WithOperationTransformerType() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); // Act var result = options.AddOperationTransformer(); @@ -124,6 +134,8 @@ public void AddSchemaTransformer_WithSchemaTransformerDelegate() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); + var transformer = new Func((schema, context, cancellationToken) => { schema.Description = "New Description"; @@ -146,6 +158,8 @@ public void AddSchemaTransformer_WithSchemaTransformerInstance() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); + var transformer = new TestOpenApiSchemaTransformer(); // Act @@ -164,6 +178,7 @@ public void AddSchemaTransformer_WithSchemaTransformerType() { // Arrange var options = new OpenApiOptions(); + RemoveBuiltInTransformers(options); // Act var result = options.AddSchemaTransformer(); @@ -176,6 +191,13 @@ public void AddSchemaTransformer_WithSchemaTransformerType() Assert.Empty(options.OperationTransformers); } + private static void RemoveBuiltInTransformers(OpenApiOptions options) + { + options.DocumentTransformers.Clear(); + options.OperationTransformers.Clear(); + options.SchemaTransformers.Clear(); + } + private class TestOpenApiDocumentTransformer : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) From 41625e927cfff1b08a5cf1d3fcfa441ff083db0b Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Sat, 30 Aug 2025 23:33:10 +0200 Subject: [PATCH 4/4] Rename DescriptionDataAnnotationsSchemaTransformer to AttributeAnnotationsSchemaTransformer --- src/OpenApi/src/Services/OpenApiOptions.cs | 2 +- ...aTransformer.cs => AttributeAnnotationsSchemaTransformer.cs} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/OpenApi/src/Services/Schemas/Transformers/{DescriptionDataAnnotationsSchemaTransformer.cs => AttributeAnnotationsSchemaTransformer.cs} (96%) diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs index 00b31c1253b5..7de3693d2964 100644 --- a/src/OpenApi/src/Services/OpenApiOptions.cs +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -15,7 +15,7 @@ public sealed class OpenApiOptions { internal readonly List DocumentTransformers = []; internal readonly List OperationTransformers = []; - internal readonly List SchemaTransformers = [new DescriptionDataAnnotationsSchemaTransformer()]; + internal readonly List SchemaTransformers = [new AttributeAnnotationsSchemaTransformer()]; /// /// A default implementation for creating a schema reference ID for a given . diff --git a/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs b/src/OpenApi/src/Services/Schemas/Transformers/AttributeAnnotationsSchemaTransformer.cs similarity index 96% rename from src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs rename to src/OpenApi/src/Services/Schemas/Transformers/AttributeAnnotationsSchemaTransformer.cs index e6352802dbd0..23a808d49a69 100644 --- a/src/OpenApi/src/Services/Schemas/Transformers/DescriptionDataAnnotationsSchemaTransformer.cs +++ b/src/OpenApi/src/Services/Schemas/Transformers/AttributeAnnotationsSchemaTransformer.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.OpenApi.Services.Schemas.Transformers; -internal class DescriptionDataAnnotationsSchemaTransformer : IOpenApiSchemaTransformer +internal class AttributeAnnotationsSchemaTransformer : IOpenApiSchemaTransformer { public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) {