Skip to content

Joker.OData

Tomas Fabian edited this page Dec 21, 2020 · 43 revisions

Boilerplate code for OData web services. Please help out the community by sharing your suggestions and code improvements:

Install-Package Joker.OData

Features:

  • Built in Autofac IoC container for dependency injection
  • Built in serialization with NewtonsoftJson
  • ODataStartup Kestrel self hosting and IIS integration
  • ODataController GET, POST, PUT, DELETE, CreateRef and DeleteRef implementation
  • ErrorLoggerMiddleware
  • Enabled ODataBatchHandler. Support for SaveChangesOptions.BatchWithSingleChangeset with SQL Server database transaction. This means end to end transaction scope from your .NET clients.
  • Serilog
  • Both EntityFrameworkCore and EntityFramework are supported
  • Health check support

Install project template:

Manage extensions: download "AspNetCore OData EF Joker template" or download free vsix installer from marketplace https://marketplace.visualstudio.com/items?itemName=tomasfabian.SelfHostedODataService-Joker-EF

Menu: File->New->Project...

Add new project from template named SelfHostedODataService.Joker.EF (.Net Core)

EF Core project template name:

AspNetCore OData EFCore Joker template

ODataHost:

  using Joker.OData.Hosting;

  public class StartupBaseWithOData : Joker.OData.Startup.ODataStartup
  {
    public StartupBaseWithOData(IWebHostEnvironment env)
      : base(env)
    {
    }
  }

  public class Program
  {
    public static async Task Main(string[] args)
    {
      //var startupSettings = new KestrelODataWebHostConfig()
      var startupSettings = new IISODataWebHostConfig()
        {
          ConfigureServices = services =>
          {
            services.AddHostedService<SqlTableDependencyProviderHostedService>();
          }
        };

        await new ODataHost<StartupBaseWithOData>().RunAsync(args, startupSettings);
    }
  }

There are two OData server configuration options one for Kestrel and one for IIS integration:

  • KestrelODataWebHostConfig
  • IISODataWebHostConfig
    private static IISODataWebHostConfig ODataStartupConfigExample()
    {
      var configuration = new ConfigurationBuilder()
        .AddEnvironmentVariables()
        .Build();

      //all settings are optional
      var startupSettings = new IISODataWebHostConfig()
                            {
                              ConfigureServices = services =>
                                                  {
                                                    services.AddHostedService<SqlTableDependencyProviderHostedService>();
                                                  },
                              Urls = new[] { @"https://localhost:32778/" },
                              Configuration = configuration
                            };

      return startupSettings;
    }

How to call not configured webHostBuilder extensions:

  public class ODataHostExample : ODataHost<StartupBaseWithOData>
  {
    protected override void OnConfigureWebHostBuilder(IWebHostBuilder webHostBuilder)
    {
      webHostBuilder.CustomExtension();
    }
  }

Calling of webHostBuilder.Build inside OnConfigureWebHostBuilder is not recommended. ODataHost.Run will call it as the final step instead of you.

ODataStartup:

  public class StartupBaseWithOData : ODataStartup
  {
    public StartupBaseWithOData(IWebHostEnvironment env) 
      : base(env)
    {
    }

    protected override ODataModelBuilder OnCreateEdmModel(ODataModelBuilder oDataModelBuilder)
    {
      oDataModelBuilder.Namespace = "Example";

      oDataModelBuilder.EntitySet<Product>("Products");
      oDataModelBuilder.AddPluralizedEntitySet<Book>();

      return oDataModelBuilder;
    }

    private void ConfigureNLog()
    {
      var config = new LoggingConfiguration();
      // ...
    }

    protected override void OnConfigureServices(IServiceCollection services)
    {
      ConfigureNLog();
    }
    
    protected override void RegisterTypes(ContainerBuilder builder)
    {
      ContainerBuilder.RegisterModule(new ProductsAutofacModule());

      ContainerBuilder.RegisterType<ProductsConfigurationProvider>()
        .As<IProductsConfigurationProvider>()
        .SingleInstance();

      ContainerBuilder.RegisterType<SchedulersFactory>().As<ISchedulersFactory>()
        .SingleInstance();
    }

    protected override void OnConfigureApp(IApplicationBuilder app, IWebHostEnvironment env, IHostApplicationLifetime applicationLifetime)
    {
      base.OnConfigureApp(app, env, applicationLifetime); //Registers routeBuilder.MapRoute("WebApiRoute", "api/{controller}/{action}/{id?}");
    }
  }

ODataControllerBase<TEntity>:

Base class for OData CRUD operations. Supported operations are GetAll, FindByKey, Create, Update, Patch, Delete and CreateRef. You can intercept all CRUD operations, see delete entity example below.

  public class ProductsController : ODataControllerBase<Product>
  {
    public ProductsController(IRepository<Product> repository)
      : base(repository)
    {
    }

    protected override Task<int> OnDelete(int key)
    {
      //intercept delete entity example
      return base.OnDelete(key);
    }
  }

In order to find the related entity during AddLink or SetLink you have to provide the corresponding DbSet from the DbContext for the current http request (scope) in TryGetDbSet override:

  public class AuthorsController : ODataControllerBase<Author>
  {
    private readonly ISampleDbContext dbContext;

    public AuthorsController(IRepository<Author> repository, ISampleDbContext dbContext)
      : base(repository)
    {
      this.dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
    }

    protected override dynamic TryGetDbSet(Type entityType)
    {
      if (entityType == typeof(Book))
        return dbContext.Books;

      return null;
    }
  }

ReadOnlyODataController<TEntity> does not expose CUD operations.

(release 1.0):

  • DeleteRef
  • TransactionScopeODataBatchHandler

End point routing

If you need end point routing please use ODataStartup, otherwise you have to switch to ODataStartupLegacy

  public class StartupBaseWithOData : ODataStartupLegacy

ODataStartupLegacy disables end point routing:

      services.AddMvc(options =>
        {
          options.EnableEndpointRouting = false;
        })

and maps OData route with:

