Skip to content

Commit

Permalink
Another round of OpenAPI improvements for [Tags] usage, operationId d…
Browse files Browse the repository at this point in the history
…etermination, better ability to customize OpenAPI generation through Swashbuckle. Much swearing by Jeremy. Closes GH-675
  • Loading branch information
jeremydmiller committed Jan 2, 2024
1 parent 2c7ca03 commit 80e944b
Show file tree
Hide file tree
Showing 16 changed files with 232 additions and 30 deletions.
2 changes: 1 addition & 1 deletion docs/guide/http/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ and register that strategy within our `MapWolverineEndpoints()` set up like so:
// Customizing parameter handling
opts.AddParameterHandlingStrategy<NowParameterStrategy>();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L150-L155' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_adding_custom_parameter_handling' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L157-L162' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_adding_custom_parameter_handling' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

And lastly, here's the application within an HTTP endpoint for extra context:
Expand Down
3 changes: 1 addition & 2 deletions docs/guide/http/fluentvalidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ app.MapWolverineEndpoints(opts =>
// This adds metadata for OpenAPI
httpChain.WithMetadata(new CustomMetadata());
httpChain.WithTags("wolverine");
});

// more configuration for HTTP...
Expand All @@ -45,5 +44,5 @@ app.MapWolverineEndpoints(opts =>
// Wolverine.Http.FluentValidation
opts.UseFluentValidationProblemDetailMiddleware();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L108-L130' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_configure_endpoints' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L116-L137' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_configure_endpoints' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->
2 changes: 1 addition & 1 deletion docs/guide/http/mediator.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ app.MapPostToWolverine<CustomRequest, CustomResponse>("/wolverine/request");
app.MapDeleteToWolverine<CustomRequest, CustomResponse>("/wolverine/request");
app.MapPutToWolverine<CustomRequest, CustomResponse>("/wolverine/request");
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L159-L171' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_optimized_mediator_usage' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L166-L178' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_optimized_mediator_usage' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

With this mechanism, Wolverine is able to optimize the runtime function for Minimal API by eliminating IoC service locations
Expand Down
67 changes: 67 additions & 0 deletions docs/guide/http/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,73 @@ public static void Configure(HttpChain chain)
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/Wolverine.Http/Runtime/PublishingEndpoint.cs#L15-L29' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_programmatic_one_off_openapi_metadata' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Swashbuckle and Wolverine

[Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) is de facto the default OpenAPI tooling and it is added in by the default `dotnet new` templates for ASP.Net Core
applications. It's also very MVC Core-centric in its assumptions about how to generate OpenAPI metadata to describe endpoints.
If you need to (or just want to), you can do quite a bit to control exactly how Swashbuckle works against
Wolverine endpoints by using a custom `IOperationFilter` of your making that can use Wolverine's own `HttpChain` model
for finer grained control. Here's a sample from the Wolverine testing code that just uses Wolverine' own model to
determine the OpenAPI operation id:

<!-- snippet: sample_WolverineOperationFilter -->
<a id='snippet-sample_wolverineoperationfilter'></a>
```cs
// This class is NOT distributed in any kind of Nuget today, but feel very free
// to copy this code into your own as it is at least tested through Wolverine's
// CI test suite
public class WolverineOperationFilter : IOperationFilter // IOperationFilter is from Swashbuckle itself
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (context.ApiDescription.ActionDescriptor is WolverineActionDescriptor action)
{
operation.OperationId = action.Chain.OperationId;
}
}
}
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/WolverineOperationFilter.cs#L7-L23' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_wolverineoperationfilter' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

And that would be registered with Swashbuckle inside of your `Program.Main()` method like so:

<!-- snippet: sample_register_custom_swashbuckle_filter -->
<a id='snippet-sample_register_custom_swashbuckle_filter'></a>
```cs
builder.Services.AddSwaggerGen(x =>
{
x.OperationFilter<WolverineOperationFilter>();
});
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L32-L39' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_register_custom_swashbuckle_filter' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Operation Id

::: warning
You will have to use the custom `WolverineOperationFilter` in the previous section to relay Wolverine's operation id
determination to Swashbuckle. We have not (yet) been able to relay that information to Swashbuckle otherwise.
:::

By default, Wolverine.HTTP is trying to mimic the logic for determining the OpenAPI `operationId` logic from MVC Core which
is *endpoint class name*.*method name*. You can also override the operation id through the normal routing attribute through
an optional property as shown below (from the Wolverine.HTTP test code):

<!-- snippet: sample_override_operation_id_for_openapi -->
<a id='snippet-sample_override_operation_id_for_openapi'></a>
```cs
// Override the operation id within the generated OpenAPI
// metadata
[WolverineGet("/fake/hello/async", OperationId = "OverriddenId")]
public Task<string> SayHelloAsync()
{
return Task.FromResult("Hello");
}
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/FakeEndpoint.cs#L13-L23' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_override_operation_id_for_openapi' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## IHttpAware or IEndpointMetadataProvider Models

Wolverine honors the ASP.Net Core [IEndpointMetadataProvider](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.metadata.iendpointmetadataprovider?view=aspnetcore-7.0)
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/http/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Which is registered like this (or as described in [`Registering Middleware by Me
opts.AddMiddlewareByMessageType(typeof(FakeAuthenticationMiddleware));
opts.AddMiddlewareByMessageType(typeof(CanShipOrderMiddleWare));
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L136-L139' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_register_http_middleware_by_type' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L143-L146' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_register_http_middleware_by_type' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The key point to notice there is that `IResult` is a "return value" of the middleware. In the case of an HTTP endpoint,
Expand Down
6 changes: 2 additions & 4 deletions docs/guide/http/policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ app.MapWolverineEndpoints(opts =>
// This adds metadata for OpenAPI
httpChain.WithMetadata(new CustomMetadata());
httpChain.WithTags("wolverine");
});

