Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Description to ProducesResponseType (and others) for better OpenAPI documents #55656

Open
sander1095 opened this issue May 10, 2024 · 14 comments · May be fixed by #58193
Open

Add Description to ProducesResponseType (and others) for better OpenAPI documents #55656

sander1095 opened this issue May 10, 2024 · 14 comments · May be fixed by #58193
Labels
api-approved API was approved in API review, it can be implemented area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates

Comments

@sander1095
Copy link

sander1095 commented May 10, 2024

Background and Motivation

The purpose of this API Change is to make it easier for developers to add the Description properties to their OpenAPI documents using the [ProducesResponseType], [Produces] and [ProducesDefaultResponseType] attributes in controller actions.

Developers currently use these properties to enrich the OpenAPI document of the API. It tells the reader the possible return values from an endpoint, like a status code, response model type and content type:

See code + OpenAPI example
[HttpGet("{id:int:min(1)}")]
[ProducesResponseType<Talk>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Talk> GetTalk(int id)
{
    var talk = _talks.FirstOrDefault(x => x.Id == id);
    if (talk == null)
    {
        return NotFound();
    }

    return Ok(talk);
}

OpenAPI document is generated using NSwag (I used a JSON to YAML converter)

paths:
  "/api/talks/{id}":
    get:
      tags:
      - Talks
      operationId: Talks_GetTalk
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          format: int32
        x-position: 1
      responses:
        '200':
          description: ''
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Talk"
        '404':
          description: ''
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ProblemDetails"

However, there is currently no way to map OpenAPIs description using attributes, which is the easiest way to enrich your methods with OpenAPI information which also works well with the OpenAPI analyzers.

It's important to make it easy for developers to set up the Description property because this adds a lot of important information to a specific response. For example, if an API returns HTTP 422, it would be useful to add a description explaining why/when this error is returned. Without this, handling this error in the client becomes more difficult because the meaning is lost.

There are other ways to set the Description right now, but I'm not a big fan of them. I explain why in Alternative designs below.

I've wanted this feature in ASP.NET Core for a while. I've created this API proposal after talking to @captainsafia about this at the MVP Summit 2024, and she agreed that this would be a worthy addition. I had already created #54064 for this at some point. I hope the rest of the team/community agrees!

Proposed API

// Assembly: Microsoft.AspNetCore.Mvc.Core

namespace Microsoft.AspNetCore.Mvc.ApiExplorer;

public interface IApiResponseMetadataProvider : IFilterMetadata
{
+	string? Description { get; }
}
// Assembly: Microsoft.AspNetCore.Mvc.Core

namespace Microsoft.AspNetCore.Mvc;

public class ProducesAttribute : Attribute, IResultFilter, IOrderedFilter, IApiResponseMetadataProvider
{
+	public string? Description { get; set; }
}
// Assembly: Microsoft.AspNetCore.Mvc.Core

namespace Microsoft.AspNetCore.Mvc;

public class ProducesResponseTypeAttribute : Attribute, IResultFilter, IOrderedFilter, IApiResponseMetadataProvider
{
+	public string? Description { get; set; }
}
// Assembly: Microsoft.AspNetCore.Mvc.Abstractions

namespace Microsoft.AspNetCore.Mvc.ApiExplorer;

public class ApiResponseType
{
+   public string? Description { get; set; }

     // Existing code here
}
// Assembly:  Microsoft.AspNetCore.Http.Abstractions;

namespace Microsoft.AspNetCore.Http.Metadata;

public interface IProducesResponseTypeMetadata
{
+	string? Description { get; }
}
// Assembly:  Microsoft.AspNetCore.Http.Abstractions;

namespace Microsoft.AspNetCore.Http;

public sealed class ProducesResponseTypeMetadata : IProducesResponseTypeMetadata
{
+	string? Description { get; private set; }
}
// Assembly: Microsoft.AspNetCore.Routing

namespace Microsoft.AspNetCore.Http;

public static class OpenApiRouteHandlerBuilderExtensions
{
+   public static RouteHandlerBuilder Produces(
+       this RouteHandlerBuilder builder,
+       int statusCode,
+       Type? responseType = null,
+		 string? description = null,
+       string? contentType = null,
+        params string[] additionalContentTypes)

+   public static RouteHandlerBuilder Produces<T>(
+       this RouteHandlerBuilder builder,
+       int statusCode,
+		 string? description = null,
+       string? contentType = null,
+        params string[] additionalContentTypes)

+ public static RouteHandlerBuilder ProducesProblem(
+		this RouteHandlerBuilder builder,
+		int statusCode,
+		string? description = null,
+		string? contentType = null)

+ public static TBuilder ProducesProblem<TBuilder>(
+		this TBuilder builder,
+		int statusCode,
+		string? description = null,
+		string? contentType = null)

+    public static RouteHandlerBuilder ProducesValidationProblem(
+        this RouteHandlerBuilder builder,
+        int statusCode = StatusCodes.Status400BadRequest,
+		  string? description = null,
+        string? contentType = null)

+    public static TBuilder ProducesValidationProblem(
+        this TBuilder builder,
+        int statusCode = StatusCodes.Status400BadRequest,
+		  string? description = null,
+        string? contentType = null)
}

Usage Examples

-/// <response code="200">A talk entity when the request is succesful</response>
-/// <response code="422">The entity is unprocessable because SOME_REASON_HERE</response>
[HttpGet("{id:int:min(1)}")]
-[Produces<Talk>]
+[Produces<Talk>(Description = "A talk entity when the request is succesful")]
-[ProducesResponseType<Talk>(StatusCodes.Status200OK)]
+[ProducesResponseType<Talk>(StatusCodes.Status200OK, Description = "A talk entity when the request is succesful")]
-[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Description = "The entity is unprocessable SOME_REASON_HERE")]
// I do not think there is currently a way to specify a description for the default error response type, so this is a nice bonus!
-[ProducesDefaultResponseType]
+[ProducesDefaultResponseType(Description = "The response for all other errors that might be thrown")]
public ActionResult<Talk> GetTalk(int id)
{
    var talk = _talks.FirstOrDefault(x => x.Id == id);
    if (talk == null)
    {
        return NotFound();
    }

    if (someCondition)
    {
        return UnprocessableEntity();
    }

    return Ok(talk);
}

