Могу ли я использовать Content Negotiation, чтобы вернуть View to browers и JSON в вызовы API в ASP.NET Core?

У меня есть довольно простой метод контроллера, который возвращает список клиентов. Я хочу, чтобы он возвращал представление списка, когда пользователь просматривает его, и возвращает JSON для запросов с application/json в заголовке Accept.

Возможно ли это в ASP.NET Core MVC 1.0?

Я пробовал это:

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

Но это возвращает JSON по умолчанию, даже если это не в списке Accept. Если я ударил "/клиентов" в своем браузере, я получаю вывод JSON, а не мой взгляд.

Мне казалось, что мне нужно написать OutputFormatter, который обрабатывал text/html, но я не могу понять, как я могу вызвать метод View() из OutputFormatter, так как эти методы находятся на Controller, и Мне нужно было бы узнать имя View, которое я хотел бы сделать.

Есть ли способ или свойство, которое я могу вызвать, чтобы проверить, сможет ли MVC найти OutputFormatter для рендеринга? Что-то вроде следующего:

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

Ответ 1

Я не пробовал это, но вы могли бы просто проверить этот тип контента в запросе и соответственно возвратить:

            var result = customers.Select(c => new { c.Id, c.Name });
            if (Request.Headers["Accept"].Contains("application/json"))
                return Json(result);
            else
                return View(result);

Ответ 2

Я думаю, что это разумный вариант использования, поскольку он упростит создание API, которые возвращают как HTML, так и JSON/XML/etc из одного контроллера. Это обеспечило бы прогрессивное улучшение, а также ряд других преимуществ, хотя это может не сработать в случаях, когда поведение API и Mvc должно быть существенно иным.

Я сделал это с помощью настраиваемого фильтра с некоторыми оговорками ниже:

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)
    {

    }
}

который может быть добавлен к контроллеру или действию:

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

или зарегистрирован глобально в Startup.cs

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

Это работает для моего использования и выполняет задачу поддержки text/html и application/json от одного и того же контроллера. Я подозреваю, что это не "лучший" подход, поскольку он дополняет пользовательские форматы. В идеале (на мой взгляд), этот код будет просто другим Formatter, таким как Xml и Json, но это выводит Html с помощью механизма просмотра View. Однако этот интерфейс немного запутан, и это было самое простое, что работает сейчас.

Ответ 3

Мне понравилась идея Даниэля и была вдохновлена, поэтому здесь также был основан подход на основе конвенции. Поскольку часто ViewModel должен включать в себя немного больше "материала", чем просто необработанные данные, возвращаемые из API, и ему также может потребоваться проверить разные вещи до того, как он выполнит свою работу, это позволит это и поможет в использовании ViewModel для каждого принципала просмотра. Используя это соглашение, вы можете написать два метода контроллера <Action> и <Action>View, оба из которых будут отображаться на один и тот же маршрут. Применяемое ограничение выберет <Action>View, если в заголовке Accept содержится текст /html.

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

который добавляется при запуске здесь:

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

В контроллере вы можете написать пары методов, например:

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

Где я создал классы ViewModel, предназначенные для вывода результатов действий API, другой шаблон, который соединяет API с выходом View и усиливает намерение, что эти два представляют одно и то же действие:

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