Table of contents
For the latest years web developers can see the rapid growth of web front-end technologies. It caused using more often the REST paradigm on the server side of web application.
When NodeJS entered the game, full stack JavaScript developers received a lot of opportunities to minimize efforts for developing REST services (with help of Express, first of all). There are also a lot of out-of-box and ready to use RESTful services.
But what does .NET world has to offer for creating RESTful services?
This article is aimed to ...
We'll try to analyse .NET based solutions to creating RESTful services with focus on next moments:
- Details of creating project;
- The complexity of service expansion (add new entities);
- The complexity of implementing advanced queries;
- Identified diffficulties, problems and ways to solve it;
- Procedure of deployment to production environment.
For the purposes of this article we will use Microsoft SQL Server database as a backend. Our demo database will be from project MVCMusicStore.
Background
You can get general information about RESTful service paradigm from it's Wiki page.
In addition to baseline of REST concepts there is a good extension - OData standard. It allows to build advanced queries for data source over HTTP.
WCF OData (WCF Data Services)
The excelent starting point to work with WCF OData technology is this CodeProject article.
WCF OData allows to create ready to use REST service in a couple of steps with Visual Studio.
First, create a database from attached backup using SQL Server Management Studio.
So, attached database will have tables list like this:
Let's implement Model project that will be common data access layer for WCF OData and Web API projects.
First, add project of "Class Library" to our solution.
Then, create ADO.NET Entity data model based on EntityFramework. Good description of working with EntityFramework are described in this article.
So, for the purpose of this article we'll skip detailes of create connection to DB and creating DBContext.
Resulting EDMX scheme will look something like this.
Detailed steps of creeating and initial configuration of WCF OData project described in this article.
We'll not duplicate it.
Then add reference to earlier created Model project.
Then, edit file with WCF Data Service definition:
using System.Data.Services;
using System.Data.Services.Common;
using WCFOdata.Utils;
using Model;
namespace WCFOdata
{
[JSONPSupportBehavior]
public class WcfDataService1 : DataService<mvcmusicstoreentities>
{
public static void InitializeService(DataServiceConfiguration config)
{
config.SetEntitySetAccessRule("*", EntitySetRights.All);
config.DataServiceBehavior.MaxProtocolVersion =
DataServiceProtocolVersion.V2;
config.UseVerboseErrors = true;
}
}
}
And it's all efforts to get working REST with OData!
Let's run project and get OData service entry point list like shown below.
Initial service route show list of available service entry points.
In our example it's a list of MVC Music Store entities collection:
- Album;
- Artist;
- Cart;
- Genre;
- Order;
- OrderDetail.
Let's try to get collection of some entities in JSON. For this task request string will be: http://localhost:14040/WcfDataService1.svc/Album?$format=json.
Details of OData URI conventions are defined on site OData.org.
To get new entities after change of database schema, we'll need to:
- complete edmx container;
- build project;
- delpoy to production environment (IIS).
Deploy procedure is not hard, but some difficulties with IIS configuration may appear of course.
Using advanced queries to entities doesn't require build and deploy process, cause it's based on OData parameters like $select, $expand, $filter and others.
Good starting point on topic of ASP.NET Web API is this Codeproject article.
Let's add Web API project to our solution like shown below.
Initial structure of created project will look like this:
For the purpose of this article we need some typical controllers that can perform identical operations:
- Get full collection of entities;
- Get single entity instance by Id;
- Get paginated collection;
- Create new entity instance;
- Update existed entity;
- Delete existed entity.
So, these operations performed equally for all entity types.
That's why it's therefore advisable to create generic controller class.
using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
using System.Data;
using Model;
using WebApiExample.Utils;
namespace WebApiExample.Controllers
{
public class BaseAPIController<T> : ApiController where T:class
{
protected readonly MvcMusicStoreEntities _dbContext;
public BaseAPIController()
{
_dbContext = new MvcMusicStoreEntities();
_dbContext.Configuration.ProxyCreationEnabled = false;
}
private string GetKeyFieldName()
{
return string.Format("{0}{1}", typeof(T).Name, "Id");
}
private T _Get(int id)
{
return _dbContext.Set<T>().Find(id);
}
public IEnumerable<T> Get()
{
return _dbContext.Set<T>();
}
public T Get(int id)
{
return _Get(id);
}
public IEnumerable<T> Get(int top, int skip)
{
var res = _dbContext.Set<T>().OrderBy(GetKeyFieldName()).Skip(skip).Take(top).ToList();
return res;
}
public void Post([FromBody]T item)
{
_dbContext.Set<T>().Add(item);
_dbContext.SaveChanges();
}
public void Put(int id, [FromBody]T item)
{
_dbContext.Entry(item).State = EntityState.Unchanged;
var entry = _dbContext.Entry(item);
foreach (var name in entry.CurrentValues.PropertyNames.Except(new[] { GetKeyFieldName() }))
{
entry.Property(name).IsModified = true;
}
_dbContext.SaveChanges();
}
public void Delete(int id)
{
var entry = _Get(id);
_dbContext.Set<T>().Remove(entry);
_dbContext.SaveChanges();
}
}
}
And target entity controller class definition now will look like this.
namespace WebApiExample.Controllers
{
public class AlbumController : BaseAPIController<Album>
{
}
}
After apply this to all entity classes we have the following controllers list:
In initial configuration of Web API project, api service will return data in XML format if request was created directly by from user in web-browser. But, for our purpose the JSON format will be more comfortable. So, we need to apropriate changes in configuration. Such a configuration trick was found on SO thread. So, let's apply this to our WebApiConfig.cs file.
namespace WebApiExample
{
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "PaginationApi",
routeTemplate: "api/{controller}/{top}/{skip}"
);
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
config.Formatters.JsonFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/html"));
}
}
}
One moment to pay explicit attention is this block:
config.Routes.MapHttpRoute(
name: "PaginationApi",
routeTemplate: "api/{controller}/{top}/{skip}"
);
This block of code configures WebAPI router to correctly handle paginated requests.
Our Web API project seems ready to use. So, let's start it in Chrome browser. Initial project page look like shown below.
But, we need to check API part, not frontend. So create a request to the next path: "http://localhost:56958/api/Album". And api service answer is a JSON collection of albums.
[{"AlbumId":386,"GenreId":1,"ArtistId":1,"Title":"DmZubr album","Price":100.99,"AlbumArtUrl":"/Content/Images/placeholder.gif","Artist":null,"Genre":null,"Cart":[],"OrderDetail":[]},
{"AlbumId":387,"GenreId":1,"ArtistId":1,"Title":"Let There Be Rock","Price":8.99,"AlbumArtUrl":"/Content/Images/placeholder.gif","Artist":null,"Genre":null,"Cart":[],"OrderDetail":[]}, ...
What will we have to do to get new entity from Web API. Basically, next steps:
- complete edmx container;
- build project;
- delpoy to production environment (IIS).
What about advanced queries with filtering, ordering and projections?
With Web API project this tasks requires more efforts than with WCF Data Services.
One approach to perform this task is building explicit API entry point for every specifical task. For example, we'll create individual method to get Albums by name part. Other method will return Artists collection by name part. For every task we will also have to build and deploy project.
Another approach is creating generic methods, like we've already done for CRUD operations and paginated GET request. For some types of queries it will be the very untrivial task.
But, the other (positive) side of the coin is that with Web API you will have the maximal extent of control over model and flow of requests handling.
OData Server have a big difference from the technologies analysed above. It's an out-of-box utility, that create REST service for existed relational database. I've founded short description of this project on official OData site in ecosystem/producers section.
We don't need to write any code to create RESTful service for our MVC Music Store DB when using this utility. But, what are the next steps then?
First, download product distribute, of course.
Distribute is an archive file that suggest to use utility in three available forms:
- Windows console application;
- Windows service;
- Microsoft IIS node.
For the purpose of this article we'll choose a way with minimal efforts - console application.
So, after getting an archive - unzip catalogue "console" to our project root path.
Further, we need to connect service to out MVC Music Store database. We'll do this by editing configuration file, that look like this:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<section name="RWADOData" type="ODataServer.HttpLevel.Configuration.RWADODataSettingsSection, ODataServer.HttpLevel" requirePermission="false" />
</configSections>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
</startup>
<connectionStrings>
<add name="Default" connectionString="Data Source=localhost\SQLExpress;Database=MVCMusicStore; User ID=******; Password=******" providerName="System.Data.SqlClient"/>
</connectionStrings>
<appSettings>
<add key="log4net.Config" value="log4net.config" />
</appSettings>
<RWADOData>
<commonSettings connectionStringName="Default" port="8085" rootServiceHost="http://definedhost" />
<authSettings authenticationMethod="None" tokenStoreMethod="InCookie" tokenCookieName="RWADTechODataAuthToken" />
<corsSettings useCORS="true" allowedOriginHosts="http://localhost:9002" />
</RWADOData>
<system.web>
<machineKey
validationKey="C50B3C89CB21F4F1422FF158A5B42D0E8DB8CB5CDA1742572A487D9401E3400267682B202B746511891C1BAF47F8D25C07F6C39A104696DB51F17C529AD3CABE"
decryptionKey="8A9BE8FD67AF6979E7D20198CFEA50DD3D3799C77AF2B72F"
validation="SHA1" />
</system.web>
</configuration>
Moments to note here are:
- connectionStrings - first of all, connection string to our DB;
- RWADOData.commonSettings:
- connectionStringName="Default" - define using previously declared connection string;
- port="8085" - port where our RESTful service will live;
- RWADOData.authSettings - settings to use service authorization and authentication. This topic is out of scope of this article.
- RWADOData.corsSettings - setting of CORS resolving. We'll discuss CORS problem later in this article.
Our service seems ready to start now.
Let's run console application. We'll get result like this one:
Then, go to web browser and request service initial route (in our case - "http://localhost:8085/").
And we'll get available entry points.
And, as we did earlier, let's get an albums collection in JSON format ("http://localhost:14040/WcfDataService1.svc/Album?$format=json").
Result of request executing seems like this:
What about getting collections of new entities after database schema change?
With OData Server we don't need to do anything except of restart service after schema change!
For advanced queries we can use OData standard parameters,
Also, there is a support of invocation of stored procedures.
What about functional expasion of service by features like working with file? It's not supported because OData Server is an out-of-box product.
But there is also a module for user, roles and permissions management. So, OData Server's functionality seems enough for creating small and medium applications.
Another advantage of this RESTful solution is a possibility to use it without IIS. In a form of Windows service or console application. It may be significant aspect for some cases.
When all of our three .NET RESTful services are ready to use, let's create a project with tests. Tests project is aimed to execute requests and print time spent by service to handle it.
First, create a project for tests in our solution. It will be simple ASP.NET MVC application. We'll use appropriate Visual Studio template.
Testing page is a absolutely minimal typical page, so we'll not cover details of creating it. You can know details in code of attached project.
According to purpose of this article, the point of interest here is a client-side code that will create requests to all 3 services and output time that services will spend.
Let's try to analyse this code.
Date.DateDiff = function(datepart, fromdate, todate) {
datepart = datepart.toLowerCase();
var diff = todate - fromdate;
var divideBy = { w:604800000,
d:86400000,
h:3600000,
n:60000,
s:1000,
ms: 1};
return Math.floor( diff/divideBy[datepart]);
}
function AppendResRow(providerType, action, route, time) {
var row = '<tr> \
<td>' + providerType + '</td> \
<td>' + action + '</td> \
<td>' + route + '</td> \
<td>' + time + '</td> \
</tr>';
$('#results tbody').append(row);
}
function GetHostByProviderType(providerType) {
var host = '';
switch (providerType) {
case 'WCFOData':
res = $('#WCFODataHost').val();
break;
case 'WebApi':
res = $('#WebApiHost').val();
break;
case 'ODataServer':
res = $('#ODataServerHost').val();
break;
}
return res;
}
function RunGetCollectionTests(providerType) {
var targetHost = GetHostByProviderType(providerType);
var timings = {};
$.each(entitiesList, function (index) {
var item = entitiesList[index];
var targetUrl = targetHost + item;
if (providerType == 'ODataServer')
targetUrl += '?$format=json';
timings[targetUrl] = new Date();
$.ajax({
url: targetUrl,
headers: {
'Accept': 'application/json'
},
async: false
})
.then(function (res) {
var timeSpan = Date.DateDiff('ms', timings[targetUrl], new Date());
AppendResRow(providerType, 'Get full collection', targetUrl, timeSpan);
});
});
}
function RunGetCollectionWithPaginationTests(providerType) {
var targetHost = GetHostByProviderType(providerType);
var timings = {};
for (var i = 0; i < testCategoryReplies; i++) {
var top = topSkipPairs[i].top;
var skip = topSkipPairs[i].skip;
var targetUrl = targetHost + 'Album';
if (providerType == 'WebApi')
targetUrl += '/' + top + '/' + skip;
else
targetUrl += '?$top=' + top + '&skip=' + skip + '&$format=json';
timings[targetUrl] = new Date();
$.ajax({
url: targetUrl,
headers: {
'Accept': 'application/json'
},
async: false
})
.then(function (res) {
var timeSpan = Date.DateDiff('ms', timings[targetUrl], new Date());
AppendResRow(providerType, 'Get collection with pagination', targetUrl, timeSpan);
});
}
}
function RunCreateEntityTests(providerType) {
var targetHost = GetHostByProviderType(providerType);
var contentType = 'application/json';
var album = {
GenreId: 1,
ArtistId: 1,
Title: "Album created from " + providerType,
Price: "20.99",
AlbumArtUrl: "/Content/Images/placeholder.gif"
};
var data = JSON.stringify(album);
var timings = {};
var targetUrl = targetHost + 'Album';
for (var i = 0; i < testCategoryReplies; i++) {
var timingsKey = targetUrl + i;
timings[timingsKey] = new Date();
$.ajax({
type: 'POST',
url: targetUrl,
data: data,
headers: {
'Accept': 'application/json',
'Content-Type': contentType
},
async: false
})
.then(function (res) {
var timeSpan = Date.DateDiff('ms', timings[timingsKey], new Date());
AppendResRow(providerType, 'Create entity (album)', targetUrl, timeSpan);
});
}
}
function RunDeleteEntityTests(providerType, initialAlbumId) {
var targetHost = GetHostByProviderType(providerType);
var timings = {};
var targetUrlBase = targetHost + 'Album';
for (var i = 0; i < testCategoryReplies; i++) {
targetUrl = targetUrlBase;
if (providerType == 'WebApi')
targetUrl += '/' + initialAlbumId;
else
targetUrl += '(' + initialAlbumId + ')';
var timingsKey = targetUrl + i;
timings[timingsKey] = new Date();
$.ajax({
type: 'DELETE',
url: targetUrl,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
async: false
})
.then(function (res) {
var timeSpan = Date.DateDiff('ms', timings[timingsKey], new Date());
AppendResRow(providerType, 'Delete entity (album)', targetUrl, timeSpan);
}, function (err) {
console.log(err);
});
initialAlbumId++;
}
deletedFirstAlbumId += testCategoryReplies;
}
$(function () {
$('#start-tests').click(function () {
RunGetCollectionTests('WCFOData');
RunGetCollectionTests('WebApi');
RunGetCollectionTests('ODataServer');
RunGetCollectionWithPaginationTests('WCFOData');
RunGetCollectionWithPaginationTests('WebApi');
RunGetCollectionWithPaginationTests('ODataServer');
RunCreateEntityTests('WCFOData');
RunCreateEntityTests('WebApi');
RunCreateEntityTests('ODataServer');
RunDeleteEntityTests('WCFOData', deletedFirstAlbumId);
RunDeleteEntityTests('WebApi', deletedFirstAlbumId);
RunDeleteEntityTests('ODataServer', deletedFirstAlbumId);
});
$('#clear-table').click(function () {
$('#results tbody').children().remove();
});
});
Before run tests we need to edit next variables:
- var testCategoryReplies = 20 - count of iterations of each test type;
- var deletedFirstAlbumId = 854 - id of first Album entity to delete. We can get value of this variable by viewing table "Album" via MS SSMS;
- var topSkipPairs = [...] - array of pairs of values for top and skip parameters. Length of this array should be equal to or greater than testCategoryReplies value. You can add some randomization logic to generate elements of this array.
Let's try to run tests, but for start point - only for WCF Data service. Just put comments symbols in lines where other services requests are called.
So, what will we see then? Unfortunately, nothing happens and results table is free. Let's go to Chrome debugger to see what's happen. And get a lot of CORS problem errors like this one "XMLHttpRequest cannot load http://localhost:14040/WcfDataService1.svc/Album. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:54835' is therefore not allowed access."
So, we'll try to define Access-Control-Allow-Origin header value and start tests again. Edit WCF OData web.config file:
<system.webServer>
<directoryBrowse enabled="true" />
<httpProtocol>
<customHeaders>
<add name="Access-Control-Allow-Origin" value="*" />
</customHeaders>
</httpProtocol>
</system.webServer>
After adding CORS header we have succesfully performed GET requests.
But CREATE requests have thrown an error:
"OPTIONS http://localhost:14040/WcfDataService1.svc/Album 501 (Not Implemented)
XMLHttpRequest cannot load http://localhost:14040/WcfDataService1.svc/Album. Response for preflight has invalid HTTP status code 501".
And googling for such a trouble will give us very disappointing results. Like this one (StackOverflow thread).
So, what will be the new plan in this case?
I've founded one workaround, that have already used in other real project. It's idea is to make all OPTIONS requests as a simple POST/PUT request with no reffering. For this purpose we'll create new request on the server-side, get results and return it to the client.
Let's code it.
using System;
using System.IO;
using System.Web;
using System.Net;
using System.Text;
namespace Shared.HttpModules
{
public class CORSProxyModule : IHttpModule
{
public CORSProxyModule()
{
}
public void Init(HttpApplication application)
{
application.BeginRequest += (new EventHandler(this.Application_BeginRequest));
}
private void Application_BeginRequest(Object sender, EventArgs e)
{
try
{
HttpApplication app = (HttpApplication)sender;
HttpContext ctx = app.Context;
if (ctx.Request.HttpMethod == "OPTIONS")
{
HttpWebResponse resp = null;
var res = WebServiceRedirect(app, ctx.Request.Url.ToString(), out resp);
ctx.Response.ContentEncoding = Encoding.UTF8;
ctx.Response.ContentType = resp.ContentType;
ctx.Response.Write(res);
ctx.Response.End();
}
}
catch (Exception) { }
}
public void Dispose() { }
private string WebServiceRedirect(HttpApplication ctx, string url, out HttpWebResponse response)
{
byte[] bytes = ctx.Request.BinaryRead(ctx.Request.TotalBytes);
char[] reqBody = Encoding.UTF8.GetChars(bytes, 0, bytes.Length);
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
req.AllowAutoRedirect = false;
req.ContentLength = ctx.Request.ContentLength;
req.ContentType = ctx.Request.ContentType;
req.UseDefaultCredentials = true;
req.Referer = url;
req.Method = ctx.Request.RequestType;
if (ctx.Request.AcceptTypes.Length > 0)
req.MediaType = ctx.Request.AcceptTypes[0];
foreach (string str in ctx.Request.Headers.Keys)
{
try { req.Headers.Add(str, ctx.Request.Headers[str]); }
catch { }
}
using (StreamWriter sw = new StreamWriter((req.GetRequestStream())))
{
sw.Write(reqBody);
sw.Flush();
sw.Close();
}
string temp = "";
try
{
response = (HttpWebResponse)req.GetResponse();
using (StreamReader sw = new StreamReader((response.GetResponseStream())))
{
temp = sw.ReadToEnd();
sw.Close();
}
}
catch (WebException exc)
{
using (StreamReader sw = new StreamReader((exc.Response.GetResponseStream())))
{
response = (HttpWebResponse)exc.Response;
temp = sw.ReadToEnd();
sw.Close();
}
}
return temp;
}
}
}
Comments are presented in code.
So, use our module by edit web.config file. At the same time, add some CORS headers.
<system.webServer>
<directoryBrowse enabled="true" />
<modules runAllManagedModulesForAllRequests="true">
<add name="CORSProxyModule" type="Shared.HttpModules.CORSProxyModule" />
</modules>
<httpProtocol>
<customHeaders>
<add name="Access-Control-Allow-Origin" value="*" />
<add name="Access-Control-Expose-Headers" value="Authorization,Origin,Content-type,Accept" />
<add name="Access-Control-Allow-Credentials" value="True" />
<add name="Access-Control-Allow-Headers" value="Authorization,Origin,Content-type,Accept" />
<add name="Access-Control-Allow-Methods" value="GET,POST,PUT,DELETE,OPTIONS,HEAD" />
<add name="Access-Control-Max-Age" value="3600" />
</customHeaders>
</httpProtocol>
</system.webServer>
Then, try to perform WCF OData tests again
And, finally, all the requests performed successfully (as Chrome debugger console confirms)!
To solve CORS problem in Web API project we'll also use the CORSProxyModule.
Just copy table content to Excel file to perform some aggregating operations.
Results shown below was calculated for the sample of 20 repeats of each operation for each RESTful service type.
You can find out the allocation of ellapsed time to operation types when run test yourself. :)
Provider type | Total time, ms |
WCFOData | 2683 |
WebApi | 7889 |
ODataServer | 2006 |
Here I will try to estimate difficulty of expanding our services functionality.
Extensibility difficulty Feature/Task | WCF OData | ASP.NET Web API | OData Server |
Need to code (server side) | Need to build and deploy (server side) | Need to code (server side) | Need to build and deploy (server side) | Need to code (server side) | Need to build and deploy (server side) |
Get a collection of new entity | + | + | + | + | - | - |
Get a collection with filters | - | - | + | + | - | - |
Get a collection with projections | - | - | + | + | - | - |
Get a collection with explicit entity fields | - | - | + | + | - | - |
Invoke stored procedure | + | + | + | + | - | - |
Resolve CORS problem | + | + | + | + | - | - |
Use authentication/authorization | + | + | + | + | - | - |
Working with files | + | + | + | + | Is not supported at the moment |
In this article we've worked with three .NET RESTful services. We've created a WCF OData and WebAPI projects and use automatical OData Server. Then, we've created the project with some tests for RESTful services.
So, what RESTful service type will be my personal choice, if taking into account the results of tests and aspect of extensibility?
My answer for this question is based on the next considerations:
- If I will need full control over REST baseline functionality - I will choose ASP.NET Web API. This is a case when the functional requirements to the service are not stable and are changing often.
But in this case I will have all the problems related to compile/build process and the following service deploy to production environment.
- If I will need an out-of-box RESTful service solution - I will definetly prefer OData Server. Because in this case I will have great OData opportunities with no efforts to code and build/deploy procedure.
But this alternative is not a good choice if requirements to service are unexpected and can change often. We don't have control over service in this scenario.
So, this service will be optimal for small and some middle size projects with the stable set of service features.
I haven't seen scenarios where I definetly would need RESTful service based on WCF OData except of case when OData Server is not available for any reasons.
History
18-04-2016 - initial state.