Search code examples
c#asp.netasp.net-mvcodatadynamic-routing

Dynamic Routing in BaseUrl in Asp.Net Core OData 4.0


I am currently developing an OData Api for a C# Asp.Net Core application.

To stay in specifications of our API the URL needs to follow our multi-tenant architecture: https://website.com/api/tenants/{tenantId}/odata/

Since OData 4.0 has no specification how to implement a dynamic base url I implemented the following workaround: Use a middleware to replace the dynamic tenantId in the HTTP Context with a the static string "tenantId". Now I need to find a way to modify/manipulate the OData metadata to reverse back this workaround in the response.

Implementation Example

Starup.cs:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    private IConfiguration Configuration { get; }


    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDependencies(Configuration);
        services.AddDbContext<DBContext>();
        services.AddOData();
        services.AddODataQueryFilter();
        services.AddAutoMapper();
        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
    }


    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // Custom Workaround Middleware
        app.Use(async (context, next) =>
        {
            // TGis Method parses the tenant id from the Request.Path, replaces it and wries it to the context.Items to maintain the information for later
            (Microsoft.AspNetCore.Http.HttpContext contextwTid, System.Guid tenantGuid) = ODataHelper.ParseTenantIDToContext(context);
            context = contextwTid;
            await next.Invoke();
        });

        app.UseMvc(b =>
        {
            b.Select().Filter().OrderBy().MaxTop(100).Count();
            b.MapODataServiceRoute(
                routeName: "odata",
                routePrefix: "api/tenants/tenantId/odata",
                model: ODataHelper.GetEdmModel());
        });

    }

ODataHelper:

  ...
  public static (Microsoft.AspNetCore.Http.HttpContext, Guid) ParseTenantIDToContext(Microsoft.AspNetCore.Http.HttpContext context)
    {
        System.Guid tenantGuid = System.Guid.Empty;
        if (context.Request.Path.ToString().Split('/').Length > 3 && context.Request.Path.ToString().ToLower().Contains("odata"))
        {
            bool isValidGUID = System.Guid.TryParse(context.Request.Path.ToString().Split('/')[3], result: out tenantGuid);
            if (isValidGUID)
                context.Request.Path = context.Request.Path.Value.Replace(context.Request.Path.ToString().Split('/')[3], "tenantId");
            context.Items["tenantId"] = tenantGuid.ToString();
        }
        return (context, tenantGuid);
    }
  ...

Example controller:

public class ClientsController : ODataController
{
    private readonly DBService<Client> _service;

    public ClientsController(DBService<Client> service)
    {
        _service = service;
    }

    [HttpGet]
    [EnableQuery]
    [ODataRoute("Clients")]
    public async Task<IEnumerable<Client>> Get(
        ODataQueryOptions<Client> options)
    {
        System.Guid tenantId = ODataHelper.GetTenantIDFromContext(this.HttpContext);
        IQueryable res = await _service.Get(
            tenantId, 
            AuthorizationHelper.GetSubjectId(tenantId, User), 
            AuthorizationHelper.GetAllowedUserRoles(RoleType.Reporting), 
            options, 
            null);
        return new List<Client>(res.Cast<Client>());
    }
}

Questions:

  1. Is there a better way to implement dynamic base routing in OData with Asp.Net Core?
  2. Is there any way to manipulate the request or the OData Metadata. In detail the response needs to display the original url with the dynamic tenantId in "@OData.context" and (for the future) in the OData paging metadata.

Research/Googling so far:


Solution

  • EDIT 2: Sometimes you think so complicated that you miss the obvious. Solution for dynamic routing with OData:

    Startup.cs

    app.UseMvc(b =>
            {
                b.Select().Filter().OrderBy().MaxTop(100).Count();
                b.MapODataServiceRoute(
                    routeName: "odata",
                    routePrefix: "api/tenants/{tenantId}/odata",
                    model: ODataHelper.GetEdmModel());
            });
    

    Controller:

        [HttpGet]
        [EnableQuery]
        [ODataRoute("Clients")]
        public async Task<IEnumerable<Client>> Get(
            ODataQueryOptions<Client> options,
            [FromRoute] Guid tenantId)
        {
            IQueryable res = await _service.Get(
                tenantId,
                AuthorizationHelper.GetSubjectId(tenantId, User),
                AuthorizationHelper.GetAllowedUserRoles(RoleType.Reporting),
                options,
                null);
            return new List<Client>(res.Cast<Client>());
        }
    

    I leave my workaround in here for the case that somebody could use it:

    After a considerable research within in OData .Net Core Implementation I finally found, that my first provided link "ODataMediaTypeFormatter in WebApi" already provided a solution to my workaround.

    Firstly, the BaseAddressFactory can only the given HTTP request. Therfore, I needed to change the following Code:

     public static (Microsoft.AspNetCore.Http.HttpContext, Guid) ParseTenantIDToContext(Microsoft.AspNetCore.Http.HttpContext context)
        {
            System.Guid tenantGuid = System.Guid.Empty;
            if (context.Request.Path.ToString().Split('/').Length > 3 && context.Request.Path.ToString().ToLower().Contains("odata"))
            {
                bool isValidGUID = System.Guid.TryParse(context.Request.Path.ToString().Split('/')[3], result: out tenantGuid);
                if (isValidGUID)
                {
                    context.Request.Path = context.Request.Path.Value.Replace(context.Request.Path.ToString().Split('/')[3], "tenantId");
                    context.Items["tenantId"] = tenantGuid.ToString();
                    context.Request.Headers.Remove("tenantId");
                    context.Request.Headers.Append("tenantId", tenantGuid.ToString());
                }
            }
            return (context, tenantGuid);
        }
    

    In this section, I save the required tenantId not only in the HTTPContext but also as a special header within the HTTPRequest.

    The main solution is to provide a special BaseAddressFactory function which manipulates the base address OData uses to build the metadata. As implemetation I add the following code within the ConfigureServices after adding OData via services.AddOData():

    services.AddMvc(op =>
    {
         foreach (var formatter in op.OutputFormatters
              .OfType<ODataOutputFormatter>())
         {
              formatter.BaseAddressFactory = ODataHelper.CustomBaseAddressFactory;
         }
         foreach (var formatter in op.InputFormatters
              .OfType<ODataInputFormatter>())
         {
              formatter.BaseAddressFactory = ODataHelper.CustomBaseAddressFactory;
         }
    });
    

    My ODataHelper.CustomBaseAddressFactory looks like this:

    public static Uri CustomBaseAddressFactory (HttpRequest request)
    {
        Guid tenantGuid = GetTenantIDFromRequest(request);
        request.Headers.Remove("tenantId");
        Uri std = ODataInputFormatter.GetDefaultBaseAddress(request);
        string ret = replaceTentantIdInURL(std.ToString(), tenantGuid);
        return ret[ret.Length - 1] != '/' ? new Uri(ret + '/') : new Uri(ret);
    }
    

    To provide as much compability as possible I use the standard ODataInputFormatter.GetDefaultBaseAddress and afterwards replaces my static placeholder again.

    EDIT

    This way of saving the tenantId is quite insecure since the request headers can be created by the enduser as well. In the end, I decided to receive the ID out of our Authorization claims which provides it. Therefore, the user can not attack this workaround.