Skip to content

Instantly share code, notes, and snippets.

@yfakariya
Created August 13, 2016 07:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save yfakariya/f376bccf06e98bf6867ff3178a3285d1 to your computer and use it in GitHub Desktop.
Save yfakariya/f376bccf06e98bf6867ff3178a3285d1 to your computer and use it in GitHub Desktop.
PoC code (not tested) to unify view as HTML response and web API response
// Copyright(c) 2016 FUJIWARA, Yusuke
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.AspNetCore.Mvc.Formatters.Internal;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace MvcCoreExp.Filters
{
// This is PoC code that allow user agents control Web response format as HTML view as well as "API" result such as JSON.
/// <summary>
/// An action filter which allows HTML view as API result.
/// </summary>
/// <remarks>
/// <para>
/// Conceptually, HTML view is just a special format for browser.
/// This filter swaps <see cref="ObjectResult"/> from controllers to <see cref="ViewResult"/> or <see cref="PartialViewResult"/>
/// if the user agent request <c>test/html</c> format.
/// </para>
/// <para>
/// This class uses <see cref="MvcOptions.RespectBrowserAcceptHeader"/> as ASP.NET Core MVC's <see cref="ObjectResult"/> handling.
/// If the user agent specifies wild card accept header, this filter assumes that the user-agent eventually request text/html.
/// This means that this class requires for API clients to specify valid accept header when <see cref="MvcOptions.RespectBrowserAcceptHeader"/> is <c>true</c>.
/// </para>
/// <para>
/// This class depends on public properties of the controller object. If the property is not declared, the <c>null</c> will be used for the property.
/// The specific properties are following:
/// <list type="bullet">
/// <item><c>ViewData</c> property which type is <see cref="ViewDataDictionary"/>.</item>
/// <item><c>TempData</c> property which type is <see cref="ITempDataDictionary"/>.</item>
/// </list>
/// </para>
/// <para>
/// This class initializes view like result as following:
/// <list type="bullet">
/// <item>Sets the <c>ViewData</c> of the result with <see cref="ViewDataDictionary"/> gotten from the controller.</item>
/// <item>Sets the <see cref="ViewDataDictionary.Model"/> of the <c>ViewData</c> with <see cref="ObjectResult.Value"/>.</item>
/// <item>Sets <c>TempData</c> of the result with <see cref="ITempDataDictionary"/> gotten from the controller.</item>
/// <item>Sets <c>ViewName</c> of the result with <see cref="DefaultViewAttribute"/> value. This value may be <c>null</c>.</item>
/// <item>Sets <c>ContentType</c> of the result with <c>"text/html"</c>.</item>
/// <item>Sets <c>StatusCode</c> of the result with <see cref="ObjectResult.StatusCode"/>.</item>
/// </list>
/// </para>
/// <para>
/// To specify view name explicitly, qualify your action method with <see cref="DefaultViewAttribute"/>.
/// </para>
/// </remarks>
/// <seealso cref="DefaultViewAttribute"/>
public sealed class ViewObjectResultFilter : IActionFilter, IAsyncActionFilter
{
private readonly FormatFilter _formatFilter;
private bool RespectBrowserAcceptHeader { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ViewObjectResultFilter"/> class.
/// </summary>
/// <param name="options">The options.</param>
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <c>null</c>.</exception>
public ViewObjectResultFilter(IOptions<MvcOptions> options)
{
this.RespectBrowserAcceptHeader = Validation.IsNotNull(options, nameof(options)).Value.RespectBrowserAcceptHeader;
this._formatFilter = new FormatFilter(options);
}
/// <inheritdoc />
public void OnActionExecuting(ActionExecutingContext context) { }
/// <inheritdoc />
public void OnActionExecuted(ActionExecutedContext context)
{
this.SetViewResultIfNeeded(Validation.IsNotNull(context, nameof(context)));
}
/// <inheritdoc />
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var postContext = await Validation.IsNotNull(next, nameof(next))();
Contract.Assert(postContext != null);
this.SetViewResultIfNeeded(postContext);
}
private void SetViewResultIfNeeded(ActionExecutedContext context)
{
var objectResult = context.Result as ObjectResult;
if (objectResult == null)
{
// Result is unspoorted type, or just view like, so nothing to do.
return;
}
var formatInParameter = this._formatFilter.GetFormat(context);
if (String.IsNullOrWhiteSpace(formatInParameter))
{
// format is not specified in query parameters and form variables, so check accept header.
if (!ContentTypeHelper.IsHtmlReqested(context, this.RespectBrowserAcceptHeader))
{
// HTML view is not requested from client, so nothing to do.
return;
}
}
else if (String.Equals(formatInParameter, ContentTypeHelper.TextHtml.MediaType, StringComparison.OrdinalIgnoreCase))
{
// Specified explicitly and it is not text/html, so nothing to do.
return;
}
// text/html is requested here.
var viewData = ControllerHelper.GetViewData(context.Controller);
if (viewData != null)
{
viewData.Model = objectResult.Value;
}
var filter = GetDefaultViewAttribute(context);
var viewName = String.IsNullOrWhiteSpace(filter.ViewName) ? null : filter.ViewName;
var tempData = ControllerHelper.GetTempData(context.Controller);
context.Result =
filter.IsPartial
? new PartialViewResult { ViewData = viewData, TempData = tempData, ViewName = viewName, ContentType = ContentTypeHelper.TextHtml.MediaType, StatusCode = objectResult.StatusCode }
: new ViewResult { ViewData = viewData, TempData = tempData, ViewName = viewName, ContentType = ContentTypeHelper.TextHtml.MediaType, StatusCode = objectResult.StatusCode } as ActionResult;
}
private static DefaultViewAttribute GetDefaultViewAttribute( FilterContext context )
{
var filters = context.Filters.OfType<DefaultViewAttribute>().ToArray();
switch ( filters.Length )
{
case 0:
{
// Nothing specified, so use default.
return DefaultViewAttribute.Default;
}
case 1:
{
return filters[ 0 ];
}
default:
{
// Invalid.
throw new InvalidOperationException( "DefaultViewAttribute must be once." );
}
}
}
}
/// <summary>
/// Specifies that this action method uses specified named view for HTML response except the method returns view like result explicitly.
/// </summary>
[AttributeUsage( AttributeTargets.Method, Inherited = false )]
public sealed class DefaultViewAttribute : Attribute, IFilterMetadata
{
/// <summary>
/// The default settings when this is not specified.
/// </summary>
internal static readonly DefaultViewAttribute Default = new DefaultViewAttribute( null );
/// <summary>
/// Gets or sets a value indicating whether the view result will be <see cref="PartialViewResult"/> instead of <see cref="ViewResult"/>.
/// </summary>
/// <value>
/// <c>true</c> if the view result will be <see cref="PartialViewResult"/> instead of <see cref="ViewResult"/>; otherwise, <c>false</c>.
/// </value>
public bool IsPartial { get; set; }
/// <summary>
/// Gets the name of the view.
/// </summary>
/// <value>
/// The name of the view. This value may be <c>null</c> or blank, and it indicates that uses default view for the controller.
/// </value>
public string ViewName { get; }
/// <summary>
/// Initializes a new instance of the <see cref="DefaultViewAttribute"/> class.
/// </summary>
/// <param name="viewName">The name of the view. This value may be <c>null</c> or blank, and it indicates that uses default view for the controller.</param>
public DefaultViewAttribute( string viewName )
{
this.ViewName = viewName;
}
}
/// <summary>
/// Common validation logics for APIs.
/// </summary>
internal static class Validation
{
/// <summary>
/// Validates the specified parameter is not <c>null</c>.
/// </summary>
/// <typeparam name="T">The type of the parameter value.</typeparam>
/// <param name="value">The value to be validated.</param>
/// <param name="parameterName">Name of the parameter for <see cref="ArgumentException.ParamName"/>.</param>
/// <returns><paramref name="value"/>. This value will not be <c>null</c>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="value"/> is <c>null</c>.</exception>
public static T IsNotNull<T>(T value, string parameterName)
{
if (value == null)
{
throw new ArgumentNullException(parameterName);
}
return value;
}
}
/// <summary>
/// Defines helper methods for content type handling.
/// </summary>
internal static class ContentTypeHelper
{
public static readonly MediaTypeHeaderValue TextHtml = MediaTypeHeaderValue.Parse("text/html").CopyAsReadOnly();
private static readonly MediaTypeCollection Htmls =
new MediaTypeCollection { TextHtml };
/// <summary>
/// Determines whether an HTML response is requested via accept header.
/// </summary>
/// <param name="context">The <see cref="ActionContext"/>.</param>
/// <param name="allowWildcard">if set to <c>true</c> wildcard accept header is treated as <c>text/html</c>.</param>
/// <returns>
/// <c>true</c> if an HTML response is requested via accept header of the request; otherwise, <c>false</c>.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="context"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="context"/> returns <c>null</c> for <see cref="HttpContext"/> or <see cref="HttpRequest"/>.</exception>
public static bool IsHtmlReqested(ActionContext context, bool allowWildcard)
{
var request = Validation.IsNotNull(context, nameof(context)).HttpContext?.Request;
if (request == null)
{
throw new ArgumentException( "The specified ActionContext does not have valid HttpRequest.", nameof( context ) );
}
var acceptableMediaTypes = GetAcceptableMediaTypes(request);
if (acceptableMediaTypes.Count == 0)
{
return allowWildcard;
}
return acceptableMediaTypes[0].MediaType.Equals(TextHtml.MediaType, StringComparison.OrdinalIgnoreCase);
}
// From ObjectResultExecutor (https://github.com/aspnet/Mvc/blob/master/src/Microsoft.AspNetCore.Mvc.Core/Internal/ObjectResultExecutor.cs)
private static List<MediaTypeSegmentWithQuality> GetAcceptableMediaTypes(HttpRequest request)
{
var result = new List<MediaTypeSegmentWithQuality>();
AcceptHeaderParser.ParseAcceptHeader(request.Headers[HeaderNames.Accept], result);
for (int i = 0; i < result.Count; i++)
{
var mediaType = new MediaType(result[i].MediaType);
if (mediaType.MatchesAllSubTypes && mediaType.MatchesAllTypes)
{
result.Clear();
return result;
}
}
result.Sort((left, right) => left.Quality > right.Quality ? -1 : (left.Quality == right.Quality ? 0 : 1));
return result;
}
}
/// <summary>
/// Defines common helper methods to treat controller.
/// </summary>
internal static class ControllerHelper
{
/// <summary>
/// Gets the view data dynamically from specified controller object.
/// </summary>
/// <param name="controller">The controller object.</param>
/// <returns>
/// A <see cref="ViewDataDictionary"/> which is stored in the <paramref name="controller"/>.
/// This value may be <c>null</c> when the controller's property returns <c>null</c> or the controller does not declare <c>ViewData</c> property.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="controller"/> is <c>null</c>.</exception>
public static ViewDataDictionary GetViewData( object controller )
{
var asMvcController = Validation.IsNotNull( controller, nameof( controller ) ) as Controller;
if ( asMvcController != null )
{
return asMvcController.ViewData;
}
return ( ( dynamic ) controller ).ViewData as ViewDataDictionary;
}
/// <summary>
/// Gets the temp data dynamically from specified controller object.
/// </summary>
/// <param name="controller">The controller object.</param>
/// <returns>
/// A <see cref="ITempDataDictionary"/> which is stored in the <paramref name="controller"/>.
/// This value may be <c>null</c> when the controller's property returns <c>null</c> or the controller does not declare <c>TempData</c> property.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="controller"/> is <c>null</c>.</exception>
public static ITempDataDictionary GetTempData( object controller )
{
var asMvcController = Validation.IsNotNull( controller, nameof( controller ) ) as Controller;
if ( asMvcController != null )
{
return asMvcController.TempData;
}
return ( ( dynamic ) controller ).TempData as ITempDataDictionary;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment