Creating an OData v4 API with ASP.NET Core 2.0

What is the Open Data Protocol (OData)?

OData Logo
OData Logo

Why would I want to use OData?

OData URL Conventions

OData Url Format
  • Service Root URL is the root address for the API.
  • Resource Path identifies exactly which resource you are trying to achieve.
  • Query Options is how you define to the API the format in which you need that data to be delivered.
  • $select: Allows you to define a subset of properties to return from that Resource.
  • $expand: Allows you to include data from a related resource to your query results.
  • $orderby: Not surprisingly, allows you to define the ordering of the returned dataset.
  • $top: Allows you to select the top X results of the query.
  • $skip: Allows you to skip X results of the query.
  • $count: Allows you to get a count of items that would result from that query.
  • $search: Allows for a free-text search on that particular resource
  • $format: Allows you to define the format for the returned data in some query types
  • $filter: Allows you to define a filter for your dataset.

OData and ASP.NET

letscode

Implementing your API

public class Author
{
public Author()
{
Books = new Collection<Book>();
}
[Key]
public int Id { get; set; }
[Required]
public string Name { get; set; }
public virtual ICollection<Book> Books { get; set; }
}
public class Publisher
{
public Publisher()
{
Books = new Collection<Book>();
}
[Key]
public int Id { get; set; }
[Required]
public string Name { get; set; }
public virtual ICollection<Book> Books { get; set; }
}
public class Book
{
[Key]
public int Id { get; set; }
[Required]
public string Name { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; }
public int PublisherId { get; set; }
public Publisher Publisher { get; set; }
}
public class BooksContext : DbContext
{
public DbSet<Book> Books { get; set; }
public DbSet<Author> Authors { get; set; }
public DbSet<Publisher> Publishers { get; set; }
public BooksContext() : base()
{
}
public BooksContext(DbContextOptions options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Publisher>()
.HasMany(p => p.Books)
.WithOne(b => b.Publisher)
.HasForeignKey(b => b.PublisherId);
modelBuilder.Entity<Author>()
.HasMany(a => a.Books)
.WithOne(b => b.Author)
.HasForeignKey(b => b.AuthorId);
}
}
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<BooksContext>(options =>
{
options.UseSqlite("Data Source=Books.db");
});
services.AddMvc().AddJsonOptions(opt =>
{
opt.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
});
}
internal static class BooksInitializer
{
private static bool _initialized = false;
private static object _lock = new object();
private static List<Author> authors;
private static List<Book> books;
private static List<Publisher> publishers;
public static void Seed(BooksContext context)
{
AddPublishers(context);
AddAuthors(context);
AddBooks(context);
}
internal static void Initialize(BooksContext context)
{
if (!_initialized)
{
lock (_lock)
{
if (_initialized)
return;
InitializeData(context);
}
}
}
private static void AddAuthors(BooksContext context)
{
authors = new List<Author>
{
// Data
};
if (!context.Authors.Any())
{
context.Authors.AddRange(authors);
context.SaveChanges();
}
}
private static void AddBooks(BooksContext context)
{
books = new List<Book>
{
// Data
};
if (!context.Books.Any())
{
context.Books.AddRange(books);
context.SaveChanges();
}
}
private static void AddPublishers(BooksContext context)
{
publishers = new List<Publisher>
{
// Data
};
if (!context.Publishers.Any())
{
context.Publishers.AddRange(publishers);
context.SaveChanges();
}
}
private static void InitializeData(BooksContext context)
{
context.Database.Migrate();
Seed(context);
}
}
public static void Main(string[] args)
{
var host = BuildWebHost(args);
using (var scope = host.Services.CreateScope())
{
var services = scope.ServiceProvider;
var context = services.GetRequiredService<BooksContext>();
BooksInitializer.Initialize(context);
}
host.Run();
}
PS C:\BooksAPI\BooksAPI.REST>dotnet ef migrations add Initial
Done. To undo this action, use 'ef migrations remove'
[Produces("application/json")]
[Route("api/Authors")]
public class AuthorsController : Controller
{
private readonly BooksContext context;
public AuthorsController(BooksContext context) => this.context = context; // GET: api/Authors
[HttpGet]
public IEnumerable<Author> Get() => context.Authors.Include(r => r.Books).ThenInclude(b => b.Publisher);
}
[Produces("application/json")]
[Route("api/books")]
public class BooksController : Controller
{
private readonly BooksContext context;
public BooksController(BooksContext context) => this.context = context; // GET: api/books
[HttpGet]
public IEnumerable<Book> Get() => context.Books.Include(b => b.Author).Include(b => b.Publisher);
}
[Produces("application/json")]
[Route("api/Publishers")]
public class PublishersController : Controller
{
private readonly BooksContext context;
public PublishersController(BooksContext context) => this.context = context; // GET: api/Publishers
[HttpGet]
public IEnumerable<Publisher> Get() => context.Publishers.Include(r => r.Books).ThenInclude(b => b.Author);
}

Payload Size

[
{
"id": 1,
"name": "Into Thin Air",
"authorId": 4,
"author": {
"id": 4,
"name": "Jon Krakauer",
"books": []
},
"publisherId": 2,
"publisher": {
"id": 2,
"name": "Anchor",
"books": []
}
},
// ... a LOT more Data
{
"id": 13,
"name": "Harry Potter And The Goblet Of Fire",
"authorId": 3,
"author": {
"id": 3,
"name": "J.K. Rowling",
"books": [
{
"id": 10,
"name": "Harry Potter and the Sorcerer's Stone",
"authorId": 3,
"publisherId": 3,
"publisher": {
"id": 3,
"name": "Scholastic",
"books": [
{
"id": 11,
"name": "Harry Potter And The Chamber Of Secrets",
"authorId": 3,
"publisherId": 3
},
{
"id": 12,
"name": "Harry Potter and the Prisoner of Azkaban",
"authorId": 3,
"publisherId": 3
}
]
}
},
{
"id": 11,
"name": "Harry Potter And The Chamber Of Secrets",
"authorId": 3,
"publisherId": 3,
"publisher": {
"id": 3,
"name": "Scholastic",
"books": [
{
"id": 10,
"name": "Harry Potter and the Sorcerer's Stone",
"authorId": 3,
"publisherId": 3
},
{
"id": 12,
"name": "Harry Potter and the Prisoner of Azkaban",
"authorId": 3,
"publisherId": 3
}
]
}
},
{
"id": 12,
"name": "Harry Potter and the Prisoner of Azkaban",
"authorId": 3,
"publisherId": 3,
"publisher": {
"id": 3,
"name": "Scholastic",
"books": [
{
"id": 10,
"name": "Harry Potter and the Sorcerer's Stone",
"authorId": 3,
"publisherId": 3
},
{
"id": 11,
"name": "Harry Potter And The Chamber Of Secrets",
"authorId": 3,
"publisherId": 3
}
]
}
}
]
},
"publisherId": 3,
"publisher": {
"id": 3,
"name": "Scholastic",
"books": [
{
"id": 10,
"name": "Harry Potter and the Sorcerer's Stone",
"authorId": 3,
"author": {
"id": 3,
"name": "J.K. Rowling",
"books": [
{
"id": 11,
"name": "Harry Potter And The Chamber Of Secrets",
"authorId": 3,
"publisherId": 3
},
{
"id": 12,
"name": "Harry Potter and the Prisoner of Azkaban",
"authorId": 3,
"publisherId": 3
}
]
},
"publisherId": 3
},
{
"id": 11,
"name": "Harry Potter And The Chamber Of Secrets",
"authorId": 3,
"author": {
"id": 3,
"name": "J.K. Rowling",
"books": [
{
"id": 10,
"name": "Harry Potter and the Sorcerer's Stone",
"authorId": 3,
"publisherId": 3
},
{
"id": 12,
"name": "Harry Potter and the Prisoner of Azkaban",
"authorId": 3,
"publisherId": 3
}
]
},
"publisherId": 3
},
{
"id": 12,
"name": "Harry Potter and the Prisoner of Azkaban",
"authorId": 3,
"author": {
"id": 3,
"name": "J.K. Rowling",
"books": [
{
"id": 10,
"name": "Harry Potter and the Sorcerer's Stone",
"authorId": 3,
"publisherId": 3
},
{
"id": 11,
"name": "Harry Potter And The Chamber Of Secrets",
"authorId": 3,
"publisherId": 3
}
]
},
"publisherId": 3
}
]
}
}
]

