1

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?

Update1:

Implementing different output formatters for IActionResult from controller or IFilter (including IExceptionFilter) is not very difficult. What I want is to be able to either set IActionResult or use IOutputFormatter related to Action identified by UseRouting for Authentication/Authorization error or IAuthorizationHandler, but looks like all those auth steps are invoked before either ActionContext or IOutputFormatter is invoked. So 2 approaches I see now:

  1. hack auth code to "always pass" and handle HttpContext.Items["MyRealAuthResult"] object in IActionFilter
  2. expose V1OutputFormatter/V2OutputFormatter in a static field and duplicate selection logic in HandleChallengeAsync/HandleForbiddenAsync based on to what controller/action it was routed from UseRouting step.

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" });
    }
}

2 Answers2

0

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:

How to read ASP.NET Core Response.Body?

General Grievance
  • 4,555
  • 31
  • 31
  • 45
Neil W
  • 7,670
  • 3
  • 28
  • 41
0

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
{
    private static readonly SystemTextJsonOutputFormatter formatter = new SystemTextJsonOutputFormatter(new()
    {
        ReferenceHandler = ReferenceHandler.IgnoreCycles,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    });

    public override void OnActionExecuted(ActionExecutedContext context)
    {
        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;
        }
    }
davidfowl
  • 37,120
  • 7
  • 93
  • 103
thanzeel
  • 431
  • 4
  • 12
  • You should reuse the JsonSerializerOptions object, not doing so results in bad performance at scale. – davidfowl Jan 30 '23 at 05:39
  • @davidfowl, doesnt the above code add the json serilaizer object there like this `objectResult.Formatters.Add(formatter);`. – thanzeel Jan 30 '23 at 06:24
  • 1
    You're adding to the formatter list per ObjectResult instance. This is allocating a new formatting per request. Very inefficient. – davidfowl Jan 30 '23 at 15:33
  • Added my thoughts about this in Update1 in main topic. My main problem is how to use same something inherited from TextOutputFormatter for IActionResult and Authentication/Authorization errors from either UseAuthentication/UseAuthorization or IAuthorizationFilter. – Andrey Biryulin Jan 30 '23 at 16:34
  • @davidfowl, thanks you very much cor sharing such knowledge. Is there better way that u suggest could work? – thanzeel Jan 30 '23 at 16:40