// more configuration for HTTP...
Expand All @@ -66,7 +65,7 @@ app.MapWolverineEndpoints(opts =>
// Wolverine.Http.FluentValidation
opts.UseFluentValidationProblemDetailMiddleware();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L108-L130' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_configure_endpoints' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L116-L137' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_configure_endpoints' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The `HttpChain` model is a configuration time structure that Wolverine.Http will use at runtime to create the full
Expand All @@ -90,7 +89,6 @@ app.MapWolverineEndpoints(opts =>
// This adds metadata for OpenAPI
httpChain.WithMetadata(new CustomMetadata());
httpChain.WithTags("wolverine");
});

// more configuration for HTTP...
Expand All @@ -99,5 +97,5 @@ app.MapWolverineEndpoints(opts =>
// Wolverine.Http.FluentValidation
opts.UseFluentValidationProblemDetailMiddleware();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L108-L130' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_configure_endpoints' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L116-L137' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_configure_endpoints' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->
18 changes: 18 additions & 0 deletions src/Http/Wolverine.Http.Tests/WolverineActionDescriptorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using JasperFx.Core.Reflection;
using Shouldly;
using WolverineWebApi;

namespace Wolverine.Http.Tests;

public class WolverineActionDescriptorTests
{
[Fact]
public void set_controller_and_action_for_swashbuckle_defaults()
{
var chain = HttpChain.ChainFor<FakeEndpoint>(x => x.SayHello());
var descriptor = new WolverineActionDescriptor(chain);
descriptor.DisplayName.ShouldBe(chain.DisplayName);
descriptor.RouteValues["controller"].ShouldBe(typeof(FakeEndpoint).FullNameInCode());
descriptor.RouteValues["action"].ShouldBe(nameof(FakeEndpoint.SayHello));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using JasperFx.CodeGeneration.Frames;
using JasperFx.CodeGeneration.Model;
using JasperFx.Core.Reflection;
using Lamar;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Metadata;
Expand Down Expand Up @@ -43,6 +44,20 @@ public void build_pattern_using_http_pattern_with_attribute()
endpoint.RoutePattern.Parameters.Any().ShouldBeFalse();
}

[Fact]
public void default_operation_id_is_endpoint_class_and_method()
{
var endpoint = HttpChain.ChainFor<FakeEndpoint>(x => x.SayHello());
endpoint.OperationId.ShouldBe($"{typeof(FakeEndpoint).FullNameInCode()}.{nameof(FakeEndpoint.SayHello)}");
}

[Fact]
public void override_operation_id_on_attributes()
{
var endpoint = HttpChain.ChainFor<FakeEndpoint>(x => x.SayHelloAsync());
endpoint.OperationId.ShouldBe("OverriddenId");
}

[Fact]
public void capturing_the_http_method_metadata()
{
Expand Down
23 changes: 23 additions & 0 deletions src/Http/Wolverine.Http.Tests/swashbuckle_integration.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using Shouldly;
using Swashbuckle.AspNetCore.Swagger;

Expand Down Expand Up @@ -34,6 +36,27 @@ public void ignore_endpoint_methods_that_are_marked_with_ExcludeFromDescription(

doc.Paths.Any(x => x.Key == "/ignore").ShouldBeFalse();
}

[Fact]
public void derive_the_operation_id()
{
var (_, op) = FindOpenApiDocument(OperationType.Get, "/result");

op.OperationId.ShouldBe("WolverineWebApi.ResultEndpoints.GetResult");
}

[Fact]
public void apply_tags_from_tags_attribute()
{
var endpoint = EndpointFor("/users/sign-up");
var tags = endpoint.Metadata.GetOrderedMetadata<ITagsMetadata>();
tags.Any().ShouldBeTrue();

var (item, op) = FindOpenApiDocument(OperationType.Post, "/users/sign-up");
op.Tags.ShouldContain(x => x.Name == "Users");
}





Expand Down
40 changes: 32 additions & 8 deletions src/Http/Wolverine.Http/HttpChain.ApiDescription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@

namespace Wolverine.Http;

/// <summary>
/// Describes a Wolverine HTTP endpoint implementation
/// </summary>
public class WolverineActionDescriptor : ActionDescriptor
{
public WolverineActionDescriptor(HttpChain chain)
{
RouteValues = new Dictionary<string, string?>();
RouteValues["controller"] = chain.Method.Method.DeclaringType?.FullNameInCode();
RouteValues["action"] = chain.Method.Method.Name;
Chain = chain;

if (chain.Endpoint != null)
{
EndpointMetadata = chain.Endpoint!.Metadata.ToArray();
}
}

public override string? DisplayName
{
get => Chain.DisplayName;
set{}
}

/// <summary>
/// The raw Wolverine model of the HTTP endpoint
/// </summary>
public HttpChain Chain { get; }

}

public partial class HttpChain
{
public ApiDescription CreateApiDescription(string httpMethod)
Expand All @@ -20,14 +51,7 @@ public ApiDescription CreateApiDescription(string httpMethod)
HttpMethod = httpMethod,
GroupName = Endpoint.Metadata.GetMetadata<IEndpointGroupNameMetadata>()?.EndpointGroupName,
RelativePath = Endpoint.RoutePattern.RawText?.TrimStart('/'),
ActionDescriptor = new ActionDescriptor
{
DisplayName = Endpoint.DisplayName,
RouteValues =
{
["controller"] = Method.Method.DeclaringType?.Namespace ?? Method.Method.Name
}
}
ActionDescriptor = new WolverineActionDescriptor(this)
};

foreach (var routeParameter in RoutePattern.Parameters)
Expand Down
9 changes: 9 additions & 0 deletions src/Http/Wolverine.Http/HttpChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ public static bool IsValidResponseType(Type type)
private readonly HttpGraph _parent;

private readonly List<QuerystringVariable> _querystringVariables = new();

public string OperationId { get; set; }


// Make the assumption that the route argument has to match the parameter name
Expand Down Expand Up @@ -93,8 +95,15 @@ public HttpChain(MethodCall method, HttpGraph parent)
{
DisplayName = att.Name;
}

if (att.OperationId.IsNotEmpty())
{
OperationId = att.OperationId;
}
}

OperationId ??= $"{Method.HandlerType.FullNameInCode()}.{Method.Method.Name}";

// Apply attributes and the Configure() method if that exists too
applyAttributesAndConfigureMethods(_parent.Rules, _parent.Container);

Expand Down
7 changes: 7 additions & 0 deletions src/Http/Wolverine.Http/ModifyHttpChainAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ protected WolverineHttpMethodAttribute(string httpMethod, string template)
/// Name for the route in ASP.Net Core
/// </summary>
public string? Name { get; set; }

/// <summary>
/// Overrides the OperationId property on HttpChain
/// Can be used to seed OpenAPI documentation with
/// Swashbuckle
/// </summary>
public string? OperationId { get; set; }
}

/// <summary>
Expand Down
18 changes: 12 additions & 6 deletions src/Http/Wolverine.Http/WolverineHttpOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ public void AddMiddleware(Type middlewareType, Func<HttpChain, bool>? filter = n
/// <param name="url"></param>
/// <param name="customize">Optionally customize the HttpChain handling for elements like validation</param>
/// <typeparam name="T"></typeparam>
public void PublishMessage<T>(HttpMethod httpMethod, string url, Action<HttpChain>? customize = null)
public RouteHandlerBuilder PublishMessage<T>(HttpMethod httpMethod, string url, Action<HttpChain>? customize = null)
{
#pragma warning disable CS4014
var method = MethodCall.For<PublishingEndpoint<T>>(x => x.PublishAsync(default!, null!, null!));
Expand All @@ -220,12 +220,15 @@ public void PublishMessage<T>(HttpMethod httpMethod, string url, Action<HttpChai

chain.MapToRoute(httpMethod.ToString(), url);
chain.DisplayName = $"Forward {typeof(T).FullNameInCode()} to Wolverine";
chain.OperationId = $"Publish:{typeof(T).FullNameInCode()}";
customize?.Invoke(chain);

return chain.Metadata;
}

public void PublishMessage<T>(string url, Action<HttpChain>? customize = null)
public RouteHandlerBuilder PublishMessage<T>(string url, Action<HttpChain>? customize = null)
{
PublishMessage<T>(HttpMethod.Post, url, customize);
return PublishMessage<T>(HttpMethod.Post, url, customize);
}

/// <summary>
Expand All @@ -235,7 +238,7 @@ public void PublishMessage<T>(string url, Action<HttpChain>? customize = null)
/// <param name="url"></param>
/// <param name="customize">Optionally customize the HttpChain handling for elements like validation</param>
/// <typeparam name="T"></typeparam>
public void SendMessage<T>(HttpMethod httpMethod, string url, Action<HttpChain>? customize = null)
public RouteHandlerBuilder SendMessage<T>(HttpMethod httpMethod, string url, Action<HttpChain>? customize = null)
{
#pragma warning disable CS4014
var method = MethodCall.For<SendingEndpoint<T>>(x => x.SendAsync(default!, null!, null!));
Expand All @@ -244,12 +247,15 @@ public void SendMessage<T>(HttpMethod httpMethod, string url, Action<HttpChain>?

chain.MapToRoute(httpMethod.ToString(), url);
chain.DisplayName = $"Forward {typeof(T).FullNameInCode()} to Wolverine";
chain.OperationId = $"Send:{typeof(T).FullNameInCode()}";
customize?.Invoke(chain);

return chain.Metadata;
}

public void SendMessage<T>(string url, Action<HttpChain>? customize = null)
public RouteHandlerBuilder SendMessage<T>(string url, Action<HttpChain>? customize = null)
{
SendMessage<T>(HttpMethod.Post, url, customize);
return SendMessage<T>(HttpMethod.Post, url, customize);
}

}
Loading

0 comments on commit 80e944b

Please sign in to comment.