Skip to content
Merged
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
210 changes: 210 additions & 0 deletions src/Http/Wolverine.Http.Tests/openapi_metadata_customization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using JasperFx.Core.Reflection;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using Shouldly;
using Swashbuckle.AspNetCore.Swagger;
using WolverineWebApi;

namespace Wolverine.Http.Tests;

public class openapi_metadata_customization : IntegrationContext
{
public openapi_metadata_customization(AppFixture fixture) : base(fixture)
{
}

#region OperationId tests

[Fact]
public void explicit_operation_id_is_used_in_openapi()
{
var (_, op) = FindOpenApiDocument(OperationType.Get, "/openapi/with-summary");
op.OperationId.ShouldBe("GetWithSummary");
}

[Fact]
public void explicit_operation_id_on_post()
{
var (_, op) = FindOpenApiDocument(OperationType.Post, "/openapi/with-operation-id");
op.OperationId.ShouldBe("CustomPostOperation");
}

[Fact]
public void default_operation_id_uses_class_and_method_name()
{
var (_, op) = FindOpenApiDocument(OperationType.Get, "/openapi/default-metadata");
op.OperationId.ShouldBe(
$"{typeof(OpenApiMetadataEndpoints).FullNameInCode()}.{nameof(OpenApiMetadataEndpoints.GetDefaultMetadata)}");
}

[Fact]
public void delete_with_explicit_operation_id_in_openapi()
{
var (_, op) = FindOpenApiDocument(OperationType.Delete, "/openapi/with-all-metadata");
op.OperationId.ShouldBe("DeleteWithAllMetadata");
}

#endregion

#region Chain property tests

[Fact]
public void chain_has_explicit_operation_id_flag_when_set()
{
var chain = HttpChains.ChainFor("GET", "/openapi/with-summary");
chain.ShouldNotBeNull();
chain!.HasExplicitOperationId.ShouldBeTrue();
chain.OperationId.ShouldBe("GetWithSummary");
}

[Fact]
public void chain_does_not_have_explicit_operation_id_flag_when_not_set()
{
var chain = HttpChains.ChainFor("GET", "/openapi/default-metadata");
chain.ShouldNotBeNull();
chain!.HasExplicitOperationId.ShouldBeFalse();
}

[Fact]
public void summary_metadata_is_set_on_chain()
{
var chain = HttpChains.ChainFor("GET", "/openapi/with-summary");
chain!.EndpointSummary.ShouldBe("Gets a greeting with summary");
}

[Fact]
public void description_metadata_is_set_on_chain()
{
var chain = HttpChains.ChainFor("GET", "/openapi/with-summary");
chain!.EndpointDescription.ShouldBe(
"This endpoint returns a simple greeting and demonstrates OpenAPI summary and description support");
}

[Fact]
public void summary_is_null_when_not_set()
{
var chain = HttpChains.ChainFor("GET", "/openapi/default-metadata");
chain!.EndpointSummary.ShouldBeNull();
}

[Fact]
public void description_is_null_when_not_set()
{
var chain = HttpChains.ChainFor("GET", "/openapi/default-metadata");
chain!.EndpointDescription.ShouldBeNull();
}

[Fact]
public void delete_chain_has_all_metadata_properties()
{
var chain = HttpChains.ChainFor("DELETE", "/openapi/with-all-metadata");
chain!.OperationId.ShouldBe("DeleteWithAllMetadata");
chain.HasExplicitOperationId.ShouldBeTrue();
chain.EndpointSummary.ShouldBe("Deletes a resource");
chain.EndpointDescription.ShouldBe("Performs a delete operation with full OpenAPI metadata");
}

#endregion

#region Endpoint metadata tests

[Fact]
public void endpoint_name_metadata_is_added_for_explicit_operation_id()
{
var endpoint = EndpointFor("/openapi/with-summary");
var endpointName = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
endpointName.ShouldNotBeNull();
endpointName!.EndpointName.ShouldBe("GetWithSummary");
}

[Fact]
public void endpoint_name_metadata_is_not_added_for_default_operation_id()
{
var endpoint = EndpointFor("/openapi/default-metadata");

// Should NOT have an explicit EndpointNameMetadata added by our code,
// which avoids duplicate endpoint name collisions for overloaded methods
var endpointNames = endpoint.Metadata.GetOrderedMetadata<IEndpointNameMetadata>();
endpointNames.ShouldNotContain(x => x.EndpointName ==
$"{typeof(OpenApiMetadataEndpoints).FullNameInCode()}.{nameof(OpenApiMetadataEndpoints.GetDefaultMetadata)}");
}

[Fact]
public void endpoint_metadata_contains_summary()
{
var endpoint = EndpointFor("/openapi/with-summary");
var summaryMeta = endpoint.Metadata.GetMetadata<IEndpointSummaryMetadata>();
summaryMeta.ShouldNotBeNull();
summaryMeta!.Summary.ShouldBe("Gets a greeting with summary");
}

[Fact]
public void endpoint_metadata_contains_description()
{
var endpoint = EndpointFor("/openapi/with-summary");
var descMeta = endpoint.Metadata.GetMetadata<IEndpointDescriptionMetadata>();
descMeta.ShouldNotBeNull();
descMeta!.Description.ShouldBe(
"This endpoint returns a simple greeting and demonstrates OpenAPI summary and description support");
}

#endregion

#region API description metadata tests

[Fact]
public void api_description_action_descriptor_has_summary_metadata()
{
// Swashbuckle reads from WolverineActionDescriptor.EndpointMetadata
var chain = HttpChains.ChainFor("GET", "/openapi/with-summary");
var apiDesc = chain!.CreateApiDescription("GET");

var summaries = apiDesc.ActionDescriptor.EndpointMetadata
.OfType<IEndpointSummaryMetadata>()
.ToList();

summaries.ShouldNotBeEmpty();
summaries.Last().Summary.ShouldBe("Gets a greeting with summary");
}

[Fact]
public void api_description_action_descriptor_has_description_metadata()
{
var chain = HttpChains.ChainFor("GET", "/openapi/with-summary");
var apiDesc = chain!.CreateApiDescription("GET");

var descriptions = apiDesc.ActionDescriptor.EndpointMetadata
.OfType<IEndpointDescriptionMetadata>()
.ToList();

descriptions.ShouldNotBeEmpty();
descriptions.Last().Description.ShouldBe(
"This endpoint returns a simple greeting and demonstrates OpenAPI summary and description support");
}

[Fact]
public void api_description_provider_includes_summary_metadata()
{
var apiDescProvider = Host.Services
.GetRequiredService<IApiDescriptionGroupCollectionProvider>();

var descs = apiDescProvider.ApiDescriptionGroups.Items
.SelectMany(g => g.Items)
.Where(d => d.RelativePath == "openapi/with-summary"
&& d.ActionDescriptor is WolverineActionDescriptor)
.ToList();

descs.ShouldNotBeEmpty();

var wolverineDesc = descs.First();
wolverineDesc.ActionDescriptor.EndpointMetadata
.OfType<IEndpointSummaryMetadata>()
.Last()
.Summary.ShouldBe("Gets a greeting with summary");
}

#endregion
}
38 changes: 37 additions & 1 deletion src/Http/Wolverine.Http/HttpChain.ApiDescription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,43 @@ public WolverineActionDescriptor(HttpChain chain)

if (chain.Endpoint != null)
{
EndpointMetadata = chain.Endpoint!.Metadata.ToArray();
var metadata = chain.Endpoint!.Metadata.ToList();

// Ensure summary/description metadata is available to Swashbuckle
// even if it wasn't in the endpoint metadata collection
if (chain.EndpointSummary.IsNotEmpty() &&
!metadata.OfType<IEndpointSummaryMetadata>().Any())
{
metadata.Add(new EndpointSummaryAttribute(chain.EndpointSummary));
}

if (chain.EndpointDescription.IsNotEmpty() &&
!metadata.OfType<IEndpointDescriptionMetadata>().Any())
{
metadata.Add(new EndpointDescriptionAttribute(chain.EndpointDescription));
}

EndpointMetadata = metadata.ToArray();
}
else
{
// Endpoint may not be built yet when the API description provider runs
var metadata = new List<object>();

if (chain.EndpointSummary.IsNotEmpty())
{
metadata.Add(new EndpointSummaryAttribute(chain.EndpointSummary));
}

if (chain.EndpointDescription.IsNotEmpty())
{
metadata.Add(new EndpointDescriptionAttribute(chain.EndpointDescription));
}

if (metadata.Count > 0)
{
EndpointMetadata = metadata.ToArray();
}
}

ActionName = chain.OperationId;
Expand Down
15 changes: 15 additions & 0 deletions src/Http/Wolverine.Http/HttpChain.EndpointBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ public RouteEndpoint BuildEndpoint(RouteWarmup warmup)
builder.Metadata.Add(new RouteNameMetadata(RouteName));
}

if (HasExplicitOperationId)
{
builder.Metadata.Add(new EndpointNameMetadata(OperationId));
}

if (EndpointSummary.IsNotEmpty())
{
builder.Metadata.Add(new EndpointSummaryAttribute(EndpointSummary));
}

if (EndpointDescription.IsNotEmpty())
{
builder.Metadata.Add(new EndpointDescriptionAttribute(EndpointDescription));
}

Endpoint = (RouteEndpoint?)builder.Build();
return Endpoint!;
}
Expand Down
20 changes: 17 additions & 3 deletions src/Http/Wolverine.Http/HttpChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ public static bool IsValidResponseType(Type type)
private readonly List<HttpElementVariable> _formValueVariables = [];

public string OperationId { get; set; }

public bool HasExplicitOperationId { get; private set; }
public string? EndpointSummary { get; set; }
public string? EndpointDescription { get; set; }

/// <summary>
/// This may be overridden by some IResponseAware policies in place of the first
/// create variable of the method call
Expand Down Expand Up @@ -127,6 +130,17 @@ public HttpChain(MethodCall method, HttpGraph parent)
if (att.OperationId.IsNotEmpty())
{
OperationId = att.OperationId;
HasExplicitOperationId = true;
}

if (att.Summary.IsNotEmpty())
{
EndpointSummary = att.Summary;
}

if (att.Description.IsNotEmpty())
{
EndpointDescription = att.Description;
}
}

Expand Down Expand Up @@ -729,9 +743,9 @@ public HttpElementVariable GetOrCreateHeaderVariable(IFromHeaderMetadata metadat
return frame.Variable;
}

string IEndpointNameMetadata.EndpointName => ToString();
string IEndpointNameMetadata.EndpointName => HasExplicitOperationId ? OperationId : ToString();

string IEndpointSummaryMetadata.Summary => ToString();
string IEndpointSummaryMetadata.Summary => EndpointSummary ?? ToString();

public List<ParameterInfo> FileParameters { get; } = [];

Expand Down
10 changes: 10 additions & 0 deletions src/Http/Wolverine.Http/ModifyHttpChainAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ protected WolverineHttpMethodAttribute(string httpMethod, string template)
/// Swashbuckle
/// </summary>
public string? OperationId { get; set; }

/// <summary>
/// Sets the summary for this endpoint in OpenAPI documentation
/// </summary>
public string? Summary { get; set; }

/// <summary>
/// Sets the description for this endpoint in OpenAPI documentation
/// </summary>
public string? Description { get; set; }
}

/// <summary>
Expand Down
39 changes: 38 additions & 1 deletion src/Http/WolverineWebApi/FakeEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,41 @@ public static class NoDependencyEndpoints
public static bool GetBool() => true;
}

public class BigResponse;
public class BigResponse;

#region sample_openapi_summary_and_description

public static class OpenApiMetadataEndpoints
{
[WolverineGet("/openapi/with-summary",
OperationId = "GetWithSummary",
Summary = "Gets a greeting with summary",
Description = "This endpoint returns a simple greeting and demonstrates OpenAPI summary and description support")]
public static string GetWithSummary()
{
return "Hello with summary";
}

[WolverinePost("/openapi/with-operation-id", OperationId = "CustomPostOperation")]
public static string PostWithOperationId(Question question)
{
return "Posted";
}

[WolverineGet("/openapi/default-metadata")]
public static string GetDefaultMetadata()
{
return "Default";
}

[WolverineDelete("/openapi/with-all-metadata",
OperationId = "DeleteWithAllMetadata",
Summary = "Deletes a resource",
Description = "Performs a delete operation with full OpenAPI metadata")]
public static string DeleteWithAllMetadata()
{
return "Deleted";
}
}

#endregion
Loading