Home > Mobile >  Differ IOutputFormatter per endpoint in ASP.NET Core 6
Differ IOutputFormatter per endpoint in ASP.NET Core 6

Time:01-30

I have a legacy ASP.NET Web API 2 app which must be ported to ASP.NET Core 6 and it has the following behaviour:

  1. Some controllers return responses in Pascal-case Json
  2. Some controllers return responses in camel-case Json
  3. All controllers have the same authentication/authorization, but they return different objects using different serializers for 401/403 cases.

In ASP.NET Web API 2 it was easily solved with IControllerConfiguration (to set the formatter for a controller), AuthorizeAttribute (to throw exceptions for 401/403), ExceptionFilterAttribute to set 401/403 status code and response which will be serialized using correct formatter.

In ASP.NET Core, it seems that IOutputFormatter collection is global for all controllers and it is not available during UseAuthentication UseAuthorization pipeline where it terminates in case of failure.

Best I could come up with is to always "succeed" in authentication / authorization with some failing flag in claims and add IActionFilter as first filter checking those flags, but it looks very hacky.

Is there some better approach?

Here is sample app that uses auth and has 2 endpoints:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, MvcOptionsSetup>();
builder.Services.AddAuthentication(options =>
{
    options.AddScheme<DefAuthHandler>("defscheme", "defscheme");
});
builder.Services.AddAuthorization(options => 
    options.DefaultPolicy = new AuthorizationPolicyBuilder("defscheme")
        .RequireAssertion(context =>
            // false here should result in Pascal case POCO for WeatherForecastV1Controller
            // and camel case POCO for WeatherForecastV2Controller
            context.User.Identities.Any(c => c.AuthenticationType == "secretheader"))
        .Build())
    .AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationResultHandler>();
builder.Services.AddControllers();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

public class AuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
{
    private readonly AuthorizationMiddlewareResultHandler _handler;
    public AuthorizationResultHandler()
    {
        _handler = new AuthorizationMiddlewareResultHandler();
    }

    public async Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult)
    {
        // Can't set ActionContext.Response here or use IOutputFormatter
        await _handler.HandleAsync(next, context, policy, authorizeResult);
    }
}

public class DefAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public DefAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock) { }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new List<ClaimsIdentity>();
        if (Request.Headers.ContainsKey("secretheader")) claims.Add(new ClaimsIdentity("secretheader"));
        return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(claims), "defscheme"));
    }
}

public class MvcOptionsSetup : IConfigureOptions<MvcOptions>
{
    private readonly ArrayPool<char> arrayPool;
    private readonly MvcNewtonsoftJsonOptions mvcNewtonsoftJsonOptions;
    public MvcOptionsSetup(ArrayPool<char> arrayPool, IOptions<MvcNewtonsoftJsonOptions> mvcNewtonsoftJsonOptions)
    {
        this.arrayPool = arrayPool;
        this.mvcNewtonsoftJsonOptions = mvcNewtonsoftJsonOptions.Value;
    }

    public void Configure(MvcOptions options)
    {
        options.OutputFormatters.Insert(0, new V1OutputFormatter(arrayPool, options, mvcNewtonsoftJsonOptions));
        options.OutputFormatters.Insert(0, new V2OutputFormatter(arrayPool, options, mvcNewtonsoftJsonOptions));
    }
}

public class V1OutputFormatter : NewtonsoftJsonOutputFormatter
{
    public V1OutputFormatter(ArrayPool<char> charPool, MvcOptions mvcOptions, MvcNewtonsoftJsonOptions? jsonOptions)
        : base(new JsonSerializerSettings { ContractResolver = new DefaultContractResolver() }, charPool, mvcOptions, jsonOptions) { }

    public override bool CanWriteResult(OutputFormatterCanWriteContext context)
    {
        var controllerDescriptor = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor>();
        return controllerDescriptor?.ControllerName == "WeatherForecastV1";
    }
}

public class V2OutputFormatter : NewtonsoftJsonOutputFormatter
{
    public V2OutputFormatter(ArrayPool<char> charPool, MvcOptions mvcOptions, MvcNewtonsoftJsonOptions? jsonOptions)
        : base(new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }, charPool, mvcOptions, jsonOptions) { }

    public override bool CanWriteResult(OutputFormatterCanWriteContext context)
    {
        var controllerDescriptor = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor>();
        return controllerDescriptor?.ControllerName == "WeatherForecastV2";
    }
}

[ApiController]
[Authorize]
[Route("v1/weatherforecast")]
public class WeatherForecastV1Controller : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        // This must be Pascal case
        return Ok(new WeatherForecast() { Summary = "summary" });
    }
}

[ApiController]
[Authorize]
[Route("v2/weatherforecast")]
public class WeatherForecastV2Controller : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        // This must be camel case
        return Ok(new WeatherForecast() { Summary = "summary" });
    }
}

CodePudding user response:

If there is no way to configure controllers independently, then you could use some middleware to convert output from selected controllers that meet a path-based predicate.

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapWhen(ctx => ctx.Request.Path.Containes("v2/"), cfg =>
{
   app.UseMiddleware<JsonCapitalizer>(); 
});
app.Run();

And then create a JsonCapitalizer class to convert output from any path that contains "v2/". Note, this middleware will not run if the predicate in MapWhen is not satisfied.

public class JsonCapitalizer
{
    readonly RequestDelegate _nextRequestDelegate;

    public RequestLoggingMiddleware(
        RequestDelegate nextRequestDelegate)
    {
        _nextRequestDelegate = nextRequestDelegate;
    }

    public async Task Invoke(HttpContext httpContext)
    {
        await _nextRequestDelegate(httpContext);

        // Get the httpContext.Response
        // Capitalize it
        // Rewrite the response
    }
}

There may be better ways, but that's the first that comes to mind.

The following link will help with manipulation of the response body:

https://itecnote.com/tecnote/c-how-to-read-asp-net-core-response-body/

CodePudding user response:

I also faced such a problem in ASP Core 7 and ended up with writing an attribute.

So the attribute will be applied on each Action where the response type has to be converted. You can write many an attribute for camelcase response and another attribute for pascalcase. The attribute will look like below for CamelCase

public class CamelCaseAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext context)
    {
        var formatter = new SystemTextJsonOutputFormatter(new()
        {
            ReferenceHandler = ReferenceHandler.IgnoreCycles,
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        });

        if (context.Result is ObjectResult objectResult)
        {
            objectResult.Formatters
                .RemoveType<NewtonsoftJsonOutputFormatter>();
            objectResult.Formatters.Add(formatter);
        }
        else
        {
            base.OnActionExecuted(context);
        }
    }
}

And on the Contoller Action you can use it like below

 [CamelCase]
    public async IAsyncEnumerable<ResponseResult<IReadOnlyList<VendorBalanceReportDto>>> VendorBalanceReport([FromQuery] Paginator paginator, [FromQuery] VendorBalanceReportFilter filter, [EnumeratorCancellation] CancellationToken token)
    {
        var response = _reportService.VendorBalanceReport(paginator, filter, token);

        await foreach (var emailMessage in response)
        {
            yield return emailMessage;
        }
    }
  • Related