dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.45k stars 10.03k forks source link

Consumes attribute/filter in controller-based web app does not set MIME type with the value specified #57090

Closed mikekistler closed 3 months ago

mikekistler commented 3 months ago

Is there an existing issue for this?

Describe the bug

In a controller-based app, the Consumes attribute/filter can be used to specify the supported request content types. But these content-types do not appear in the requestBody field of the operation in the generated OpenAPI document.

As an example, for the following endpoint:

    [Consumes("application/octet-stream")]
    [HttpPost("non-json-body")]
    public IResult NonJsonBody(
        [Description("A non-Json request body")] [FromBody] byte[] body
    )
    {
        return Results.Ok("Good to go");
    }

the corresponding operation in the generated OpenAPI document is:

  "/request-bodies/non-json-body": {
    "post": {
      "tags": [
        "RequestBodies"
      ],
      "requestBody": {
        "description": "A non-Json request body",
        "content": {
          "application/json": {
            "schema": {
              "type": "string",
              "format": "byte"
            }
          }
        },
        "required": true
      },
      "responses": {
        "200": {
          "description": "OK"
        }
      }
    }
  },

Note that the MIME type shown in the request body is "application/json" and not "application/octet-stream".

Expected Behavior

The content-type specified in the Consumes attribute is the one shown in the requestBody field of the corresponding operation in the generated OpenAPI.

Steps To Reproduce

I have a recreate in the bug-57090 branch of my aspnet-openapi-examples project:

https://github.com/mikekistler/aspnet-openapi-examples/tree/bug-57090

Exceptions (if any)

No response

.NET Version

9.0.100-rc.1.24380.1

Anything else?

No response

captainsafia commented 3 months ago

So, I believe the behavior here is as expected, albeit confusing if you're not familiar with MVC's input formatters. When we discussed this offline, I shared my hunch that this might be related to the fact that an input formatter for application/octet-stream isn't registered in the application. We tried to confirm this hunch by using another content-type that might apply (text/plain) but that didn't appear to work either...

...and that's because there's no input formatter configured for the text/plain content-type by default. The only input formatters MVC will configure by default are for application/json.

By default, MVC's ApiExplorer layer will check the set of content-types provided via the Consumes attributes against the registered input formatters here:

https://github.com/dotnet/aspnetcore/blob/6dc454f9079ff8d4b268b7e307dd14a05917e903/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs#L417

Anything that doesn't pass the vibe check doesn't actually end up populating the response metadata. This functionally checks out with the way the API handles incoming requests with the application/octet-stream type. If no formatter is registered, the request will be rejected with a 415.

POST /request-bodies/non-json-body HTTP/1.1
Content-Length: 0
Content-Type: application/octet-stream
Host: localhost:5159
User-Agent: HTTPie

HTTP/1.1 415 Unsupported Media Type
Connection: close
Content-Type: application/problem+json; charset=utf-8
Date: Wed, 31 Jul 2024 02:45:52 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-77d5052a0a43cbeda676098472cd420e-3493138586cecc94-00"}

You can resolve this by implementing an input formatter like in the stub below:

using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.OpenApi.Models;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddSwaggerGen();
builder.Services.AddControllers(options => {
    options.InputFormatters.Add(new StreamInputFormatter());
});

builder.Services.AddOpenApi(options => {
    options.UseTransformer<InfoTransformer>();
    options.UseTransformer((document, context, cancellationToken) =>
    {
        document.Servers = new List<OpenApiServer>
        {
            new OpenApiServer
            {
                Url = "http://localhost:5000",
                Description = "Local development server"
            }
        };
        return Task.CompletedTask;
    });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
    app.MapOpenApi();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

public class StreamInputFormatter : IInputFormatter, IApiRequestFormatMetadataProvider
{
    public bool CanRead(InputFormatterContext context) => 
        context.HttpContext.Request.ContentType is { } contentType &&
        contentType == "application/octet-stream";

    public IReadOnlyList<string>? GetSupportedContentTypes(string contentType, Type objectType)
    {
        return ["application/octet-stream"];
    }

    public Task<InputFormatterResult> ReadAsync(InputFormatterContext context)
        => InputFormatterResult.SuccessAsync(new byte[1] { 0xFF });
}

That gets the desired result as far as actual runtime behavior:

POST /request-bodies/non-json-body HTTP/1.1
Content-Length: 0
Content-Type: application/octet-stream
Host: localhost:5159
User-Agent: HTTPie

HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Wed, 31 Jul 2024 02:50:08 GMT
Server: Kestrel
Transfer-Encoding: chunked

"Good to go"

And the generated OpenAPI:

"/request-bodies/non-json-body": {
      "post": {
        "tags": [
          "RequestBodies"
        ],
        "requestBody": {
          "description": "A non-Json request body",
          "content": {
            "application/octet-stream": {
              "schema": {
                "type": "string",
                "format": "byte"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },