From e7047ace6e5d0bc402efc42eb0355c2450214050 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Thu, 7 Aug 2025 21:15:04 +0200 Subject: [PATCH 1/7] Add Handling of the special x-reference- attributes in the schema exporter --- .../src/Schemas/OpenApiJsonSchema.Helpers.cs | 11 +++++++ .../Services/Schemas/OpenApiSchemaService.cs | 29 +++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs index 0b660ceb1d66..22a19c2d0999 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -345,6 +345,17 @@ 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; + case OpenApiConstants.RefExampleAnnotation: + reader.Read(); + schema.Metadata ??= new Dictionary(); + schema.Metadata[OpenApiConstants.RefExampleAnnotation] = reader.GetString() ?? string.Empty; + break; + default: reader.Skip(); break; diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 2971ab2e9b6c..f81e619229ce 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -103,21 +103,32 @@ internal sealed class OpenApiSchemaService( } if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { - if (attributeProvider.GetCustomAttributes(inherit: false).OfType() is { } validationAttributes) + var isInlinedSchema = schema["x-schema-id"] is null; + if (isInlinedSchema) { - schema.ApplyValidationAttributes(validationAttributes); - } - if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DefaultValueAttribute defaultValueAttribute) - { - schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); + if (attributeProvider.GetCustomAttributes(inherit: false).OfType() is { } validationAttributes) + { + schema.ApplyValidationAttributes(validationAttributes); + } + if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DefaultValueAttribute defaultValueAttribute) + { + schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); + } + if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute descriptionAttribute) + { + schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; + } } - if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute descriptionAttribute) + else { - schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; + if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute descriptionAttribute) + { + schema[OpenApiConstants.RefDescriptionAnnotation] = descriptionAttribute.Description; + } } } - return schema; + return schema; } }; From 6cd0d414533ead0c3aa88ec118d8e9b6187a3f3c Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Thu, 7 Aug 2025 21:16:35 +0200 Subject: [PATCH 2/7] Add unit tests for schema annotations comming from [Description] attributes --- .../OpenApiSchemaService.Annotations.cs | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.Annotations.cs diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.Annotations.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.Annotations.cs new file mode 100644 index 000000000000..50a76fb76970 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.Annotations.cs @@ -0,0 +1,145 @@ +// 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.Net.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.Routing; + +public partial class OpenApiSchemaServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task SchemaDescriptions_HandlesSchemaReferences() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (DescribedReferencesDto dto) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/"].Operations[HttpMethod.Post]; + var requestBody = operation.RequestBody; + + Assert.NotNull(requestBody); + var content = Assert.Single(requestBody.Content); + Assert.Equal("application/json", content.Key); + Assert.NotNull(content.Value.Schema); + var schema = content.Value.Schema; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("child1", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("Property: DescribedReferencesDto.Child1", reference.Reference.Description); + }, + property => + { + Assert.Equal("child2", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Equal("Property: DescribedReferencesDto.Child2", reference.Reference.Description); + }, + property => + { + Assert.Equal("childNoDescription", property.Key); + var reference = Assert.IsType(property.Value); + Assert.Null(reference.Reference.Description); + }); + + var childSchema = document.Components.Schemas["DescribedChildDto"]; + // TODO: Handle descriptions on classes + // Assert.Equal("Class: DescribedChildDto", "DescribedChildDto"); + }); + + } + + [Description("Class: DescribedReferencesDto")] + public class DescribedReferencesDto + { + [Description("Property: DescribedReferencesDto.Child1")] + public DescribedChildDto Child1 { get; set; } + + [Description("Property: DescribedReferencesDto.Child2")] + public DescribedChildDto Child2 { get; set; } + + public DescribedChildDto ChildNoDescription { get; set; } + } + + [Description("Class: DescribedChildDto")] + public class DescribedChildDto + { + [Description("Property: DescribedChildDto.ChildValue")] + public string ChildValue { get; set; } + } + + [Fact] + public async Task SchemaDescriptions_HandlesInlinedSchemas() + { + // Arrange + var builder = CreateBuilder(); + + var options = new OpenApiOptions(); + var originalCreateSchemaReferenceId = options.CreateSchemaReferenceId; + options.CreateSchemaReferenceId = (x) => x.Type == typeof(DescribedInlinedDto) ? null : originalCreateSchemaReferenceId(x); + + // Act + builder.MapPost("/", (DescribedInlinedSchemasDto dto) => { }); + + // Assert + await VerifyOpenApiDocument(builder, options, document => + { + var operation = document.Paths["/"].Operations[HttpMethod.Post]; + var requestBody = operation.RequestBody; + + Assert.NotNull(requestBody); + var content = Assert.Single(requestBody.Content); + Assert.Equal("application/json", content.Key); + Assert.NotNull(content.Value.Schema); + var schema = content.Value.Schema; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("inlined1", property.Key); + var inlinedSchema = Assert.IsType(property.Value); + Assert.Equal("Property: DescribedInlinedSchemasDto.Inlined1", inlinedSchema.Description); + }, + property => + { + Assert.Equal("inlined2", property.Key); + var inlinedSchema = Assert.IsType(property.Value); + Assert.Equal("Property: DescribedInlinedSchemasDto.Inlined2", inlinedSchema.Description); + }, + property => + { + Assert.Equal("inlinedNoDescription", property.Key); + var inlinedSchema = Assert.IsType(property.Value); + // TODO: Handle descriptions on classes + // Assert.Equal("Class: DescribedInlinedDto", inlinedSchema.Description); + }); + }); + } + + [Description("Class: DescribedInlinedSchemasDto")] + public class DescribedInlinedSchemasDto + { + [Description("Property: DescribedInlinedSchemasDto.Inlined1")] + public DescribedInlinedDto Inlined1 { get; set; } + + [Description("Property: DescribedInlinedSchemasDto.Inlined2")] + public DescribedInlinedDto Inlined2 { get; set; } + + public DescribedInlinedDto InlinedNoDescription { get; set; } + } + + [Description("Class: DescribedInlinedDto")] + public class DescribedInlinedDto + { + [Description("Property: DescribedInlinedDto.ChildValue")] + public string ChildValue { get; set; } + } +} From 3bafc2a0378b1f44fe6ddc52b9eb05abfdc61bda Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Thu, 7 Aug 2025 21:17:40 +0200 Subject: [PATCH 3/7] Add applying annotations with [Description] attributes for types --- src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs | 6 +++++- .../OpenApiSchemaService.Annotations.cs | 8 +++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index f81e619229ce..e3d0999414ac 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -101,6 +101,10 @@ internal sealed class OpenApiSchemaService( { schema.ApplyNullabilityContextInfo(jsonPropertyInfo); } + if (context.TypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute typeDescriptionAttribute) + { + schema[OpenApiSchemaKeywords.DescriptionKeyword] = typeDescriptionAttribute.Description; + } if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { var isInlinedSchema = schema["x-schema-id"] is null; @@ -128,7 +132,7 @@ internal sealed class OpenApiSchemaService( } } - return schema; + return schema; } }; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.Annotations.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.Annotations.cs index 50a76fb76970..f4bb0a5aa4e7 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.Annotations.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.Annotations.cs @@ -50,9 +50,8 @@ await VerifyOpenApiDocument(builder, document => Assert.Null(reference.Reference.Description); }); - var childSchema = document.Components.Schemas["DescribedChildDto"]; - // TODO: Handle descriptions on classes - // Assert.Equal("Class: DescribedChildDto", "DescribedChildDto"); + var referencedSchema = document.Components.Schemas["DescribedChildDto"]; + Assert.Equal("Class: DescribedChildDto", referencedSchema.Description); }); } @@ -118,8 +117,7 @@ await VerifyOpenApiDocument(builder, options, document => { Assert.Equal("inlinedNoDescription", property.Key); var inlinedSchema = Assert.IsType(property.Value); - // TODO: Handle descriptions on classes - // Assert.Equal("Class: DescribedInlinedDto", inlinedSchema.Description); + Assert.Equal("Class: DescribedInlinedDto", inlinedSchema.Description); }); }); } From 91e890ae599f42f569ab8b2f293401ae9f535d28 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Thu, 7 Aug 2025 21:36:50 +0200 Subject: [PATCH 4/7] Remove magic strings and simplify null checks for attributes --- .../src/Services/Schemas/OpenApiSchemaService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index e3d0999414ac..cd9aa1200710 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -101,31 +101,31 @@ internal sealed class OpenApiSchemaService( { schema.ApplyNullabilityContextInfo(jsonPropertyInfo); } - if (context.TypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute typeDescriptionAttribute) + if (context.TypeInfo.Type.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } typeDescriptionAttribute) { schema[OpenApiSchemaKeywords.DescriptionKeyword] = typeDescriptionAttribute.Description; } if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { - var isInlinedSchema = schema["x-schema-id"] is null; + var isInlinedSchema = schema[OpenApiConstants.SchemaId] is null; if (isInlinedSchema) { if (attributeProvider.GetCustomAttributes(inherit: false).OfType() is { } validationAttributes) { schema.ApplyValidationAttributes(validationAttributes); } - if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DefaultValueAttribute defaultValueAttribute) + if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } defaultValueAttribute) { schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); } - if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute descriptionAttribute) + if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } descriptionAttribute) { schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; } } else { - if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute descriptionAttribute) + if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } descriptionAttribute) { schema[OpenApiConstants.RefDescriptionAnnotation] = descriptionAttribute.Description; } From d78e47e93b4fd40ce0667cebcee297d3428632e8 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Thu, 7 Aug 2025 21:41:41 +0200 Subject: [PATCH 5/7] Retrieve the property attributes once when transforming schema nodes --- src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index cd9aa1200710..a4e0ed79dc3b 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -107,25 +107,26 @@ internal sealed class OpenApiSchemaService( } if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { + var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false); var isInlinedSchema = schema[OpenApiConstants.SchemaId] is null; if (isInlinedSchema) { - if (attributeProvider.GetCustomAttributes(inherit: false).OfType() is { } validationAttributes) + if (propertyAttributes.OfType() is { } validationAttributes) { schema.ApplyValidationAttributes(validationAttributes); } - if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } defaultValueAttribute) + if (propertyAttributes.OfType().LastOrDefault() is { } defaultValueAttribute) { schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); } - if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } descriptionAttribute) + if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) { schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; } } else { - if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is { } descriptionAttribute) + if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) { schema[OpenApiConstants.RefDescriptionAnnotation] = descriptionAttribute.Description; } From 7ff6c0df6b0dac14077580ea0da2048c28ee59c4 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Fri, 8 Aug 2025 22:20:02 +0200 Subject: [PATCH 6/7] Remove deserialization of special x-ref-example as it's not used in that code path. --- 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 22a19c2d0999..28b8de3eaa89 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.RefDescriptionAnnotation] = reader.GetString() ?? string.Empty; break; - case OpenApiConstants.RefExampleAnnotation: - reader.Read(); - schema.Metadata ??= new Dictionary(); - schema.Metadata[OpenApiConstants.RefExampleAnnotation] = reader.GetString() ?? string.Empty; - break; default: reader.Skip(); From 4af724cc5a7986c7d22941e898a34c24f37a7e0c Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Sat, 9 Aug 2025 10:44:57 +0200 Subject: [PATCH 7/7] Limit the isInlinedSchema check to the OpenApi annotation "description" --- .../src/Services/Schemas/OpenApiSchemaService.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index a4e0ed79dc3b..bb2fb3d4fc12 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -108,17 +108,17 @@ internal sealed class OpenApiSchemaService( if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false); + if (propertyAttributes.OfType() is { } validationAttributes) + { + 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() is { } validationAttributes) - { - schema.ApplyValidationAttributes(validationAttributes); - } - if (propertyAttributes.OfType().LastOrDefault() is { } defaultValueAttribute) - { - schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); - } if (propertyAttributes.OfType().LastOrDefault() is { } descriptionAttribute) { schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description;