Querying the Data

SELECT "b"."Id", "b"."AuthorId", "b"."Name", "b"."PublisherId", "b.Publisher"."Id", "b.Publisher"."Name", "b.Author"."Id", "b.Author"."Name"
FROM "Books" AS "b"
INNER JOIN "Publishers" AS "b.Publisher" ON "b"."PublisherId" = "b.Publisher"."Id"
INNER JOIN "Authors" AS "b.Author" ON "b"."AuthorId" = "b.Author"."Id""

Changing our API to OData

PM> Install-Package Microsoft.AspnetCore.Odata -Version 7.0.0-beta1
Successfully installed 'Microsoft.AspNetCore.OData 7.0.0-beta1' to OData.Books
public class BooksModelBuilder
{
public IEdmModel GetEdmModel(IServiceProvider serviceProvider)
{
var builder = new ODataConventionModelBuilder(serviceProvider);
builder.EntitySet<Book>(nameof(Book))
.EntityType
.Filter() // Allow for the $filter Command
.Count() // Allow for the $count Command
.Expand() // Allow for the $expand Command
.OrderBy() // Allow for the $orderby Command
.Page() // Allow for the $top and $skip Commands
.Select();// Allow for the $select Command;
builder.EntitySet<Author>(nameof(Author))
.EntityType
.Filter() // Allow for the $filter Command
.Count() // Allow for the $count Command
.Expand() // Allow for the $expand Command
.OrderBy() // Allow for the $orderby Command
.Page() // Allow for the $top and $skip Commands
.Select() // Allow for the $select Command
.ContainsMany(x => x.Books)
.Expand();
builder.EntitySet<Publisher>(nameof(Publisher))
.EntityType
.Filter() // Allow for the $filter Command
.Count() // Allow for the $count Command
.Expand() // Allow for the $expand Command
.OrderBy() // Allow for the $orderby Command
.Page() // Allow for the $top and $skip Commands
.Select() // Allow for the $select Command
.HasMany(x => x.Books)
.Expand();
return builder.GetEdmModel();
}
}
public void ConfigureServices(IServiceCollection services)
{
// ... Other Configurations
services.AddOData();
services.AddTransient<BooksModelBuilder>();
// ... MVC Service Configurations
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, BooksModelBuilder modelBuilder)
{
// ... Other Configurations
app.UseMvc(routeBuilder =>
{
routeBuilder.MapODataServiceRoute("ODataRoutes", "odata", modelBuilder.GetEdmModel(app.ApplicationServices));
});
}
  • All the controllers were renamed to be in the singular form. That is only necessary due to our configuration in the ModelBuilder, they can be configured to be plural.
  • The return types were all changed to IQueryable<T>
  • The .Include() calls were removed, as they are no longer necessary. The OData package will take care of this for you.
  • We are no longer inheriting from Controller but from ODataController
  • We have a new decorator for the API calls: [EnableQuery]
[Produces("application/json")]
public class AuthorController : ODataController
{
private readonly BooksContext context;
public AuthorController(BooksContext context) => this.context = context; // GET: odata/Author
[EnableQuery]
public IQueryable<Author> Get() => context.Authors.AsQueryable();
}
[Produces("application/json")]
public class BookController : ODataController
{
private readonly BooksContext context;
public BookController(BooksContext context) => this.context = context; // GET: odata/Book
[EnableQuery]
public IQueryable<Book> Get() => context.Books.AsQueryable();
}
[Produces("application/json")]
public class PublisherController : ODataController
{
private readonly BooksContext context;
public PublisherController(BooksContext context) => this.context = context; // GET: odata/Publisher
[EnableQuery]
public IQueryable<Publisher> Get() => context.Publishers.AsQueryable();
}

New API and Results

