OData is an application-level protocol that has been designed to provide data interaction operations via HTTP. Besides basic data manipulation capabilities (such as adding, deleting and updating) it also provides more advanced mechanisms such as filtering and navigation between related entities.
In this post I am going to show how to leverage some of OData features introduced to ASP.NET Web API to build example service.
OData
You may be wondering why would you need another http based protocol for your web apps. Aren't simple JSON or XML services good enough? Well in fact OData extends these and is not meant to be a replacement. It can use either XML (ATOM) or JSON to represent resources and what is important adheres to REST principles. In some sense it builds on top of 'simple' REST HTTP services with a very clear aim - to simplify and standardize the way we manipulate and query resources and data sets. If your application is data centric chances are you could benefit from OData. Also if you've struggled to create search/filter or paging api for your REST services, OData has a provides this as well.
Some examples of OData query syntax:
- Entity set - /Artists
- Entity by id - /Artists(1)
- Sorting - /Artists?$orderby=Name
- Filtering - /Artists?$filter=Name eq 'Gridlock'
But that's just a tip of an iceberg.
Instead of talking more, let's write some code. Fortunately, ASP.NET Web API let's us create OData endpoints quite easily.
Creating the project
Let's start by creating a new ASP.NET Web API project. We won't need any MVC related things (views, js libraries, etc.).
OData functionality is provided by a separate assembly that has to be installed separately. Please note that at the time of writing the package is still pre-release and the latest version available on official nuget repository is 0.3 RC (see this blogpost for a detailed overview of the release).
Unfortunately there is an issue when using this package with latest ODataLib preventing some functionality from working (eg. filtering). Moreover newer commits introduced some breaking changes in configuration API. Because there is no point learning deprecated API we will use nightly builds available at http://www.myget.org/F/aspnetwebstacknightly/ nuget source. If you are unsure how to configure nuget to get these, have a look here.
Once you set up nightly build nuget source, you can install latest Web API OData package using Manage NuGet Packages, jus make sure you select 'Include Prerelease' in the dropdown on the top.
Please have in mind that Web API OData support is still work in progress and may miss support for certain OData features. Having said this, it definitely provides an impressive set of functionality.
Data model
We need a simple model to operate on. I will use Entity Framework and SQL CE 4, but Web API's OData implementation does not constraint you to any particular data persistence technology.
CREATE TABLE [Album] ( [AlbumId] INT NOT NULL IDENTITY, [Title] NVARCHAR(160) NOT NULL, [ArtistId] INT NOT NULL, [GenreId] INT NOT NULL, [ReleaseDate] DATETIME, CONSTRAINT [PK_Album] PRIMARY KEY ([AlbumId]) ); CREATE TABLE [Artist] ( [ArtistId] INT NOT NULL IDENTITY, [Name] NVARCHAR(120), CONSTRAINT [PK_Artist] PRIMARY KEY ([ArtistId]) ); CREATE TABLE [Genre] ( [GenreId] INT NOT NULL IDENTITY, [Name] NVARCHAR(120), [Description] NVARCHAR(1020), CONSTRAINT [PK_Genre] PRIMARY KEY ([GenreId]) ); ALTER TABLE [Album] ADD CONSTRAINT [FK_AlbumArtistId] FOREIGN KEY ([ArtistId]) REFERENCES [Artist] ([ArtistId]) ON DELETE NO ACTION ON UPDATE NO ACTION; CREATE INDEX [IFK_AlbumArtistId] ON [Album] ([ArtistId]); ALTER TABLE [Album] ADD CONSTRAINT [FK_AlbumGenreId] FOREIGN KEY ([GenreId]) REFERENCES [Genre] ([GenreId]) ON DELETE NO ACTION ON UPDATE NO ACTION; CREATE INDEX [IFK_AlbumGenreId] ON [Album] ([GenreId]);
You can create a new SQL CE database in App_Data folder and use built in explorer to execute SQL code. Please note that it does not support execution of multiple statements so you will need to execute it one by one. Once database schema is in place we can generate Entity Data Model using the wizard provided (it should automatically detect the database created).
In the end we should get a DbContext class that will be used to perform data operations.
$metadata endpoint and IEdmModel
As I mentioned previously OData standard defines a special metadata endpoint that contains a document defining the entity sets, relationships, entity types, and operations. This makes OData service self-describing and enables client libraries to generate client-side code to represent server types and simplify service access (for example by generating proxies). Metadata endpoint should be available under /$metadata. If you are familiar with SOAP services you can think about it roughly as a WSDL analogue.
GET http://services.odata.org/Northwind/Northwind.svc/$metadata
Metadata document uses OData Common Schema Definition Language (CSDL). Fortunately ASP.NET Web API can expose $metadata endpoint for us, as long as we supply a representation of our model in the form of IEdmModel object.
public class ModelBuilder { public IEdmModel Build() { ODataModelBuilder modelBuilder = new ODataConventionModelBuilder(); modelBuilder.EntitySet<Album>("Albums"); modelBuilder.EntitySet<Artist>("Artists"); modelBuilder.EntitySet<Genre>("Genres"); return modelBuilder.GetEdmModel(); } }
You can also build model representation explicitly using ODataModelBuilder to have more fine grained control over generated representation.
public IEdmModel BuildExplicitly() { ODataModelBuilder modelBuilder = new ODataModelBuilder(); EntitySetConfiguration<Genre> genres = modelBuilder.EntitySet<Genre>("Genres"); EntityTypeConfiguration<Genre> genre = genres.EntityType; genre.HasKey(g => g.GenreId); genre.Property(g => g.Name); genre.Property(g => g.Description); //(...) return modelBuilder.GetEdmModel(); }
Enabling OData
Microsoft.AspNet.WebApi.OData package provides a set of classes that are supposed to plug into Web API extensibility points in order to provide OData support (formatters, path handling, etc.).
The RC version had a single HttpConfiguration.EnableOData(IEdmModel) helper method that did all this in one go. However this approach wasn't really well fitted for scenarios where we wanted to support different models in one application. Because of this latest versions use per route configuration (which is more flexible).
public static class WebApiConfig { public static void Register(HttpConfiguration config) { var modelBuilder = new ModelBuilder(); IEdmModel model = modelBuilder.Build(); config.Routes.MapODataRoute("OData", null, model); config.EnableQuerySupport(); } }
This code (executed from Global.asax.cs) does two things:
- registers our model representation (IEdmModel) with a route - we are using null for route prefix, meaning it will be the root route, but we could have specified something like 'albums' making OData Albums services available at /albums instead of / (and thanks to this we could serve more data models in one app)
- enables query support (EnableQuerySupport()) for actions returning IQueryable<T> (we will show what's that about when creating controllers)
Now our service should automagically know how to handle OData ~/$metadata request. Cool, isn't it :) ?
<edmx:Edmx xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx" Version="1.0"> <edmx:DataServices xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" m:DataServiceVersion="1.0"> <Schema xmlns="http://schemas.microsoft.com/ado/2009/11/edm" Namespace="Piotr.ODataWebApiService.Service.Models"> <EntityType Name="Album">...</EntityType> <EntityType Name="Artist">...</EntityType> <EntityType Name="Genre">...</EntityType> <Association Name="Piotr_ODataWebApiService_Service_Models_Album_Artist_Piotr_ODataWebApiService_Service_Models_Artist_ArtistPartner">...</Association> <Association Name="Piotr_ODataWebApiService_Service_Models_Album_Genre_Piotr_ODataWebApiService_Service_Models_Genre_GenrePartner">...</Association> <Association Name="Piotr_ODataWebApiService_Service_Models_Artist_Albums_Piotr_ODataWebApiService_Service_Models_Album_AlbumsPartner">...</Association> <Association Name="Piotr_ODataWebApiService_Service_Models_Genre_Albums_Piotr_ODataWebApiService_Service_Models_Album_AlbumsPartner">...</Association> </Schema> <Schema xmlns="http://schemas.microsoft.com/ado/2009/11/edm" Namespace="Default">...</Schema> </edmx:DataServices> </edmx:Edmx>
Controllers
Now it's time to reap the reward. We need to add controllers that will actually expose our entities as OData resources. As you will see this is not very different from writing 'regular' CRUD controllers. It is very easy to expose OData entity set.
[ODataRouting] [ODataFormatting] public class ArtistsController : ApiController { private AlbumsContext db = new AlbumsContext(); // GET /Artists // GET /Artists?$filter=startswith(Name,'Grid') [Queryable] public IQueryable<Artist> Get() { return db.Artists; } protected override void Dispose(bool disposing) { db.Dispose(); base.Dispose(disposing); } }
/Artists resource should now become available along with all the fancy filtering functionality for entity sets.
OData specific routing and formatting has been provided by controller attributes. Alternatively instead of deriving ArtistsController from ApiController we could have derived from ODataController which is a helper abstract class already decorated with all the necessary attributes (I would recommend this approach as additional functionality may be introduced in the future).
Now we can continue by providing other actions such as add (POST), update (PUT), partial update (PATCH) and delete.
[ODataRouting] [ODataFormatting] public class ArtistsController : ApiController { private AlbumsContext _db = new AlbumsContext(); // GET /Artists // GET /Artists?$filter=startswith(Name,'Grid') [Queryable] public IQueryable<Artist> Get() { return _db.Artists; } // GET /Artists(2) public HttpResponseMessage Get([FromODataUri]int id) { Artist artist = _db.Artists.SingleOrDefault(b => b.ArtistId == id); if (artist == null) { return Request.CreateResponse(HttpStatusCode.NotFound); } return Request.CreateResponse(HttpStatusCode.OK, artist); } public HttpResponseMessage Put([FromODataUri] int id, Artist artist) { if (!_db.Artists.Any(a => a.ArtistId == id)) { return Request.CreateResponse(HttpStatusCode.NotFound); } //overwrite any existing id, as url is more explicit artist.ArtistId = id; _db.Entry(artist).State = EntityState.Modified; try { _db.SaveChanges(); } catch (DbUpdateConcurrencyException) { return Request.CreateResponse(HttpStatusCode.NotFound); } return Request.CreateResponse(HttpStatusCode.NoContent); } public HttpResponseMessage Post(Artist artist) { var odataPath = Request.GetODataPath(); if (odataPath == null) { return Request.CreateErrorResponse(HttpStatusCode.BadRequest, "ODataPath not present in the request."); } var entitySetPathSegment = odataPath.Segments.FirstOrDefault() as EntitySetPathSegment; if (entitySetPathSegment == null) { return Request.CreateErrorResponse(HttpStatusCode.BadRequest, "ODataPath does not start with entity set path segment"); } Artist addedArtist = _db.Artists.Add(artist); _db.SaveChanges(); var response = Request .CreateResponse(HttpStatusCode.Created, addedArtist); response.Headers.Location = new Uri(Url.ODataLink( entitySetPathSegment, new KeyValuePathSegment(ODataUriUtils .ConvertToUriLiteral(addedArtist.ArtistId , ODataVersion.V3)))); return response; } public HttpResponseMessage Patch([FromODataUri] int id, Delta<Artist> artistPatch) { Artist artist = _db.Artists .SingleOrDefault(p => p.ArtistId == id); if (artist == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } artistPatch.Patch(artist); _db.SaveChanges(); return Request.CreateResponse(HttpStatusCode.NoContent); } public HttpResponseMessage Delete([FromODataUri] int id) { Artist artist = _db.Artists.Find(id); if (artist == null) { return Request.CreateResponse(HttpStatusCode.NotFound); } _db.Artists.Remove(artist); _db.SaveChanges(); return Request.CreateResponse(HttpStatusCode.Accepted); } protected override void Dispose(bool disposing) { _db.Dispose(); base.Dispose(disposing); } }
Please note that current builds use JSON as default formatting. You can change the format to XML by using appropriate Accept header when making the request.
Security and mass assignment
In the example above we expose our model directly to the user. We also assume that users can modify data entities including all their properties without restrictions (which in this particular case is not a security problem).
In real life scenarios that involve authorization (and authentication) such an approach is very often unacceptable as we may obliviously expose properties that were never supposed to be modified by the given user. This is especially true when our model contains properties that directly affect authorization or authentication process (eg. the infamous isAdmin flag or a property denoting owner of an object). Usually in such case we need an extra layer of security and validation that will ensure a user does not execute an action he is not permitted to execute (eg. modify an object he does not own). If possible we also can introduce a flattened-out DTO model that is mapped to data model (eg. using Automapper). Such DTOs are meant to 'hide' persistable data model.
Testing the service
I will use Fiddler composer to test the service.
Note the Content-Type: application/json header. This should add a new genre. If we wanted to make a partial update to the entity we would PATCH verb as follows.
Now the genre with id=3 will have an updated description.
Finally, let's issue a query against Artists entity set that will sort the results and return their count:
http://localhost:2537/Artists?$orderby=Name&$inlinecount=allpages
As you can see we didn't have to write any special logic to support this feature - all was provided by the framework. If we wanted we also could have provided custom actions in the controllers as we are not limited to OData specific crud operations.
OData is without a doubt an interesting protocol. It feels a little bit like REST services on steroids :)
The source code is available as usually on bitbucket.