Combining ASP.NET MVC and Web API for content negotiation goodness

Ok, so this post sprung out of an idea that I have had in my head for a while. I know it will probably be solved better in ASP.NET v.Next, and can probably be solved in a bunch of other ways using only Web API or only MVC, but I wanted to see if I could use both to do it…

So what is IT? Well… In Web API, we have the ability to use content negotiation out of the box. Unfortunately, that content negotiation is, at least by default, based around serializing to XML or JSON. It doesn’t include all the view goodness that MVC has. There is no simple way to ask Web API to return a Razor view… So if I want to have content negotiation to handle both serialized data and views, we need to do some work…

On top of that, my solution would work nicely together with an existing MVC application, making it “easy” to add API features and content negotiation to the existing MVC URLs.

My solution to the fact that MVC doesn’t have content negotiation, and Web API doesn’t have view handling is to combine them and have Web API handle requests for non HTML types, and have MVC handle HTML.

I could of course have added content negotiation to MVC. It wouldn’t even be that hard I think, but it would make my controllers cluttered. I want my MVC controllers to handle view stuff, and nothing else. Just like I want my Web API to handle non-view stuff only… This is what they were built for…

The scenario I am looking at is a simple product catalog. It has an IProducts service that contains products, and a website that serves up views for those products. The website uses ASP.NET MVC, with a single controller called ProductCatalogController. The ProductCatalogController is ridiculously simple. It looks like this

public class ProductCatalogController : Controller
{
private readonly IProducts _products;

public ProductCatalogController() : this(new Products())
{

}
public ProductCatalogController(IProducts products)
{
_products = products;
}

public ActionResult Product(int id)
{
var product = _products.WhereIdIs(id);

if (product == null)
return new HttpNotFoundResult();

return View(product);
}
}

As you can see, I am keeping it as simple as possible, thus using “poor man’s dependency injection” and just returning a very basic view that looks like this

@model DarksideCookie.AspNet.MVCWebAPI.Combo.Domain.Product

@{
Layout = null;
}

<!DOCTYPE html>

<html>
<head>
<title>@Model.Name</title>
</head>
<body>
<div>
<h1>@Model.Name</h1>
<p>@Model.Description</p>
</div>
</body>
</html>

Ok, it doesn’t get much simple than that! But that is the whole idea!

To view this page, I browse to /ProductCatalog/1. The thing I want here is the ability to go to that URL and use the accept header to define what I get back. In this case, images…

So what I need is a way to add a Web API route that will use the same route, but ignore any request with an accept header other than those starting with “image/”… To be honest, I want it a bit more flexible, so I will make the type configurable…

So, how do I do this? Well, it is possible to create our own versions of the “RouteAttribute”. However, instead of inheriting from RouteAttribute, I inherit from RouteFactoryAttribute. This is an attribute route type that can contain custom constraints, which is what I will be using.

Other than my custom constraint, the attribute class, which I call AcceptTypeRouteAttribute, is pretty simple

public class AcceptTypeRouteAttribute : RouteFactoryAttribute
{
private readonly string _acceptPattern;

public AcceptTypeRouteAttribute(string template, string acceptPattern)
: base(template)
{
_acceptPattern = acceptPattern;
}

public override IDictionary<string, object> Constraints
{
get
{
return new HttpRouteValueDictionary {
{"acceptType", new AcceptTypeConstraint(_acceptPattern)}
};
}
}
}

As you can see it takes 2 parameters. A template, which is the path to use for the route, and an accetPattern. The acceptPattern string defines what accept type this route is responsible for. The route template is just passed on to the base class, and the accept pattern is stored globally in the class.

I also override the Constraints property, and return a Dictionary<string, object> containing a single constraint of type AcceptTypeConstraint, which is a custom constraint type I have created.

The AcceptTypeConstraint is a class implementing the IHttpRouteConstraint. This interface defines a single method called Match(). It takes a bunch of parameters, including the current HttpRequestMessage, and returns a bool that indicates whether or not this route should handle this request…

I guess you can see where I am going with this…

The implementation looks like this

