Skip to content

Support WithApiDescription extension method for minimal APIsΒ #40084

Closed
@captainsafia

Description

@captainsafia

Summary

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 and RelativePath
  • We maintain the existing semantics for setting things like parameter types and content-types associated with responses
  • EndpointApiDescription.Parameters and EndpointApiDescription.Responses get populated into ApiDescription.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]
Loading

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]
Loading

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

Metadata

Metadata

Assignees

Labels

Priority:1Work that is critical for the release, but we could probably ship withoutenhancementThis issue represents an ask for new feature or an enhancement to an existing onefeature-openapiold-area-web-frameworks-do-not-use*DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions