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

后端 未结 3 1480
庸人自扰
庸人自扰 2021-02-07 15:33

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 <

相关标签:
3条回答
  • 2021-02-07 15:42

    I haven't tried this, but could you just test for that content type in the request and return accordingly:

                var result = customers.Select(c => new { c.Id, c.Name });
                if (Request.Headers["Accept"].Contains("application/json"))
                    return Json(result);
                else
                    return View(result);
    
    0 讨论(0)
  • 2021-02-07 15:55

    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; }
    }
    
    0 讨论(0)
  • 2021-02-07 15:58

    I think this is a reasonable use case as it would simplify creating APIs that return both HTML and JSON/XML/etc from a single controller. This would allow for progressive enhancement, as well as several other benefits, though it might not work well in cases where the API and Mvc behavior needs to be drastically different.

    I have done this with a custom filter, with some caveats below:

    public class ViewIfAcceptHtmlAttribute : Attribute, IActionFilter
    {
        public void OnActionExecuted(ActionExecutedContext context)
        {
            if (context.HttpContext.Request.Headers["Accept"].ToString().Contains("text/html"))
            {
                var originalResult = context.Result as ObjectResult;
                var controller = context.Controller as Controller;
                if(originalResult != null && controller != null)
                {
                    var model = originalResult.Value;
                    var newResult = controller.View(model);
                    newResult.StatusCode = originalResult.StatusCode;
                    context.Result = newResult;
                }
            }
        }
    
        public void OnActionExecuting(ActionExecutingContext context)
        {
    
        }
    }
    

    which can be added to a controller or action:

    [ViewIfAcceptHtml]
    [Route("/foo/")]
    public IActionResult Get(){ 
            return Ok(new Foo());
    }
    

    or registered globally in Startup.cs

    services.AddMvc(x=>
    { 
       x.Filters.Add(new ViewIfAcceptHtmlAttribute());
    });
    

    This works for my use case and accomplishes the goal of supporting text/html and application/json from the same controller. I suspect isn't the "best" approach as it side-steps the custom formatters. Ideally (in my mind), this code would just be another Formatter like Xml and Json, but that outputs Html using the View rendering engine. That interface is a little more involved, though, and this was the simplest thing that works for now.

    0 讨论(0)
提交回复
热议问题