Support `WithApiDescription` extension method for minimal APIs
See original GitHub issueSummary
Minimal APIs currently support annotating endpoints with metadata that can be used to generated OpenAPI descriptions for a particular endpoint. Currently, this endpoint can be used to annotate endpoints with details about the request parameters and responses and descriptive details (tags, summaries, descriptions).
However, the OpenAPI spec provides support for a larger variety of annotations including ones that describe the parameters of an endpoint specifically (like examples for a particular parameter). There’s also a matter of the fact that the OpenAPI specification can introduce new annotations at any time and we have to ensure that we are compatible.
These circumstances present the requirement for an API that allows the user to more flexibly describe the API associated with an endpoint.
Goals
- Allow users to annotate individual parameters and responses
- Allow user to modify descriptions that are applied to the API by default
- Allow us to detach a little bit from the ApiExplorer model used in MVC
Non-goals
- Strictly match the OpenAPI specification with regard to what strongly-typed properties we expose
Proposed Design Walkthrough
Let’s say that the user wants to annotate the following endpoint with these details:
- Examples for the Todo that gets passed the body
- Document that Todo parameter is not required
- Examples for the response
- Description for the
id
parameter - Description for the Todo parameter
- Name and summary for the endpoint
app.MapPut(
"/api/todos/{id}",
(int id, [Description("This is a default")] Todo updatedTodo, TodosService todosService) => {
todosService.Update(id, updatedTodo);
})
.WithName("GetFoo")
.WithDescription("This is an endpoint for updating a todo")
To make things a little bit more interesting, we’ll also assume that the endpoint already contains annotations using our currently supported patterns (extension methods and attributes) that we would like to override with our new strategy. This will help show how the two implementations can intersect.
The user will leverage a new extension method in the framework:
public static RouteHandlerBuilder WithApiDescription(this RouteHandlerBuilder builder, Action<EndpointApiDescription> configureDescription);
To annotate their API with the desired schema, the user will provide a function that takes an EndpointApiDescription
, modifies the desired properties, and returns the modified EndpointApiDescription
.
.WithApiDescription(schema => {
schema.Parameters["updatedTodo"].Items["Examples"] = new Todo { ... };
schema.Parameters["updatedTodo"].Description = "New description";
schema.Parameters["id"]["Type"] = typeof(string);
schema.Responses[StatusCodes.Status200OK].Items["Examples"] = new Todo { ... };
schema.EndpointName = "UpdateTodo";
schema.EndpointDescription = "A filter for updating a todo";
});
The EndpointApiDescription
is a new class that represents the API description. It contains a mix of strongly-typed properties and an Items
bag that can be used to add/override arbitrary fields.
public class EndpointApiDescription
{
public string EndpointName;
public string EndpointDescription;
public string[] EndpointTags;
public Dictionary<string, EndpointApiParameter> Parameters;
public Dictionary<StatusCode, EndpointApiResponse> Responses;
public Dictionary<string, object>? Items;
}
The EndpointApiDescription
in turn references two new types: EndpointApiParameter
and EndpointApiResponse
that follow a similar model.
public class EndpointApiResponse
{
public StatusCodes StatusCode { get; }
public string Description { get; set; }
public Dictionary<string, object> Items { get; set; }
}
public class EndpointApiParameter
{
public string Name { get; }
public Type ParameterType { get; }
public string Description { get; set; }
public Dictionary<string, object> Items { get; set; }
}
The WithApDescription
will register the configureDescription
delegate that is provided and store it in the metadata of the targeted endpoint.
This change will be coupled with some changes to the EndpointMetadataApiDescriptionProvider
that will register the constructs produced by the provider onto the schema, call the users configureDescription
method, and set the resulting EndpointApiDescription
onto the metadata for consumption by external libraries.
This change will require that ecosystem libraries like Swashbuckle and NSwag respect the new EndpointApiDescription
class and that some conventions be adopted around how certain properties are represented. For example, since the Examples
property is not supported as a strongly-typed field, conventions will need to be established around storing it in description.Parameters["todo"].Items["Examples"]
.
Back to the WitApiDescription
method, behind the scenes it registers the delegate provided to the user as part of the metadata associated with the endpoint.
public static RouteHandlerBuilder WithApiDescription(this RouteHandlerBuilder builder, Action<EndpointApiDescription> configureSchema)
{
builder.WithMetadata(configureSchema);
}
The configureSchema
method is called from the EndpointMetadataApiDescriptionProvider
after the API description has been constructed. In order to support this scenario, we will need to refactor the provider so that the following logic can be invoked.
foreach (var httpMethod in httpMethodMetadata.HttpMethods)
{
var schema = CreateDefaultApiSchema(routeEndpoint, httpMethod, ...);
var withApiDescription = routeEndpoint.Metadata.GetMetadata<Action<EndpointApiDescription>>();
if (withApiDescription is not null)
{
withApiDescription(schema);
}
var apiDescription = CreateApiDescriptionFromSchema(modifiedSchema);
context.Results.Add(apiDescription);
}
The CreateApiDescriptionFromSchema
maps the new EndpointApiDescription
type to the existing ApiDescription
type by using the following mappings:
- We maintain the existing semantics for setting endpoint-related like
GroupName
andRelativePath
- We maintain the existing semantics for setting things like parameter types and content-types associated with responses
EndpointApiDescription.Parameters
andEndpointApiDescription.Responses
get populated intoApiDescription.Properties
With this in mind, the previous flow for API generation looked like this:
flowchart LR
A[Attributes]-->B[Metadata]
C[Extension Methods]-->B[Metadata]
B[Metadata]-->D[EndpointAPIDescriptionProvider]
D[EndpointAPIDescriptionProvider]-->E[ApiDescription]
To the following:
flowchart LR
A[Attributes]-->B[Metadata]
C[Extension Methods]-->B[Metadata]
B[Metadata]-->D[EndpointAPIDescriptionProvider]
D[EndpointAPIDescriptionProvider]-->E[EndpointApiDescription]
E[EndpointApiDescription]-->F[WithApiDescription]-->G[EndpointApiDescription]
G[EndpointApiDescription]-->H[ApiDescription]
Unknowns
There are some considerations to be made around how this feature interacts with route groups. For now, it might be sufficient for our purposes to assume that the WithApiDescription
method cannot be invoked on a RouteGroup
since API-descriptions are endpoint-specific descriptors.
If we do want to provide support for WithApiDescription
on route groups, we can work with one of two patterns:
- The delegate registered via
WithApiDescription
is invoked on every endpoint defined within a group - The route group API provides a strategy for applying a callback on a single endpoint within a group
Alternatives Considered
Another design considered for this problem was a more builder-style approach for the WithApiDescription
approach where in users could modify the API description using a set of ConfigureX
methods.
app.MapPut(
"/api/todos/{id}",
(int id, [Description("This is a default")] Todo updatedTodo, TodosService todosService) => {
todosService.Update(id, updatedTodo);
})
.WithName("GetFoo")
.WithDescription("This is an endpoint for updating a todo")
// Builder pattern
.WithApiDescription(builder => {
builder.ConfigureParameter("updatedTodo", parameterBuilder => {
parameterBuilder.AddExample(new Todo { ... })
parameterBuilder.SetDescription("New description")
});
builder.ConfigureParameter("id", parameterBuilder => {
parameterBuilder.SetDescription("The id associated with the todo")
});
builder.ConfigureResponse(StatusCodes.200OK, responseBuilder => {
responseBuilder.AddExample(new Todo { ... });
})
builder.ConfigureEndpoint(endpointBuilder => {
endpointBuilder.SetName("GetBar");
endpointBuilder.SetDescription("New Description");
});
});
Although this alternative does come with benefits, including, some of the drawbacks include:
- Needing to define a new builder type for the API
- The API itself is not particularly ergonomic
- The experience for mutating existing properties is a little opaque
Issue Analytics
- State:
- Created 2 years ago
- Reactions:2
- Comments:5 (5 by maintainers)
Top GitHub Comments
Closing to pursue bigger dreams.
@captainsafia do you still expect for this proposal to be reviewed? If not, let’s remove the
api-ready-for-review
label.