Alternative Designs

XML comments

An alternative design is to do nothing and use the built-in solution, which are XML comments:

/// <response code="201">Returns the newly created item</response>
/// <response code="400">If the item is null</response>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(TodoItem item)
{
    _context.TodoItems.Add(item);
    await _context.SaveChangesAsync();

    return CreatedAtAction(nameof(Get), new { id = item.Id }, item);
}

This will fill the description properties with the values from the <response> tags.

However:

  • This becomes quite verbose when you're combining [ProducesResponseType] (and the other ones mentioned above) and XML comments.
  • We're duplicating XML comments and produces response type attributes, which isn't ideal because now we need to manage the same code twice, which will get out of sync over time.
  • I do not believe that the <response> can set a description for [ProducesDefaultResponseType]

To summarize: I do not think that XML comments is the correct one.

Library specific attributes

Both Swashbuckle.AspNetCore and NSwag have their own versions of [ProducesResponseType] called [SwaggerResponse] that do support the OpenAPI Description property.

However, there are several downsides to this:

  • They are library specific, wheras [ProducesResponseType] is library agnostic. By using [ProducesResponseType] I could switch from Swashbuckle to NSwag and the OpenAPI document generation should keep working, which isn't guaranteed if I used a library specific attribute.
  • Trying to combine NSwag's [SwaggerResponse] with [ProducesResponseType] to set up response descriptions doesn't work. When [SwaggerResponse] is used, all instances of [ProducesResponseType] are ignored for that method.
    • This means that some methods need to use [SwaggerResponse] for description support and others can still use [ProducesResponseType], which makes things more difficult to read because their method signature is different.
  • The OpenAPI analyzer doesn't work with library-specific attributes like [SwaggerResponse], which means I need to disable those warnings for specific methods, which is annoying and makes code more difficult to read.

If ASP.NET Core's [ProducesResponseType] would support things like Description, the library-specific versions attributes might not be needed anymore, which makes it even easier to switch libraries in the future.

Risks

Swashbuckle.AspNetCore's SwaggerResponse [ProducesResponseType] and already has its own Description property, which means that this change might cause some issues for them. However, .NET 9 won't ship Swashbuckle anymore by default, reducing the impact this has. Regardless, Swashbuckle's authors should be informed of this change.

@sander1095 sander1095 added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label May 10, 2024
@dotnet-issue-labeler dotnet-issue-labeler bot added the area-web-frameworks *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels label May 10, 2024
@sander1095
Copy link
Author

Note: In case this proposal gets accepted, I would love to be the "main author" for getting this implemented in ASP.NET Core (Though help would definitely be welcome!).

@martincostello
Copy link
Member

martincostello commented May 10, 2024

Swashbuckle.AspNetCore's SwaggerResponse [ProducesResponseType] and already has its own Description property, which means that this change might cause some issues for them. However, .NET 9 won't ship Swashbuckle anymore by default, reducing the impact this has. Regardless, Swashbuckle's authors should be informed of this change.

Consider us informed 😄

We'll happily accept a PR (once we have a branch for .NET 9 10 support open) to add support for this if it's approved.

@captainsafia captainsafia added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed area-web-frameworks *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels labels May 10, 2024
@captainsafia
Copy link
Member

@sander1095 Thanks for getting this issue together!

The one gap I notice is that this proposal primarily targets the ProducesResponseType attributes associated with MVC and not the Produces metadata/attributes/extension methods for minimal API.

@sander1095
Copy link
Author

sander1095 commented May 10, 2024

I am unfamiliar with using attributes in minimal API's, I thought it was all extension method based on the MapGet/MapPost/MapEtc.. methods.

Description is already a first class citizen in minimal API.

However, I have nothing against adding more OpenAPI description support in both Controllers and Minimal API's.😁

EDIT: I just realized that I got the the Description of an endpoint confused with the Description of a response. Woops! In case this is not supported yet in the minimal api model, I 100% believe it should be added!

@captainsafia captainsafia added api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Aug 15, 2024
@amcasey
Copy link
Member

amcasey commented Aug 15, 2024

[API Review]

  • Current behavior is that the Description is blank
  • The interface property can default to null
    • Not netstandard, so DIM is available
  • Does ProducesResponseTypeMetadata need a constructor overload?
    • It doesn't seem strictly necessary - it could be on the private constructor
    • BUT, we have to cross assembly boundaries
    • Maybe the property should be init?
      • That wouldn't follow the local convention
      • Validation isn't required
    • Next time we add a property, it will be optional and any validation can be performed in a customer init
    • We probably don't want to make the existing properties init since they're core characteristics of the object and are validated
  • Are nullable properties dangerous in attributes?
    • Should be okay if they're reference types
  • Follow the overload update guidelines
    • Try not to reorder parameters (might be stuck because of params)
    • Option: don't use overloading - use more explicit names
    • We need to run it through the compiler and try again
    • Hard to see how this could not be a breaking change for an existing call site passing three strings at the end
    • We don't really want to add six new methods (for Produces, ProducesProblem, and ProducesValidationProblem)
    • Can we just make people call WithMetadata?
      • builder.WithMetadata(new ProducesResponseTypeMetadata(statusCode, typeof(Todo), contentTypes) { Description = "foo" });
var app = WebApplication.Create();

app.MapGet("/todo", () => new Todo())
    .Produces<Todo>(StatusCodes.Status200OK);
// Requires no new extension methods
app.MapGet("/todo", () => new Todo())
    .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"]) { Description = "Get a todo item" });
// Requires new Produces overload that takes ProducesResponseTypeMetadata
app.MapGet("/todo", () => new Todo())
    .Produces(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), new[] { "application/json" }) { Description = "Get a todo item" });

@amcasey
Copy link
Member

amcasey commented Aug 15, 2024

// Assembly: Microsoft.AspNetCore.Mvc.Core

namespace Microsoft.AspNetCore.Mvc.ApiExplorer;

public interface IApiResponseMetadataProvider : IFilterMetadata
{
+	string? Description { get; }
}
// Assembly: Microsoft.AspNetCore.Mvc.Core

namespace Microsoft.AspNetCore.Mvc;

public class ProducesAttribute : Attribute, IResultFilter, IOrderedFilter, IApiResponseMetadataProvider
{
+	public string? Description { get; set; }
}
// Assembly: Microsoft.AspNetCore.Mvc.Core

namespace Microsoft.AspNetCore.Mvc;

public class ProducesResponseTypeAttribute : Attribute, IResultFilter, IOrderedFilter, IApiResponseMetadataProvider
{
+	public string? Description { get; set; }
}
// Assembly: Microsoft.AspNetCore.Mvc.Abstractions

namespace Microsoft.AspNetCore.Mvc.ApiExplorer;

public class ApiResponseType
{
+   public string? Description { get; set; }

     // Existing code here
}
// Assembly:  Microsoft.AspNetCore.Http.Abstractions;

namespace Microsoft.AspNetCore.Http.Metadata;

public interface IProducesResponseTypeMetadata
{
+	string? Description { get; }
}
// Assembly:  Microsoft.AspNetCore.Http.Abstractions;

namespace Microsoft.AspNetCore.Http;

public sealed class ProducesResponseTypeMetadata : IProducesResponseTypeMetadata
{
+	string? Description { get; init; }
}

API Approved!

@amcasey amcasey added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Aug 15, 2024
@captainsafia
Copy link
Member

@sander1095 The above API was approved in case you'd like to update your draft PR.

Note: we're leaving the question on how to handle the Produces extension method for minimal APIs open for now so the delta for minimal is only focused on changing the interfaces/concrete implementations.

@sander1095
Copy link
Author

sander1095 commented Aug 16, 2024

Thanks Safia! I'll look into this. It seems like lots of @amcasey 's code suggestions are already part of my branch.

My ongoing work can be seen here. I'm extremely excited to contribute to ASP.NET Core in this area! ❤️

@sander1095
Copy link
Author

sander1095 commented Aug 16, 2024

@captainsafia @amcasey

Safia updated the PR description with new methods in OpenApiRouteHandlerBuilderExtensions, but the comment from @amcasey says:

We don't really want to add six new methods (for Produces, ProducesProblem, and ProducesValidationProblem)

Which one is it? Please take a look at my work so you can give specific feedback :)

main...sander1095:aspnetcore:main

Also, could you help me with these issues? I read the docs about overloading public methods and I believe i've updated the code correctly, but I still get the following errors:

/root/projects/personal/aspnetcore/src/Http/Http.Abstractions/src/Metadata/ProducesResponseTypeMetadata.cs(25,12): error RS0026: Symbol 'ProducesResponseTypeMetadata' violates the backcompat requirement: 'Do not add multiple overloads with optional parameters'. See 'https://github.com/dotnet/roslyn/blob/main/docs/Adding%20Optional%20Parameters%20in%20Public%20API.md' for details. (https://github.com/dotnet/roslyn/blob/main/docs/Adding%20Optional%20Parameters%20in%20Public%20API.md)

/root/projects/personal/aspnetcore/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt(16,1): error RS0050: Symbol 'Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.ProducesResponseTypeMetadata(int statusCode, System.Type? type = null, string![]? contentTypes = null) -> void' is marked as removed but it isn't deleted in source code (https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Should I simply use #pragma warning restore RS0026 to ignore the first one, just like OpenApiRouteHandlerBuilderExtensions does? Or is another approach desired?

I do not know how to solve the 2nd issue :)

One more question, if you wouldn't mind: Would you like me to write unit tests in all of these classes that receive the new Description property and test if that property is set correctly when using the constructor and/or property?

Finally, could you give me some instructions on how I could test my changes? I'd love to create a new ASP.NET Core project locally and test out my changes with these new Description properties I'm adding. If there are docs I missed, feel free to point me towards them!

@amcasey
Copy link
Member

amcasey commented Aug 16, 2024

Hey, @sander1095! My comments on the API proposal were on behalf of the API reviewers as a group - @captainsafia is the best person to help you move this forward.

@captainsafia
Copy link
Member

Which one is it? Please take a look at my work so you can give specific feedback :)

That part of the API proposal was rejected because it would've been difficult to update the existing Produces extension methods in a way that is source compatible. The only API that was approved is the new Description property on the interfaces and concrete implementations.

Also, could you help me with these issues? I read the docs about overloading public methods and I believe i've updated the code correctly, but I still get the following errors:

This issue is happening because the description parameter was added to the constructor of the ProducesResponseTypeMetadata type. Instead of adding a new parameter to the constructor, we approved an API to add the property with an initializer so that instead of instantiating it like this:

var metadata = new ProducesResponseTypeMetadata(200, typeof(Todo), ["application/json"], "This is my description");

It is instantiated like this:

var metadata = new ProducesResponseTypeMetadata(200, typeof(Todo), ["application/json"]) { Description = "This is my description");

One more question, if you wouldn't mind: Would you like me to write unit tests in all of these classes that receive the new Description property and test if that property is set correctly when using the constructor and/or property?

For this and the other testing-related question, would you mind opening a draft PR with your change and we can iterate on testing there?

@sander1095
Copy link
Author

sander1095 commented Aug 17, 2024

@captainsafia Thanks for your swift reply! I'll do so when I get back from my holiday in a few weeks. 😁 Now that I got things running a bit more smoothly locally, it is easier to work on this issue and PR.

@A-Stapleton
Copy link

Getting description support in MinimalAPIs would be great for us!

@sander1095
Copy link
Author

sander1095 commented Sep 19, 2024

@A-Stapleton I've created #57963 for this.

I'm planning to create a PR during hacktoberfest in a few weeks for this current issue (ProducesResponseType). Iwouldn't mind working on the minimal API support, too!

cc @captainsafia : Perhaps we can start a discussion in #57963 on what changes would be needed to add description to the Produces... methods in OpenApiRouteHandlerBuilderExtensions (which were barred from this API proposal because of source incompatibility changes)? I'd love to see what can be made possible, and would be very proud to contribute to this part of ASP.NET Core!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-approved API was approved in API review, it can be implemented area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates
Projects
None yet
5 participants