Created
August 13, 2016 07:33
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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