Skip to content

Commit 8d2a495

Browse files
authored
Fix openapi schema xml comments handling for referenced schemas (#62213)
* Update OpenAPI range formatting to format using the target culture and Update tests to write in the InvariantCulture * Updated schema reference XML Comment handling. Update handling of nested schemas and referenced schemas * Only set a schema property description when it's not a schema reference Update XmlCommentGenerator.Emitter.cs to skip applying property descriptions when it's a schema reference. Schema transformers get the full schema and not the schema reference. So when the description is updated it would update it for all locations. * Snapshot the generated openapi as part of the OpenApi source generator tests * Uncomment and remove commented code * Rename emitted variable to isInlinedSchema * Add handling of XML Comments on properties with a schema reference with Metadata properties * Improve XmlComments SchemaReference tests and revert modified unrelated tests * Revert outputting OpenApi.json snapshots in the XmlSourceGenerator Tests * Fix dereference null warning because the compiler cannot analyze it from a boolean value * Fix null assignment warning * Add tests around <example> comments * Fix formatting of emitted code * Revert change to OpenApiSchemaService
1 parent 6905dbe commit 8d2a495

12 files changed

+940
-92
lines changed

src/OpenApi/gen/XmlCommentGenerator.Emitter.cs

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -449,17 +449,7 @@ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceP
449449
{
450450
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
451451
{
452-
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
453-
{
454-
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
455-
{
456-
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
457-
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
458-
{
459-
schema.Example = jsonString.Parse();
460-
}
461-
}
462-
}
452+
// Apply comments from the type
463453
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
464454
{
465455
schema.Description = typeComment.Summary;
@@ -468,6 +458,34 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
468458
schema.Example = jsonString.Parse();
469459
}
470460
}
461+
462+
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
463+
{
464+
// Apply comments from the property
465+
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
466+
{
467+
if (schema.Metadata is null
468+
|| !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
469+
|| string.IsNullOrEmpty(schemaId as string))
470+
{
471+
// Inlined schema
472+
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary!;
473+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
474+
{
475+
schema.Example = jsonString.Parse();
476+
}
477+
}
478+
else
479+
{
480+
// Schema Reference
481+
schema.Metadata["x-ref-description"] = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary!;
482+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
483+
{
484+
schema.Metadata["x-ref-example"] = jsonString.Parse()!;
485+
}
486+
}
487+
}
488+
}
471489
return Task.CompletedTask;
472490
}
473491
}
@@ -507,6 +525,7 @@ file static class GeneratedServiceCollectionExtensions
507525
{{GenerateAddOpenApiInterceptions(groupedAddOpenApiInvocations)}}
508526
}
509527
}
528+
510529
""";
511530

512531
internal static string GetAddOpenApiInterceptor(AddOpenApiOverloadVariant overloadVariant) => overloadVariant switch

src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Text.Json.Nodes;
5+
46
namespace Microsoft.AspNetCore.OpenApi;
57

68
internal static class OpenApiDocumentExtensions
@@ -21,6 +23,19 @@ public static IOpenApiSchema AddOpenApiSchemaByReference(this OpenApiDocument do
2123
document.Workspace ??= new();
2224
var location = document.BaseUri + "/components/schemas/" + schemaId;
2325
document.Workspace.RegisterComponentForDocument(document, schema, location);
24-
return new OpenApiSchemaReference(schemaId, document);
26+
27+
object? description = null;
28+
object? example = null;
29+
if (schema is OpenApiSchema actualSchema)
30+
{
31+
actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefDescriptionAnnotation, out description);
32+
actualSchema.Metadata?.TryGetValue(OpenApiConstants.RefExampleAnnotation, out example);
33+
}
34+
35+
return new OpenApiSchemaReference(schemaId, document)
36+
{
37+
Description = description as string,
38+
Examples = example is JsonNode exampleJson ? [exampleJson] : null,
39+
};
2540
}
2641
}

src/OpenApi/src/Services/OpenApiConstants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ internal static class OpenApiConstants
1313
internal const string DescriptionId = "x-aspnetcore-id";
1414
internal const string SchemaId = "x-schema-id";
1515
internal const string RefId = "x-ref-id";
16+
internal const string RefDescriptionAnnotation = "x-ref-description";
17+
internal const string RefExampleAnnotation = "x-ref-example";
1618
internal const string DefaultOpenApiResponseKey = "default";
1719
// Since there's a finite set of HTTP methods that can be included in a given
1820
// OpenApiPaths, we can pre-allocate an array of these methods and use a direct

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SchemaTests.cs

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Net.Http;
@@ -175,6 +175,7 @@ internal class User : IUser
175175
/// <inheritdoc/>
176176
public string Name { get; set; }
177177
}
178+
178179
""";
179180
var generator = new XmlCommentGenerator();
180181
await SnapshotTestHelper.Verify(source, generator, out var compilation);
@@ -260,4 +261,161 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
260261
Assert.Equal("The user's display name.", user.Properties["name"].Description);
261262
});
262263
}
264+
265+
[Fact]
266+
public async Task XmlCommentsOnPropertiesShouldApplyToSchemaReferences()
267+
{
268+
var source = """
269+
using System;
270+
using Microsoft.AspNetCore.Builder;
271+
using Microsoft.Extensions.DependencyInjection;
272+
273+
var builder = WebApplication.CreateBuilder();
274+
275+
builder.Services.AddOpenApi(options => {
276+
var prevCreateSchemaReferenceId = options.CreateSchemaReferenceId;
277+
options.CreateSchemaReferenceId = (x) => x.Type == typeof(ModelInline) ? null : prevCreateSchemaReferenceId(x);
278+
});
279+
280+
var app = builder.Build();
281+
282+
app.MapPost("/example", (RootModel model) => { });
283+
284+
app.Run();
285+
286+
/// <summary>
287+
/// Comment on class ModelWithSummary.
288+
/// </summary>
289+
/// <example>
290+
/// { "street": "ModelWithSummaryClass" }
291+
/// </example>
292+
public class ModelWithSummary
293+
{
294+
public string Street { get; set; }
295+
}
296+
297+
public class ModelWithoutSummary
298+
{
299+
public string Street { get; set; }
300+
}
301+
302+
/// <summary>
303+
/// Comment on class ModelInline.
304+
/// </summary>
305+
/// <example>
306+
/// { "street": "ModelInlineClass" }
307+
/// </example>
308+
public class ModelInline
309+
{
310+
public string Street { get; set; }
311+
}
312+
313+
/// <summary>
314+
/// Comment on class RootModel.
315+
/// </summary>
316+
/// <example>
317+
/// { }
318+
/// </example>
319+
public class RootModel
320+
{
321+
public ModelWithSummary NoPropertyComment { get; set; }
322+
323+
/// <summary>
324+
/// Comment on property ModelWithSummary1.
325+
/// </summary>
326+
/// <example>
327+
/// { "street": "ModelWithSummary1Prop" }
328+
/// </example>
329+
public ModelWithSummary ModelWithSummary1 { get; set; }
330+
331+
/// <summary>
332+
/// Comment on property ModelWithSummary2.
333+
/// </summary>
334+
/// <example>
335+
/// { "street": "ModelWithSummary2Prop" }
336+
/// </example>
337+
public ModelWithSummary ModelWithSummary2 { get; set; }
338+
339+
/// <summary>
340+
/// Comment on property ModelWithoutSummary1.
341+
/// </summary>
342+
/// <example>
343+
/// { "street": "ModelWithoutSummary1Prop" }
344+
/// </example>
345+
public ModelWithoutSummary ModelWithoutSummary1 { get; set; }
346+
347+
/// <summary>
348+
/// Comment on property ModelWithoutSummary2.
349+
/// </summary>
350+
/// <example>
351+
/// { "street": "ModelWithoutSummary2Prop" }
352+
/// </example>
353+
public ModelWithoutSummary ModelWithoutSummary2 { get; set; }
354+
355+
/// <summary>
356+
/// Comment on property ModelInline1.
357+
/// </summary>
358+
/// <example>
359+
/// { "street": "ModelInline1Prop" }
360+
/// </example>
361+
public ModelInline ModelInline1 { get; set; }
362+
363+
/// <summary>
364+
/// Comment on property ModelInline2.
365+
/// </summary>
366+
/// <example>
367+
/// { "street": "ModelInline2Prop" }
368+
/// </example>
369+
public ModelInline ModelInline2 { get; set; }
370+
}
371+
""";
372+
var generator = new XmlCommentGenerator();
373+
await SnapshotTestHelper.Verify(source, generator, out var compilation);
374+
await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
375+
{
376+
var path = document.Paths["/example"].Operations[HttpMethod.Post];
377+
var exampleOperationBodySchema = path.RequestBody.Content["application/json"].Schema;
378+
Assert.Equal("Comment on class RootModel.", exampleOperationBodySchema.Description);
379+
380+
var rootModelSchema = document.Components.Schemas["RootModel"];
381+
Assert.Equal("Comment on class RootModel.", rootModelSchema.Description);
382+
383+
var modelWithSummary = document.Components.Schemas["ModelWithSummary"];
384+
Assert.Equal("Comment on class ModelWithSummary.", modelWithSummary.Description);
385+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse("""{ "street": "ModelWithSummaryClass" }"""), modelWithSummary.Example));
386+
387+
var modelWithoutSummary = document.Components.Schemas["ModelWithoutSummary"];
388+
Assert.Null(modelWithoutSummary.Description);
389+
390+
Assert.DoesNotContain("ModelInline", document.Components.Schemas.Keys);
391+
392+
// Check RootModel properties
393+
var noPropertyCommentProp = Assert.IsType<OpenApiSchemaReference>(rootModelSchema.Properties["noPropertyComment"]);
394+
Assert.Null(noPropertyCommentProp.Reference.Description);
395+
396+
var modelWithSummary1Prop = Assert.IsType<OpenApiSchemaReference>(rootModelSchema.Properties["modelWithSummary1"]);
397+
Assert.Equal("Comment on property ModelWithSummary1.", modelWithSummary1Prop.Description);
398+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse("""{ "street": "ModelWithSummary1Prop" }"""), modelWithSummary1Prop.Examples[0]));
399+
400+
var modelWithSummary2Prop = Assert.IsType<OpenApiSchemaReference>(rootModelSchema.Properties["modelWithSummary2"]);
401+
Assert.Equal("Comment on property ModelWithSummary2.", modelWithSummary2Prop.Description);
402+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse("""{ "street": "ModelWithSummary2Prop" }"""), modelWithSummary2Prop.Examples[0]));
403+
404+
var modelWithoutSummary1Prop = Assert.IsType<OpenApiSchemaReference>(rootModelSchema.Properties["modelWithoutSummary1"]);
405+
Assert.Equal("Comment on property ModelWithoutSummary1.", modelWithoutSummary1Prop.Description);
406+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse("""{ "street": "ModelWithoutSummary1Prop" }"""), modelWithoutSummary1Prop.Examples[0]));
407+
408+
var modelWithoutSummary2Prop = Assert.IsType<OpenApiSchemaReference>(rootModelSchema.Properties["modelWithoutSummary2"]);
409+
Assert.Equal("Comment on property ModelWithoutSummary2.", modelWithoutSummary2Prop.Description);
410+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse("""{ "street": "ModelWithoutSummary2Prop" }"""), modelWithoutSummary2Prop.Examples[0]));
411+
412+
var modelInline1Prop = Assert.IsType<OpenApiSchema>(rootModelSchema.Properties["modelInline1"]);
413+
Assert.Equal("Comment on property ModelInline1.", modelInline1Prop.Description);
414+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse("""{ "street": "ModelInline1Prop" }"""), modelInline1Prop.Example));
415+
416+
var modelInline2Prop = Assert.IsType<OpenApiSchema>(rootModelSchema.Properties["modelInline2"]);
417+
Assert.Equal("Comment on property ModelInline2.", modelInline2Prop.Description);
418+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse("""{ "street": "ModelInline2Prop" }"""), modelInline2Prop.Example));
419+
});
420+
}
263421
}

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//HintName: OpenApiXmlCommentSupport.generated.cs
1+
//HintName: OpenApiXmlCommentSupport.generated.cs
22
//------------------------------------------------------------------------------
33
// <auto-generated>
44
// This code was generated by a tool.
@@ -431,17 +431,7 @@ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceP
431431
{
432432
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
433433
{
434-
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
435-
{
436-
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
437-
{
438-
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
439-
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
440-
{
441-
schema.Example = jsonString.Parse();
442-
}
443-
}
444-
}
434+
// Apply comments from the type
445435
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
446436
{
447437
schema.Description = typeComment.Summary;
@@ -450,6 +440,34 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
450440
schema.Example = jsonString.Parse();
451441
}
452442
}
443+
444+
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
445+
{
446+
// Apply comments from the property
447+
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
448+
{
449+
if (schema.Metadata is null
450+
|| !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
451+
|| string.IsNullOrEmpty(schemaId as string))
452+
{
453+
// Inlined schema
454+
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary!;
455+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
456+
{
457+
schema.Example = jsonString.Parse();
458+
}
459+
}
460+
else
461+
{
462+
// Schema Reference
463+
schema.Metadata["x-ref-description"] = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary!;
464+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
465+
{
466+
schema.Metadata["x-ref-example"] = jsonString.Parse()!;
467+
}
468+
}
469+
}
470+
}
453471
return Task.CompletedTask;
454472
}
455473
}

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -460,17 +460,7 @@ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceP
460460
{
461461
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
462462
{
463-
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
464-
{
465-
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
466-
{
467-
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
468-
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
469-
{
470-
schema.Example = jsonString.Parse();
471-
}
472-
}
473-
}
463+
// Apply comments from the type
474464
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
475465
{
476466
schema.Description = typeComment.Summary;
@@ -479,6 +469,34 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
479469
schema.Example = jsonString.Parse();
480470
}
481471
}
472+
473+
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
474+
{
475+
// Apply comments from the property
476+
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
477+
{
478+
if (schema.Metadata is null
479+
|| !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
480+
|| string.IsNullOrEmpty(schemaId as string))
481+
{
482+
// Inlined schema
483+
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary!;
484+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
485+
{
486+
schema.Example = jsonString.Parse();
487+
}
488+
}
489+
else
490+
{
491+
// Schema Reference
492+
schema.Metadata["x-ref-description"] = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary!;
493+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
494+
{
495+
schema.Metadata["x-ref-example"] = jsonString.Parse()!;
496+
}
497+
}
498+
}
499+
}
482500
return Task.CompletedTask;
483501
}
484502
}

0 commit comments

Comments
 (0)