public class AcceptTypeConstraint : IHttpRouteConstraint
{
private readonly string _acceptPattern;

public AcceptTypeConstraint(string acceptPattern)
{
_acceptPattern = acceptPattern;
}

public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values, HttpRouteDirection routeDirection)
{
if (routeDirection != HttpRouteDirection.UriResolution) return false;

return request.Headers.Accept.First().MediaType.StartsWith(_acceptPattern);
}
}

Yes, it is a bit simplified. It only looks at the first accept type in the collection and does a very basic “starts with” check, but you get the idea. It could be much more complex…

Once I got that attribute up and running, I create a new Web API controller called ProductCatalogImagesController. I put a RoutePrefix(“ProductCatalog”) attribute on it, and create a single method called ProductImageByAcceptType() in it. Next, I add my custom attribute on it, and tell it to only respond to “image/” requests. Like this

[AcceptTypeRoute("Product/{id:int}")]
[HttpGet]
public HttpResponseMessage ProductImageByFileExtension(int id)
{
var path = HostingEnvironment.MapPath(@"~/App_Data/Images/" + id + ".jpg");
if (!File.Exists(path))
{
// or return genric "no image available" image...
return Request.CreateErrorResponse(HttpStatusCode.NotFound, "Not Found");
}

var ms = new MemoryStream();
using (var fs = File.OpenRead(path))
{
fs.CopyTo(ms);
fs.Close();
}
ms.Position = 0;

var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(ms) };
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
return response;
}

Once again, it is a very simplistic in the way it gets its image and so on, but it does show the idea behind it…

Finally, I make sure to get my Web API routes registered before my MVC routes as they are processed in the order that they are registered. And since my custom routes need to evaluate all incoming routes before they are passed to MVC, they need to be first.

Now, if I were to request that URL in a web browser, I would get the HTML view. But if I used Fiddler, or something similar, and changed the accept header to “image/jpg” I would get an image… The simple way of doing this, instead of using Fiddler, is to have the HTML view do it for us… I just add this one line of HTML in my view

<img src="@Url.Action("Product", new { id = Model.Id })" />

Luckily, web browser will assume it being and image, and thus add that accept type for that request. This means that browsing to that product page will now include an image…fetched from the same URL…

But we could take it one step further and have the size included in the path as well. However, instead of doing custom sizes, I will keep it simple and just do fixed sizes based on an enum that looks like this

public enum ImageFormats
{
Icon,
Thumbnail,
FullSize
}

To get this working, I just need to make a few changes to my action method. First of all, I need to change the route attribute to the following

[AcceptTypeRoute("Product/{id:int}/{imageFormat=FullSize}", "image/")]

I have added an “imageFormat” parameter at the end of the path, and given it a default value of “FullSize”.

Next I change the action method like this

[AcceptTypeRoute("Product/{id:int}/{imageFormat=FullSize}", "image/")]
[HttpGet]
public HttpResponseMessage ProductImageByAcceptType(int id, ImageFormats imageFormat)
{
var path = HostingEnvironment.MapPath(@"~/App_Data/Images/" + GetFormatBasedFileName(id, format));
if (!File.Exists(path))
{
// or return generic "no image available" image...
return Request.CreateErrorResponse(HttpStatusCode.NotFound, "Not Found");
}

var ms = new MemoryStream();
using (var fs = File.OpenRead(path))
{
fs.CopyTo(ms);
fs.Close();
}
ms.Position = 0;

var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(ms) };
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
return response;
}

The changes, if not obvious (which they aren’t) are as follows… I have added another input parameter for the image format. I have changed the path to include the format in the filename (not shown as it is irrelevant)…

Now, I can change my view to make use of this by adding HTML that looks like this

<img src="@Url.Action("Product", new { id = Model.Id })" />
<img src="@Url.Action("Product", new { id = Model.Id })/Thumbnail" />
<img src="@Url.Action("Product", new { id = Model.Id })/Icon" />

Leaving the trailing part of the path empty just makes it FullSize…

Ok, so I have now managed to marry MVC and Web API to get this stuff going. And it obviously doesn’t have to stop at images. I could just as well handle requests for PDFs or custom formats…or pretty much whatever you want…

However, the fact that I need to pass an accept header can be a bit cumbersome. Not if you are building a client that consumes that information, or include an image like I did, but if I want to access other things than images, or just be able to request it from a basic browser. If I for example wanted to offer up PDFs, I wouldn’t be able to do it through a browser. The browser wouldn’t know that it needed to add the correct accept header. It only knows this for images… So I want to get around this somehow…

What if I wanted Web API to handle requests not only based on the Accept header, but also based on the file extension used in the request…?

Well, this is actually quite easy to do with exactly the same technique we used for the header stuff…

I just create a new routing attribute called FileExtensionRouteAttribute. It looks pretty identical to the AcceptTypeRouteAttribute, but instead of a accept type pattern, it takes the file extension to handle…

public class FileExtensionRouteAttribute : RouteFactoryAttribute
{
private readonly string _extension;

public FileExtensionRouteAttribute(string template, string extension) : base(template)
{
_extension = extension;
}

public override IDictionary<string, object> Constraints
{
get
{
var constraints = new HttpRouteValueDictionary
{
{"fileextension", new FileExtensionConstraint(_extension)}
};
return constraints;
}
}
}

However, instead of adding a AcceptTypeConstraint, I add a new constraint type called FileExtensionConstraint.

The FileExtensionConstraint looks like this

public class FileExtensionConstraint : IHttpRouteConstraint
{
private readonly IEnumerable<string> _extensions;

public FileExtensionConstraint(string extension)
{
_extensions = extension.ToLower().Split(',');
}

public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values, HttpRouteDirection routeDirection)
{
if (routeDirection != HttpRouteDirection.UriResolution) return false;

if (!request.RequestUri.AbsolutePath.Contains(".")) return false;

var ext = request.RequestUri.AbsolutePath.Substring(request.RequestUri.AbsolutePath.IndexOf(".") + 1).ToLower();
return _extensions.Any(x => x == ext);
}
}

Once again, it is fairly simple. It accepts multiple extensions in a comma separated string, which it splits and stores in an array. When a request comes in, it checks for a file extension, and if there is one, it checks whether or not it is in the array of accepted extensions…

Using that new attribute, and the new constraint, I can add a route to my controller that looks like this

[FileExtensionRoute("Product/{filename}", "jpg,jpeg")]
[HttpGet]
public HttpResponseMessage ProductImageByFileExtension(string filename, ImageFormats format = ImageFormats.FullSize)
{
var path = HostingEnvironment.MapPath(@"~/App_Data/Images/" + GetFormatBasedFileName(filename, format));
if (!File.Exists(path))
{
// or return generic "no image available" image...
return Request.CreateErrorResponse(HttpStatusCode.NotFound, "Not Found");
}

var ms = new MemoryStream();
using (var fs = File.OpenRead(path))
{
fs.CopyTo(ms);
fs.Close();
}
ms.Position = 0;

var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(ms) };
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
return response;
}

It is pretty similar to the previous one (and yes they should go some refactoring, which they have in the code download…). Instead of taking an id, it takes a filename. It uses the filename and file size to figure out the name of the file to use. In my version it just adds the file size to the filename before the extension… It then returns that to the client.

Using this new action, I can add something like this to my HTML

<img src="@(Url.Action("Product", new { id = Model.Id })).jpg" />
<img src="@(Url.Action("Product", new { id = Model.Id })).jpg?format=thumbnail" />
<img src="@(Url.Action("Product", new { id = Model.Id })).jpg?format=icon" />

This gives me URLs that works with my browser. I can browse to it and it gives me the right image… It might not look as nice, or be as “RESTful”, but still works. And solves some problems…

Ok, that’s all there is to it! A nice way of combining Web API and MVC to serve up both views and data based on the same URL.

In the future, this will be even easier using ASP.NET v.Next, but until then, this works nicely I think.

And as usual, the code is available for download here: DarksideCookie.AspNet.MVCWebAPI.Combo.zip (526.36 kb)

And feel free to ask any questions or post any comments you might have!

Comments (1) -

miksi en laihdu 8/4/2017 4:08:36 PM

Dead indited articles, thanks for information. "Necessity is the mother of taking chances." by Mark Twain.

Add comment