I solved this in a slightly different way. I modified my MVC application to response to the application/json content type via a filter and a custom view engine which injects a Json serializer razor template into the view locations to search.
This was done to allow skinning of our website with jQuery UI, Bootstrap & Json responses for the same controllers/actions.
Here is a sample json result:
{
"sid": "33b336e5-733a-435d-ad11-a79fdc1e25df",
"form": {
"id": 293021,
"disableValidation": false,
"phone": null,
"zipCode": "60610",
"firstName": null,
"lastName": null,
"address": null,
"unit": null,
"state": "IL",
"email": null,
"yearsAtAddress": null,
"monthsAtAddress": null,
"howHeard": null
},
"errors": [
"The first name is required",
"The last name is required",
"Please enter a phone number",
"Please enter an email address"
],
"viewdata": {
"cities": [
{
"selected": false,
"text": "CHICAGO",
"value": "CHICAGO"
}
],
"counties": [
{
"selected": false,
"text": "COOK"
}
]
}
}
The filter is used to translate redirect results into a json object which passes the next url onto the calling program:
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
base.OnActionExecuted(filterContext);
// if the request was application.json and the response is not json, return the current data session.
if (filterContext.HttpContext.Request.ContentType.StartsWith("application/json") &&
!(filterContext.Result is JsonResult || filterContext.Result is ContentResult))
{
if (!(filterContext.Controller is BaseController controller)) return;
string url = filterContext.HttpContext.Request.RawUrl ?? "";
if (filterContext.Result is RedirectResult redirectResult)
{
// It was a RedirectResult => we need to calculate the url
url = UrlHelper.GenerateContentUrl(redirectResult.Url, filterContext.HttpContext);
}
else if (filterContext.Result is RedirectToRouteResult routeResult)
{
// It was a RedirectToRouteResult => we need to calculate
// the target url
url = UrlHelper.GenerateUrl(routeResult.RouteName, null, null, routeResult.RouteValues, RouteTable.Routes,
filterContext.RequestContext, false);
}
else
{
return;
}
var absolute = url;
var currentUri = filterContext.HttpContext.Request.Url;
if (url != null && currentUri != null && url.StartsWith("/"))
{
absolute = currentUri.Scheme + "://" + currentUri.Host + url;
}
var data = new {
nextUrl = absolute,
uid = controller.UniqueSessionId(),
errors = GetFlashMessage(filterContext.HttpContext.Session)
};
var settings = new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore
};
filterContext.Result = new ContentResult
{
ContentType = "application/json",
Content = JsonConvert.SerializeObject(data,settings)
};
}
Here is the Views\Json\Serializer.cshml, with using statements excluded for brevity and security of our codebase. This does three attempts at returning a response. The first is to read the original View{controller}{action}.cshtml, parsing out the html helpers and placing those into forms and fields. The second attempt looks for and elements from our built-in blogging system (PostContent below) and failing that we just use the Model.
@model dynamic
@{
Response.ContentType = "application/json";
Layout = "";
var session = new Object(); // removed for security purposes
var messages = ViewBag.Messages as List<string>() ?? new List<string>();
var className = "";
if (!ViewData.ModelState.IsValid)
{
messages.AddRange(ViewData.ModelState.Values.SelectMany(val => val.Errors).Select(error => error.ErrorMessage));
}
dynamic result;
string serial;
try
{
Type tModel = Model == null ? typeof(Object) : Model.GetType();
dynamic form = new ExpandoObject();
dynamic fields = new ExpandoObject();
var controller = ViewContext.RouteData.Values["controller"] as string ?? "";
var action = ViewContext.RouteData.Values["action"] as string;
var viewPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Views", controller, action + ".cshtml");
if (File.Exists(viewPath))
{
string contents = File.ReadAllText(viewPath);
var extracted = false;
var patterns = new[]
{
@"@Html\.\w+For\(\w+ => \w+\.(.*?)[,\)]",
@"@Html\.(\w+)For\(\w+ => \w+\.([\w\.]+)[, ]*(\(SelectList\))*(ViewBag\.\w+)*[^\)]*",
"name=\"(.*?)\""
};
for (var i = 0; i < 3 && !extracted; i++)
{
switch (i)
{
case 0:
form = contents.ExtractFields(patterns[0], Model as object, out extracted);
fields = contents.ExtractElements(patterns[1], Model as object, out extracted, ViewData);
break;
case 1:
form = Model as mvcApp.Models.Blog == null ? null : (Model.PostContent as string).ExtractFields(patterns[2], Model as object, out extracted);
break;
default:
form = Model;
break;
}
}
}
else if (Model == null)
{
// nothing to do here - safeModel will serialize to an empty object
}
else if (Model is IEnumerable)
{
form = new List<object>();
foreach (var element in ((IEnumerable) Model).AsQueryable()
.Cast<dynamic>())
{
form.Add(CustomExtensions.SafeClone(element));
}
} else {
form = Activator.CreateInstance(tModel);
CustomExtensions.CloneMatching(form, Model);
}
// remove any data models from the viewbag to prevent
// recursive serialization
foreach (var key in ViewData.Keys.ToArray())
{
var value = ViewData[key];
if (value is IEnumerable)
{
var enumerator = (value as IEnumerable).GetEnumerator();
value = enumerator.MoveNext() ? enumerator.Current : null;
}
if (value != null)
{
var vtype = value.GetType();
if (vtype.Namespace != null && (vtype.Namespace == "System.Data.Entity.DynamicProxies" || vtype.Namespace.EndsWith("Models")))
{
ViewData[key] = null;
}
}
}
result = new
{
uid = session.UniqueId,
form,
fields,
errors = messages.Count == 0 ? null : messages,
viewdata = ViewBag
};
var setting = new JsonSerializerSettings
{
PreserveReferencesHandling = PreserveReferencesHandling.None,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Formatting = Formatting.Indented
};
if (form is IEnumerable)
{
setting.NullValueHandling = NullValueHandling.Ignore;
}
serial = JsonConvert.SerializeObject(result, setting);
}
catch (Exception e)
{
result = new {
uid = session.UniqueId,
error = e.Message.Split('|')
};
serial = JsonConvert.SerializeObject(result);
}
@Html.Raw(serial)
}
For the clone methods see Best way to clone properties of disparate objects
public static dynamic ExtractFields(this string html, string pattern, object model, out bool extracted)
{
if (html == null || model == null)
{
extracted = false;
return null;
}
dynamic safeModel = new ExpandoObject();
var safeDict = (IDictionary<string, Object>)safeModel;
var matches = new Regex(pattern).Matches(html);
extracted = matches.Count > 0;
if ( extracted )
{
foreach (Match match in matches)
{
var name = match.Groups[1].Value;
var value = CustomExtensions.ValueForKey(model, name);
var segments = name.Split('.');
var obj = safeDict;
for (var i = 0; i < segments.Length; i++)
{
name = segments[i];
if (i == segments.Length - 1)
{
if (obj.ContainsKey(name))
{
obj[name] = value;
}
else
{
obj.Add(name, value);
}
continue;
}
if (!obj.ContainsKey(name))
{
obj.Add(name, new ExpandoObject());
}
obj = (IDictionary<string, Object>)obj[name];
}
}
}
return safeModel;
}
And here is an implementation of key value coding to make dealing with property chains a bit easier:
/// <summary>
/// This borrows KeyValueCoding from Objective-C and makes working with long chains of properties more convenient.
/// KeyValueCoding is null tolerant, and will stop if any element in the chain returns null instead of throwing a NullReferenceException.
/// Additionally, the following Linq methods are supported: First, Last, Sum & Average.
/// <br/>
/// KeyValueCoding flattens nested enumerable types, but will only aggregate the last element: "children.grandchildren.first" will return
/// the first grandchild for each child. If you want to return a single grandchild, use "first.children.grandchildren". The same applies to
/// Sum and Average.
/// </summary>
/// <param name="source">any object</param>
/// <param name="keyPath">the path to a descendant property or method "child.grandchild.greatgrandchild".</param>
/// <param name="throwErrors">optional - defaults to supressing errors</param>
/// <returns>returns the specified descendant. If intermediate properties are IEnumerable (Lists, Arrays, Collections), the result *should be* IEnumerable</returns>
public static object ValueForKey(this object source, string keyPath, bool throwErrors = false)
{
try
{
while (true)
{
if (source == null || keyPath == null) return null;
if (keyPath == "") return source;
var segments = keyPath.Split('.');
var type = source.GetType();
var first = segments.First();
var property = type.GetProperty(first);
object value = null;
if (property == null)
{
var method = type.GetMethod(first);
if (method != null)
{
value = method.Invoke(source, null);
}
}
else
{
value = property.GetValue(source, null);
}
if (segments.Length == 1) return value;
var children = string.Join(".", segments.Skip(1));
if (value is IEnumerable || "First|Last|Sum|Average".IndexOf(first, StringComparison.OrdinalIgnoreCase) > -1)
{
var firstChild = children.Split('.').First();
var grandchildren = string.Join(".", children.Split('.').Skip(1));
if (value == null) {
var childValue = source.ValueForKey(children);
value = childValue as IEnumerable<object>;
switch (first.Proper())
{
case "First":
return value == null ? childValue : ((IEnumerable<object>)value).FirstOrDefault();
case "Last":
return value == null ? childValue : ((IEnumerable<object>)value).LastOrDefault();
case "Count":
return value == null ? (childValue == null ? 0 : 1) : (int?)((IEnumerable<object>)value).Count();
case "Sum":
return value == null
? Convert.ToDecimal(childValue ?? "0")
: ((IEnumerable<object>) value).Sum(obj => Convert.ToDecimal(obj ?? "0"));
case "Average":
return value == null
? Convert.ToDecimal(childValue ?? "0")
: ((IEnumerable<object>) value).Average(obj => Convert.ToDecimal(obj ?? "0"));
}
} else {
switch (firstChild.Proper())
{
case "First":
return ((IEnumerable<object>)value).FirstOrDefault().ValueForKey(grandchildren);
case "Last":
return ((IEnumerable<object>)value).LastOrDefault().ValueForKey(grandchildren);
case "Count":
if (!string.IsNullOrWhiteSpace(grandchildren))
{
value = value.ValueForKey(grandchildren);
if (value != null && ! (value is IEnumerable<object>))
{
return 1;
}
}
return value == null ? 0 : ((IEnumerable<object>)value).Count();
case "Sum":
return ((IEnumerable<object>)value).Sum(obj => Convert.ToDecimal(obj.ValueForKey(grandchildren)??"0"));
case "Average":
return ((IEnumerable<object>)value).Average(obj => Convert.ToDecimal(obj.ValueForKey(grandchildren) ?? "0"));
}
}
if (value == null) return null;
var flat = new List<object>();
foreach (var element in (IEnumerable<object>)value)
{
var child = element.ValueForKey(children);
if (child == null)
{
continue;
}
if (child is IEnumerable && !(child is string))
{
flat.AddRange((IEnumerable<object>) child);
}
else
{
flat.Add(child);
}
}
return flat.Count == 0? null: flat;
}
source = value;
keyPath = children;
}
}
catch (Exception)
{
if (throwErrors) throw;
}
return null;
}