Skip to content
Closed
Show file tree
Hide file tree
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
40 changes: 29 additions & 11 deletions src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -449,17 +449,7 @@ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceP
{
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
{
schema.Example = jsonString.Parse();
}
}
}
// Apply comments from the type
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
Expand All @@ -468,6 +458,33 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
schema.Example = jsonString.Parse();
}
}

if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
// Apply comments from the property
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
var isInlinedSchema = schema.Metadata is null
|| !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
|| string.IsNullOrEmpty(schemaId as string);
if(isInlinedSchema)
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
{
schema.Example = jsonString.Parse();
}
}
else
{
schema.Metadata["x-ref-description"] = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
{
schema.Metadata["x-ref-example"] = jsonString.Parse();
}
}
}
}
return Task.CompletedTask;
}
}
Expand Down Expand Up @@ -507,6 +524,7 @@ file static class GeneratedServiceCollectionExtensions
{{GenerateAddOpenApiInterceptions(groupedAddOpenApiInvocations)}}
}
}

""";

internal static string GetAddOpenApiInterceptor(AddOpenApiOverloadVariant overloadVariant) => overloadVariant switch
Expand Down
14 changes: 13 additions & 1 deletion src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Nodes;

namespace Microsoft.AspNetCore.OpenApi;

internal static class OpenApiDocumentExtensions
Expand All @@ -21,6 +23,16 @@ public static IOpenApiSchema AddOpenApiSchemaByReference(this OpenApiDocument do
document.Workspace ??= new();
var location = document.BaseUri + "/components/schemas/" + schemaId;
document.Workspace.RegisterComponentForDocument(document, schema, location);
return new OpenApiSchemaReference(schemaId, document);

object? description = null;
object? example = null;
((OpenApiSchema)schema).Metadata.TryGetValue("x-ref-description", out description);
((OpenApiSchema)schema).Metadata.TryGetValue("x-ref-example", out example);

return new OpenApiSchemaReference(schemaId, document)
{
Description = description as string,
Examples = example is JsonNode exampleJsonNode ? [exampleJsonNode] : null,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -516,9 +516,6 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
Assert.Equal("This class validates the behavior for mapping\ngeneric types to open generics for use in\ntypeof expressions.", genericParent.Description, ignoreLineEndingDifferences: true);
Assert.Equal("This property is a nullable value type.", genericParent.Properties["id"].Description);
Assert.Equal("This property is a nullable reference type.", genericParent.Properties["name"].Description);
Assert.Equal("This property is a generic type containing a tuple.", genericParent.Properties["taskOfTupleProp"].Description);
Assert.Equal("This property is a tuple with a generic type inside.", genericParent.Properties["tupleWithGenericProp"].Description);
Assert.Equal("This property is a tuple with a nested generic type inside.", genericParent.Properties["tupleWithNestedGenericProp"].Description);

path = document.Paths["/params-and-param-refs"].Operations[HttpMethod.Post];
var paramsAndParamRefs = path.RequestBody.Content["application/json"].Schema;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net.Http;
Expand All @@ -19,7 +19,10 @@ public async Task SupportsXmlCommentsOnSchemas()

var builder = WebApplication.CreateBuilder();

builder.Services.AddOpenApi();
builder.Services.AddOpenApi(options => {
var prevCreateSchemaReferenceId = options.CreateSchemaReferenceId;
options.CreateSchemaReferenceId = (x) => x.Type == typeof(AddressNested) ? null : prevCreateSchemaReferenceId(x);
});

var app = builder.Build();

Expand All @@ -31,6 +34,7 @@ public async Task SupportsXmlCommentsOnSchemas()
app.MapPost("/todo-with-description", (TodoWithDescription todo) => { });
app.MapPost("/type-with-examples", (TypeWithExamples typeWithExamples) => { });
app.MapPost("/user", (User user) => { });
app.MapPost("/company", (Company company) => { });

app.Run();

Expand Down Expand Up @@ -175,6 +179,60 @@ internal class User : IUser
/// <inheritdoc/>
public string Name { get; set; }
}

/// <summary>
/// An address.
/// </summary>
public class AddressWithSummary
{
public string Street { get; set; }
}

public class AddressWithoutSummary
{
public string Street { get; set; }
}

/// <summary>
/// An address.
/// </summary>
public class AddressNested
{
public string Street { get; set; }
}

public class Company
{
/// <summary>
/// Billing address.
/// </summary>
public AddressWithSummary BillingAddressClassWithSummary { get; set; }

/// <summary>
/// Billing address.
/// </summary>
public AddressWithoutSummary BillingAddressClassWithoutSummary { get; set; }

/// <summary>
/// Billing address.
/// </summary>
public AddressNested BillingAddressNested { get; set; }

/// <summary>
/// Visiting address.
/// </summary>
public AddressWithSummary VisitingAddressClassWithSummary { get; set; }

/// <summary>
/// Visiting address.
/// </summary>
public AddressWithoutSummary VisitingAddressClassWithoutSummary { get; set; }

/// <summary>
/// Visiting address.
/// </summary>
public AddressNested VisitingAddressNested { get; set; }
}
""";
var generator = new XmlCommentGenerator();
await SnapshotTestHelper.Verify(source, generator, out var compilation);
Expand Down Expand Up @@ -258,6 +316,118 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
var user = path.RequestBody.Content["application/json"].Schema;
Assert.Equal("The unique identifier for the user.", user.Properties["id"].Description);
Assert.Equal("The user's display name.", user.Properties["name"].Description);

path = document.Paths["/company"].Operations[HttpMethod.Post];
var company = path.RequestBody.Content["application/json"].Schema;
Assert.Equal("Billing address.", company.Properties["billingAddressNested"].Description);
Assert.Equal("Visiting address.", company.Properties["visitingAddressNested"].Description);

var addressWithSummary = document.Components.Schemas["AddressWithSummary"];
Assert.Equal("An address.", addressWithSummary.Description);

var addressWithoutSummary = document.Components.Schemas["AddressWithoutSummary"];
Assert.Null(addressWithoutSummary.Description);
});
}

[Fact]
public async Task XmlCommentsOnPropertiesShouldNotApplyToReferencedSchemas()
{
var source = """
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder();

builder.Services.AddOpenApi(options => {
var prevCreateSchemaReferenceId = options.CreateSchemaReferenceId;
options.CreateSchemaReferenceId = (x) => x.Type == typeof(ModelInline) ? null : prevCreateSchemaReferenceId(x);
});

var app = builder.Build();

app.MapPost("/example", (RootModel model) => { });

app.Run();

/// <summary>
/// Comment on class ModelWithSummary.
/// </summary>
public class ModelWithSummary
{
public string Street { get; set; }
}

public class ModelWithoutSummary
{
public string Street { get; set; }
}

/// <summary>
/// Comment on class ModelInline.
/// </summary>
public class ModelInline
{
public string Street { get; set; }
}

/// <summary>
/// Comment on class RootModel.
/// </summary>
public class RootModel
{
/// <summary>
/// Comment on property FirstModelWithSummary.
/// </summary>
public ModelWithSummary FirstModelWithSummary { get; set; }

/// <summary>
/// Comment on property SecondModelWithSummary.
/// </summary>
public ModelWithSummary SecondModelWithSummary { get; set; }

/// <summary>
/// Comment on property FirstModelWithoutSummary.
/// </summary>
public ModelWithoutSummary FirstModelWithoutSummary { get; set; }

/// <summary>
/// Comment on property SecondModelWithoutSummary.
/// </summary>
public ModelWithoutSummary SecondModelWithoutSummary { get; set; }

/// <summary>
/// Comment on property FirstModelInline.
/// </summary>
public ModelInline FirstModelInline { get; set; }

/// <summary>
/// Comment on property SecondModelInline.
/// </summary>
public ModelInline SecondModelInline { get; set; }
}
""";
var generator = new XmlCommentGenerator();
await SnapshotTestHelper.Verify(source, generator, out var compilation);
await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
{
var path = document.Paths["/example"].Operations[HttpMethod.Post];
var exampleOperationBodySchema = path.RequestBody.Content["application/json"].Schema;
Assert.Equal("Comment on class RootModel.", exampleOperationBodySchema.Description);

var rootModelSchema = document.Components.Schemas["RootModel"];
Assert.Equal("Comment on class RootModel.", rootModelSchema.Description);

var modelWithSummary = document.Components.Schemas["ModelWithSummary"];
Assert.Equal("Comment on class ModelWithSummary.", modelWithSummary.Description);

var modelWithoutSummary = document.Components.Schemas["ModelWithoutSummary"];
Assert.Null(modelWithoutSummary.Description);

Assert.DoesNotContain("ModelInline", document.Components.Schemas.Keys);
Assert.Equal("Comment on property FirstModelInline.", rootModelSchema.Properties["firstModelInline"].Description);
Assert.Equal("Comment on property SecondModelInline.", rootModelSchema.Properties["secondModelInline"].Description);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ void OnEntryPointExit(Exception exception)
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]);

var openApiString = Encoding.UTF8.GetString(stream.ToArray());
await Verifier.Verify(openApiString)
.UseTextForParameters("openapi.json")
.UseDirectory(SkipOnHelixAttribute.OnHelix()
? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "snapshots")
: "snapshots");

stream.Position = 0;
var (document, _) = await OpenApiDocument.LoadAsync(stream, "json");
verifyFunc(document);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,22 @@ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceP
{
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
// Apply comments from the type
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
{
schema.Example = jsonString.Parse();
}
}

var isInlinedSchema = schema.Metadata is null
|| !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
|| string.IsNullOrEmpty(schemaId as string);
if (isInlinedSchema && context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
// Apply comments from the property
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
Expand All @@ -442,14 +456,6 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
{
schema.Example = jsonString.Parse();
}
}
return Task.CompletedTask;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,22 @@ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceP
{
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
// Apply comments from the type
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
{
schema.Example = jsonString.Parse();
}
}

var isInlinedSchema = schema.Metadata is null
|| !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
|| string.IsNullOrEmpty(schemaId as string);
if (isInlinedSchema && context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
// Apply comments from the property
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
Expand All @@ -471,14 +485,6 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
{
schema.Example = jsonString.Parse();
}
}
return Task.CompletedTask;
}
}
Expand Down
Loading