app.UseMvc(routeBuilder =>

Batch operations

In order the get database transactions you have to register IDbTransactionFactory

    protected override void RegisterTypes(ContainerBuilder builder)
    {
      base.RegisterTypes(builder);

      builder.RegisterType<SampleDbContext>()
        .As<ISampleDbContext, IDbTransactionFactory, IContext>()
        .WithParameter(connectionStringParameter)
        .InstancePerLifetimeScope();
    }

DbContextBase implements IDbTransactionFactory and is inherited from System.Data.Entity.DbContext so you could change your code like this

    public partial class SampleDbContext : DbContextBase //:DbContext

You can change the default SQL Server IsolationLevel.RepeatableRead in the following way

    protected override ODataBatchHandler OnCreateODataBatchHandler()
    {
      var batchHandler = (TransactionScopeODataBatchHandler)base.OnCreateODataBatchHandler();

      batchHandler.BatchDbIsolationLevel = IsolationLevel.ReadCommitted;

      return batchHandler;
    }

OData client side batch operations example

In this example all inserts are commited to the database together or rollback-ed in case of any error.

      try
      {
        var dataServiceContext = new ODataServiceContextFactory().Create(url);

        dataServiceContext.AddObject("Authors", new Author() { LastName = new Random().Next(1, 100000).ToString()});
        dataServiceContext.AddObject("Authors", new Author() { LastName = "Asimov"});
        dataServiceContext.AddObject("Authors", new Author() { LastName = new Random().Next(1, 100000).ToString()});

        var dataServiceResponse = await dataServiceContext.SaveChangesAsync(SaveChangesOptions.BatchWithSingleChangeset);
      }
      catch (Exception e)
      {
        Console.WriteLine(e);
        throw;
      }

Data access layer

Install-Package Joker.EntityFrameworkCore

using Joker.Contracts.Data;
using Joker.EntityFrameworkCore.Database;
using Joker.EntityFrameworkCore.Repositories;

  public interface ITestDbContext : IContext, IDbTransactionFactory
  {
    DbSet<Product> Products { get; set; }
  }

  public class TestDbContext : DbContextBase, ITestDbContext
  {
    public DbSet<Product> Products { get; set; }
  }

  public class ProductsRepository : Repository<Product>
  {
    private readonly ITestDbContext context;

    public ProductsRepository(ITestDbContext context) 
      : base(context)
    {
      this.context = context ?? throw new ArgumentNullException(nameof(context));
    }

    protected override DbSet<Product> DbSet => context.Products;
  }

or Install-Package Joker.EntityFramework

Note: EntityFramework uses IDbSet<TEntity> interface instead of DbSet<TEntity> abstract class as in EF Core

protected override IDbSet<Product> DbSet => context.Products;

EF Core migrations (v 1.1)

DesignTimeDbContextFactory reads the connection string from your appsettings.json during Add-Migration and Update-Database commands

  "ConnectionStrings": {
    "FargoEntities": "Server=127.0.0.1,1402;User Id = SA;Password=<YourNewStrong@Passw0rd>;Initial Catalog = Test;MultipleActiveResultSets=true"
  }
using Sample.DataCore.EFCore;
using Joker.EntityFrameworkCore.DesignTime;

  public class DesignTimeDbContextFactory : DesignTimeDbContextFactory<SampleDbContextCore>
  {
    public DesignTimeDbContextFactory()
    {
      ConnectionStringName = "FargoEntities"; // default ConnectionStringName is set to DefaultConnection
    }

    protected override SampleDbContextCore Create(DbContextOptions<SampleDbContextCore> options)
    {
      return new SampleDbContextCore(options);
    }
  }

How to override default settings

    public StartupBaseWithOData(IWebHostEnvironment env)
      : base(env)
    {
      SetSettings(startupSettings =>
                  {
                    startupSettings
                      .DisableHttpsRedirection(); //by default it is enabled

                    startupSettings.UseDeveloperExceptionPage = false; //by default it is enabled
                  });

      SetODataSettings(odataStartupSettings =>
                       {
                         odataStartupSettings
                           //OData route prefix setup https://localhost:5001/odata/$metadata
                           .SetODataRouteName("odata") //default is empty https://localhost:5001/$metadata
                           .DisableODataBatchHandler(); //by default it is enabled
                       });

      SetWebApiSettings(webApiStartupSettings =>
                        {
                          webApiStartupSettings
                            .SetWebApiRoutePrefix("myApi") //default is "api"
                            .SetWebApiTemplate("api/{controller}/{id}"); //default is "{controller}/{action}/{id?}"
                        });
    }

Code Samples

OData client examples:

dataServiceContext.AddObject("Books", book);
dataServiceContext.UpdateObject(book);
dataServiceContext.DeleteObject(book);
dataServiceContext.AttachTo("Books", book);
dataServiceContext.SetLink(book, "Publisher", publisher);
dataServiceContext.SetLink(book, "Publisher", null); //Remove navigation property reference
dataServiceContext.AddLink(author, "Books", book);
dataServiceContext.DeleteLink(author, "Books", book);

Filter books by id and include all authors:

https://localhost:5001/Books('New%20Id')?$expand=Authors

Logging of exceptions

ErrorLoggerMiddleware intercepts all exceptions and log them with ILogger provided by ILoggerFactory. Default logger for OData hosts is ConsoleLogger.

OData query validation (v 1.3)

You can override the default query validation settings:

    protected override ODataValidationSettings OnCreateODataValidationSettings()
    {
      var oDataValidationSettings = base.OnCreateODataValidationSettings();

      oDataValidationSettings.MaxExpansionDepth = 3; //default is 2

      oDataValidationSettings.AllowedQueryOptions = //disabled AllowedQueryOptions.Format
        AllowedQueryOptions.Apply | AllowedQueryOptions.SkipToken | AllowedQueryOptions.Count
        | AllowedQueryOptions.Skip | AllowedQueryOptions.Top | AllowedQueryOptions.OrderBy
        | AllowedQueryOptions.Select | AllowedQueryOptions.Expand | AllowedQueryOptions.Filter;


      return oDataValidationSettings;
    } 

Api StartUp (v1.6.0)

Added support for web apis without OData configuration:

  public class ApiStartup : Joker.OData.Startup.ApiStartup
  {
    public ApiStartup(IWebHostEnvironment env)
      : base(env, enableEndpointRouting : true)
    {
      SetSettings(s =>
      {
        s.UseAuthentication = false;
        s.UseAuthorization = false;
      });
    }

    protected override void OnConfigureServices(IServiceCollection services)
    {
      base.OnConfigureServices(services);

      services.AddSingleton<ICarService, CarService>();
    }
  }

  public class Program
  {
    public static async Task Main(string[] args)
    {
      var webHostConfig = new IISWebHostConfig();
      
      await new ApiHost<ApiStartup>().RunAsync(args, webHostConfig);
    }
  }

Health checks (v1.6.0)

https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-3.1

Basic health check https://localhost:5001/healthCheck can be overriden:

    protected override string HealthCheckPath { get; } = "/healthCheck"; // override default /health

    protected override IEndpointConventionBuilder OnMapHealthChecks(IEndpointRouteBuilder endpoints)
    {
      var healthCheckOptions = new HealthCheckOptions
      {
        AllowCachingResponses = false,
        ResultStatusCodes =
        {
          [HealthStatus.Healthy] = StatusCodes.Status200OK,
          [HealthStatus.Degraded] = StatusCodes.Status200OK,
          [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
        }
      };

      return endpoints.MapHealthChecks(HealthCheckPath, healthCheckOptions)
         .RequireAuthorization();
    }

or extended:

    protected override IEndpointConventionBuilder OnMapHealthChecks(IEndpointRouteBuilder endpoints)
    {
      return base.OnMapHealthChecks(endpoints).RequireAuthorization();
    }

Autofac 5-6 breaking changes (v2.0.0)

IEdmModel registration (v2.1.0)

    protected override void OnRegisterEdmModel(IServiceCollection services)
    {
      services.AddSingleton(EdmModel); //in this case, it is same as base.OnRegisterEdmModel(services)
    }

UseCors (v2.1.0)

    public Startup(IWebHostEnvironment env)
      : base(env)
    {      
      SetSettings(startupSettings =>
      {
        startupSettings.UseCors = true;
      });
    }

Configure cors policies

    
    protected override void OnConfigureCorsPolicy(CorsOptions options)
    {
      options.AddPolicy(name: MyAllowSpecificOrigins,
        builder =>
        {
          builder.WithOrigins("https://localhost:5003", "http://localhost:3000")
            .AllowAnyMethod()
            .AllowAnyHeader()
            .AllowCredentials();
        });

      options.DefaultPolicyName = MyAllowSpecificOrigins;
    }
          
    protected override void OnConfigureCorsPolicy(CorsOptions options)
    {
      //Sets AllowAnyOrigin, AllowAnyMethod and AllowAnyHeader.
      AddDefaultCorsPolicy(options);
    }

List of breaking changes from version 0.9 to 1.0

  • ODataStartupConfig was renamed to ODataWebHostConfig

  • KestrelODataStartupConfig was renamed to KestrelODataWebHostConfig

  • IISHostedODataStartupConfig was renamed to IISODataWebHostConfig

  • ODataStartup namespace Joker.OData changed to Joker.OData.Startup

  • Repository and ReadOnlyRepository were moved to Joker.EntityFramework.Repositories