{
"@odata.context": "http://localhost:5000/odata/$metadata#Book",
"value": [
{
"Id": 1,
"Name": "Into Thin Air",
"AuthorId": 4,
"PublisherId": 2
},
// ... Other Books
{
"Id": 13,
"Name": "Harry Potter And The Goblet Of Fire",
"AuthorId": 3,
"PublisherId": 3
}
]
}
{
"@odata.context": "http://localhost:5000/odata/$metadata#Book",
"value": [
{
"Id": 1,
"Name": "Into Thin Air",
"AuthorId": 4,
"PublisherId": 2
}
]
}
SELECT "$it"."Id", "$it"."AuthorId", "$it"."Name", "$it"."PublisherId"
FROM "Books" AS "$it"
ORDER BY "$it"."Id"
LIMIT @__TypedProperty_0"
{
"@odata.context": "http://localhost:5000/odata/$metadata#Book(Name)",
"value": [
{
"Name": "Into Thin Air"
}
]
}
SELECT "$it"."Id" AS "Value0", "$it"."Name" AS "Value"
FROM "Books" AS "$it"
ORDER BY "Value0"
LIMIT @__TypedProperty_0"
{
"@odata.context": "http://localhost:5000/odata/$metadata#Author",
"value": [
{
"Id": 3,
"Name": "J.K. Rowling",
"Books@odata.context": "http://localhost:5000/odata/$metadata#Author(3)/Books",
"Books": [
{
"Id": 10,
"Name": "Harry Potter and the Sorcerer's Stone",
"AuthorId": 3,
"PublisherId": 3
},
{
"Id": 11,
"Name": "Harry Potter And The Chamber Of Secrets",
"AuthorId": 3,
"PublisherId": 3
},
{
"Id": 12,
"Name": "Harry Potter and the Prisoner of Azkaban",
"AuthorId": 3,
"PublisherId": 3
},
{
"Id": 13,
"Name": "Harry Potter And The Goblet Of Fire",
"AuthorId": 3,
"PublisherId": 3
}
]
}
]
}
<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="BooksAPI.OData.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityType Name="Book">
<Key>
<PropertyRef Name="Id" />
</Key>
<Property Name="Id" Type="Edm.Int32" Nullable="false" />
<Property Name="Name" Type="Edm.String" Nullable="false" />
<Property Name="AuthorId" Type="Edm.Int32" />
<Property Name="PublisherId" Type="Edm.Int32" />
<NavigationProperty Name="Author" Type="BooksAPI.OData.Models.Author">
<ReferentialConstraint Property="AuthorId" ReferencedProperty="Id" />
</NavigationProperty>
<NavigationProperty Name="Publisher" Type="BooksAPI.OData.Models.Publisher">
<ReferentialConstraint Property="PublisherId" ReferencedProperty="Id" />
</NavigationProperty>
</EntityType>
<EntityType Name="Author">
<Key>
<PropertyRef Name="Id" />
</Key>
<Property Name="Id" Type="Edm.Int32" Nullable="false" />
<Property Name="Name" Type="Edm.String" Nullable="false" />
<NavigationProperty Name="Books" Type="Collection(BooksAPI.OData.Models.Book)" ContainsTarget="true" />
</EntityType>
<EntityType Name="Publisher">
<Key>
<PropertyRef Name="Id" />
</Key>
<Property Name="Id" Type="Edm.Int32" Nullable="false" />
<Property Name="Name" Type="Edm.String" Nullable="false" />
<NavigationProperty Name="Books" Type="Collection(BooksAPI.OData.Models.Book)" />
</EntityType>
</Schema>
<Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityContainer Name="Container">
<EntitySet Name="Book" EntityType="BooksAPI.OData.Models.Book">
<NavigationPropertyBinding Path="Author" Target="Author" />
<NavigationPropertyBinding Path="Publisher" Target="Publisher" />
</EntitySet>
<EntitySet Name="Author" EntityType="BooksAPI.OData.Models.Author" />
<EntitySet Name="Publisher" EntityType="BooksAPI.OData.Models.Publisher">
<NavigationPropertyBinding Path="Books" Target="Book" />
</EntitySet>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>

What’s Next?

Source Code

Sources:

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Lucas Araujo | Azure Coder

Lucas Araujo | Azure Coder

Software Development and everything that encircles it :)