Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can I use Content Negotiation to return a View to browers and JSON to API calls in ASP.NET Core?

I've got a pretty basic controller method that returns a list of Customers. I want it to return the List View when a user browses to it, and return JSON to requests that have application/json in the Accept header.

Is that possible in ASP.NET Core MVC 1.0?

I've tried this:

    [HttpGet("")]
    public async Task<IActionResult> List(int page = 1, int count = 20)
    {
        var customers = await _customerService.GetCustomers(page, count);

        return Ok(customers.Select(c => new { c.Id, c.Name }));
    }

But that returns JSON by default, even if it's not in the Accept list. If I hit "/customers" in my browser, I get the JSON output, not my view.

I thought I might need to write an OutputFormatter that handled text/html, but I can't figure out how I can call the View() method from an OutputFormatter, since those methods are on Controller, and I'd need to know the name of the View I wanted to render.

Is there a method or property I can call to check if MVC will be able to find an OutputFormatter to render? Something like the following:

[HttpGet("")]
public async Task<IActionResult> List(int page = 1, int count = 20)
{
    var customers = await _customerService.GetCustomers(page, count);
    if(Response.WillUseContentNegotiation)
    {
        return Ok(customers.Select(c => new { c.Id, c.Name }));
    }
    else
    {
        return View(customers.Select(c => new { c.Id, c.Name }));
    }
}
like image 826
Jamie Penney Avatar asked Oct 28 '16 08:10

Jamie Penney


Video Answer


1 Answers

I liked Daniel's idea and felt inspired, so here's a convention based approach as well. Because often the ViewModel needs to include a little bit more 'stuff' than just the raw data returned from the API, and it also might need to check different stuff before it does its work, this will allow for that and help in following a ViewModel for every View principal. Using this convention, you can write two controller methods <Action> and <Action>View both of which will map to the same route. The constraint applied will choose <Action>View if "text/html" is in the Accept header.

public class ContentNegotiationConvention : IActionModelConvention
{
    public void Apply(ActionModel action)
    {
        if (action.ActionName.ToLower().EndsWith("view"))
        {
            //Make it match to the action of the same name without 'view', exa: IndexView => Index
            action.ActionName = action.ActionName.Substring(0, action.ActionName.Length - 4);
            foreach (var selector in action.Selectors)                
                //Add a constraint which will choose this action over the API action when the content type is apprpriate
                selector.ActionConstraints.Add(new TextHtmlContentTypeActionConstraint());                
        }
    }
}

public class TextHtmlContentTypeActionConstraint : ContentTypeActionConstraint
{
    public TextHtmlContentTypeActionConstraint() : base("text/html") { }
}

public class ContentTypeActionConstraint : IActionConstraint, IActionConstraintMetadata
{
    string _contentType;

    public ContentTypeActionConstraint(string contentType)
    {
            _contentType = contentType;
    }

    public int Order => -10;

    public bool Accept(ActionConstraintContext context) => 
            context.RouteContext.HttpContext.Request.Headers["Accept"].ToString().Contains(_contentType);        
}

which is added in startup here:

    public void ConfigureServices(IServiceCollection services)
    {            
        services.AddMvc(o => { o.Conventions.Add(new ContentNegotiationConvention()); });
    }

In you controller, you can write method pairs like:

public class HomeController : Controller
{
    public ObjectResult Index()
    {
        //General checks

        return Ok(new IndexDataModel() { Property = "Data" });
    }

    public ViewResult IndexView()
    {
        //View specific checks

        return View(new IndexViewModel(Index()));
    }
}

Where I've created ViewModel classes meant to take the output of API actions, another pattern which connects the API to the View output and reinforces the intent that these two represent the same action:

public class IndexViewModel : ViewModelBase
{
    public string ViewOnlyProperty { get; set; }
    public string ExposedDataModelProperty { get; set; }

    public IndexViewModel(IndexDataModel model) : base(model)
    {
        ExposedDataModelProperty = model?.Property;
        ViewOnlyProperty = ExposedDataModelProperty + " for a View";
    }

    public IndexViewModel(ObjectResult apiResult) : this(apiResult.Value as IndexDataModel) { }
}

public class ViewModelBase
{
    protected ApiModelBase _model;

    public ViewModelBase(ApiModelBase model)
    {
        _model = model;
    }
}

public class ApiModelBase { }

public class IndexDataModel : ApiModelBase
{
    public string Property { get; internal set; }
}
like image 84
Erikest Avatar answered Sep 27 '22 15:09

Erikest