I'm writing a WebApi OData service (using Microsoft.AspNet.OData v6 package) for an ERP system and I'm running into a bit of issues.
The system has multiple modules (Sales, Purchase, Management...) and I'm trying to reflect this in my service.
For example, I have an entity "SalesOrders" and an entity "PurchaseOrders". These are different from each other, and I would like to expose these via different OData routes.
For SalesOrders, I'd like my route to be ~/odata/Sales/Orders while PurchaseOrders should be under ~/odata/Purchase/Orders.
Unfortunately I've been unable to get this to work, because OData keeps telling me that "Sales" and "Purchase" are not valid OData path segments (which is correct, as these are not my entity): Resource not found for segment 'Sales'
I could use different models, linked to different routes (using a workaround for a ModelBoundODataRoute found here), but then I won't have a unified $metadata document. I could probably get away with writing a custom ControllerSelector (possibly a NamespaceAwareControllerSelector, or maybe using some kind of marker attributes), but that's a whole different can of worms I'd rather avoid if possible. Last resort would be to just not try to divide this up and use entities called PurchaseOrder, SalesOrder and so on.
WebApiConfig.Register:
public static void Register(HttpConfiguration config)
{
//OData routes
var salesModel = ModelBuilder.GetSalesModel(); // this just builds an edm model with the required entities
config.MapModelBoundODataServiceRoute(routeName: "salesRoute", routePrefix: "odata/Sales", configureAction: action =>
{
action.AddDefaultODataServices()
.AddService<IEdmModel>(ServiceLifetime.Singleton, s => salesModel)
.AddService<IEnumerable<IODataRoutingConvention>>(ServiceLifetime.Singleton, sp =>
{
return ODataRoutingConventions.CreateDefault()
.Concat(new[] { new ModelBoundODataAttributeRoutingConvention(salesModel, "salesRoute", config) });
})
});
var purchaseModel = ModelBuilder.GetPurchaseModel();
config.MapModelBoundODataServiceRoute(routeName: "purchaseRoute", routePrefix: "odata/Purchase", configureAction: action =>
{
action.AddDefaultODataServices()
.AddService<IEdmModel>(ServiceLifetime.Singleton, s => purchaseModel)
.AddService<IEnumerable<IODataRoutingConvention>>(ServiceLifetime.Singleton, sp =>
{
return ODataRoutingConventions.CreateDefault()
.Concat(new[] { new ModelBoundODataAttributeRoutingConvention(purchaseModel, "purchaseRoute", config) });
})
});
}
Is there any way to add a string literal (not mapping to a resource) to the OData route prefix, while preserving a single unified $metadata document?
An alternative option may be to use Containment to do this
This would involve having a singleton named Sales with a contained navigation property called Orders and a singleton named Purchase with a contained navigation property named Orders. This does raise the interesting question of what should be returned when you ask for the singleton Sales using the URL ~/odata/Sales but it would give you the URLs that you are looking for. For more details, see the link below:
https://learn.microsoft.com/en-us/aspnet/web-api/overview/odata-support-in-aspnet-web-api/odata-v4/odata-containment-in-web-api-22
Related
I'm trying to configure Razor Pages routing (not Razor Views) and make it support multi-tenancy via directory-structure...
So, I'd have a bunch of pages for different tenants, eg:
/Pages/1/About.cshtml
/Pages/2/About.cshtml
/Pages/2/Other.cshtml
... plus many many more ...
and an origin to tenantID lookup, ie:
"example.com" : 1,
"site.tld" : 2,
...
Then when someone requests "example.com/About" it maps correctly to page in the tenant # 1 subfolder (since "example.com" maps to # 1 in example above), rather than a different tenant "About" page.
Discarded solutions...
There are a bunch of Razor View solutions but that's not what I'm looking for (I'm using Razor PAGES).
Also I've seen one person use url-rewriting, but this is a bit brute-force and inelegant and I'd like a proper routing solution.
Hardcoding routes would obviously work (either in a mapping or in page directives) but this is not scalable and is error-prone.
Possible solution?
Using IPageRouteModelConvention seems like the "correct" way of configuring Razor Pages routes?
Seems like I can modify the route selectors to strip off the tenant Id sub-dir and therefore make the pages available at the root path. However then I also need to make sure the appropriate tenant's page is requested rather than a different tenant's...
One way (I think) this could be done is using an ActionConstraint (which can also be configured in IPageRouteModelConvention). If the origin:tenantId dictionary was hard-coded then I think that would be easy... but my tenant lookup data needs to be pulled from the DB (I actually have a TenantCollection service added as a singleton in the .NET Core service collection already).
The problem is that I don't have access to the ServiceProvider (to get my TenantCollection) at builder.Services.Configure(...) call.
So I can't create the ActionConstraint to restrict access to certain pages for certain origins since I don't have the tenant mapping data.
Here is some example code in-case it helps to illustrate...
builder.Services.AddSingleton<TenantCollection>();
builder.Services.AddRazorPages();
builder.Services.Configure<RazorPagesOptions>(options =>
{
var tenantCollection = GET_MY_TENANT_COLLECTION; // Cant do?
options.Conventions.Add(new MultiTenantPageRouteModelConvention(tenantCollection));
});
I feel like I'm missing something obvious, or attacking the problem from the wrong direction?
So, in the end I was "missing something obvious".
In the ActionConstraint the ServiceProvider can be accessed via the ActionConstraintContext's RouteContext.HttpContext.RequestServices reference.
This allows me to get the service I needed to do what I needed. Simple.
Instead of leaving it at that, I figure I might as well make this post more worth while.. so I'll give a stripped down implementation of what I'm doing, just in case some future person finds it useful.
Program.cs
...
builder.Services.AddSingleton<MyTenantCollection>();
builder.Services.AddScoped(MyTenant.ImplementationFactoryBasedOnRequestOrigin);
builder.Services.Configure<RazorPagesOptions>(options =>
{
options.Conventions.Add(new MyPageRouteModelConvention());
});
...
MyPageRouteModelConvention.cs
...
public class MyPageRouteModelConvention : IPageRouteModelConvention
{
public void Apply(PageRouteModel model)
{
// Only modify pages in the tenants folder.
if (!model.ViewEnginePath.StartsWith("/Tenants/"))
return;
// Tenants/<num>/<page>...
if (!validateAndParseTenantFolderNumFromPath(model.ViewEnginePath, out int tenantFolderNum))
return;
var constraint = new MyTenantActionConstraint(tenantFolderNum);
foreach (var selector in model.Selectors)
{
// Change the selector route template so the page is
// accessible as if it was in the root path.
// Eg "Tenants/123/SomePage" changes to "SomePage"
selector.AttributeRouteModel.Template =
stripOffTheTenantPath(selector.AttributeRouteModel.Template);
// Note that this is directly modifying this selector's route template,
// so it will no longer be accessible from the tenant sub folder path.
// Alternatively one could create a new selector based on this
// one, modify the template in the same way, and add it as a new
// selector to the model.Selectors collection.
// Add the constraint which will restrict the page from being
// chosen unless the request's origin matches the tenant
// (ie: folderNum == tenantId).
selector.ActionConstraints.Add(constraint);
}
}
}
...
MyTenantActionConstraint.cs
...
public class MyTenantActionConstraint : IActionConstraint
{
public int Order => 0;
private readonly int _tenantID;
public MyTenantActionConstraint(int tenantID)
{
_tenantID = tenantID;
}
public bool Accept(ActionConstraintContext context)
{
// Get the MyTenant that matches the current requests origin
// using the MyTenant.ImplementationFactoryBasedOnRequestOrigin.
// This is a 'scoped' service so it only needs to do it once per request.
// Alternatively one could just get the MyTenantCollection and find the
// tenant by _tenantID and then check that your tenant.ExpectedOrigin matches
// the current HttpContext.Request.Host, but that would run
// every time MyTenantActionConstraint.Accept is invoked.
var tenant =
context.RouteContext.HttpContext.RequestServices.GetService(
typeof(MyTenant)) as MyTenant;
// Return whether or not this ActionConstraint and more importantly
// the Page/Route this ActionConstraint is attached to
// is within the tenant folder (eg Pages/Tenants/123/About.cshtml)
// which has the same number (eg 123) as the tenant Id that
// corresponds to the tenant that matches the current request's
// origin (ie tenantWithId123.DomainName == currentRequest.Host),
// meaning.. true/false this page-route is for this tenant.
return tenant?.Id == _tenantID;
}
}
...
using Microsoft.AspNetCore.Mvc.RazorPages;
using WebAppRazor.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<TestService>();
// Add services to the container.
builder.Services.AddRazorPages();
ServiceProvider serviceProvider = builder.Services.BuildServiceProvider();
var userRepository = serviceProvider.GetService<TestService>();
var a = userRepository.getString();
I have a test service which will return a string. then this code worked for me, I can call this service by the code above.
I need to create an OData controller with this assign.
http://xxxxxx/odata/mock/Itens(NumContrato='1234',IdItem='10')/Pedidos
ItensController
public class ItensController : ODataController
{
[HttpPost]
[ODataRoute("(NumContrato={NumContrato},IdItem={IdItem})/Pedidos")]
public IQueryable<Pedido> Pedidos([FromODataUri] string NumContrato, [FromODataUri] string IdItem)
{
... do something
}
}
WebApiConfig.cs
...
config.Routes.MapODataServiceRoute(
"ODataRoute",
"odata/mock",
model: GetModel(),
new DefaultODataBatchHandler(GlobalConfiguration.DefaultServer));
...
public static IEdmModel GetModel()
{
ODataModelBuilder builder = new ODataConventionModelBuilder();
...
builder.EntitySet<Dominio.ODataSapFake.Item>("Itens");
builder.EntitySet<Dominio.ODataSapFake.LinhaDeServico>("LinhasDeServicos");
builder.EntitySet<Dominio.ODataSapFake.Pedido>("Pedidos");
var a = builder.Entity<Dominio.ODataSapFake.Item>().Collection.Action("Pedidos");
a.Parameter<string>("NumContrato");
a.Parameter<string>("IdItem");
a.ReturnsCollectionFromEntitySet<Dominio.ODataSapFake.Pedido>("Pedidos");
...
return builder.GetEdmModel();
}
An error occurs when call service.
<m:error xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
<m:code/>
<m:message xml:lang="en-US">
No HTTP resource was found that matches the request URI 'http://localhost:4492/odata/mock/Itens(NumContrato='100',IdItem='00040')/Pedidos'.
</m:message>
<m:innererror>
<m:message>No routing convention was found to select an action for the OData path with template '~/entityset/key/navigation'.
</m:message>
<m:type/>
<m:stacktrace/>
</m:innererror>
</m:error>
Either your URL is incorrect, or your configuration is incorrect, lets explore both possibilities:
Assuming that the config is correct, you have declared Action called Pedidos has 2 parameters, and it is bound to the collection, no to an item... the URL that matches your definition is actually:
~/Itens/Pedidos(NumContrato='1234',IdItem='10')
Assuming that URL is correct, then you need to configure the endpoint as an Item bound Action and we also assume that the Itens record already has the keys NumContrato and IdItem defined:
builder.EntitySet<Dominio.ODataSapFake.LinhaDeServico>("LinhasDeServicos");
builder.EntitySet<Dominio.ODataSapFake.Pedido>("Pedidos");
var itens = builder.EntitySet<Dominio.ODataSapFake.Item>("Itens");
// Key should be defined by attribute notation, but we can redefine it here for clarity
itens.EntityType.HasKey(x => x.NumContrato);
itens.EntityType.HasKey(x => x.IdItem);
// declare the Action bound to an Item
itens.Item.Action("Pedidos")
.ReturnsCollectionFromEntitySet<Dominio.ODataSapFake.Pedido>("Pedidos");
Update 1 - GET support:
Action supports HTTP Post, which OP has decorated the method with, but if you intend to support HTTP GET, then you must configure the endpoint as a Function instead. The syntax is still the same.
don't forget to remove the [HttpPost] attribute, consider replacing it with [EnableQuery] or [HttpGet]
Update 2 - Item Navigation with Composite Key
If the Item data record has a Navigation Property called Pedidos and your intention is to support the standard Item navigation down this property path then you do not need to include this information in the config all.
remove the [HttpPost], this is a GET request.
remove the [ODataRoute] attribute, the default routes should work
Make sure that you use the names of the key properties, using the same casing and order as they are declared with in the configuration.
If you are using auto configuration, then the order will match the Key attribute column specification in your data model.
For OData v3 you may need to follow this advice: Odata v3 Web Api navigation with composite key
The title explains the requirement.
Is it possible to expose multiple endpoints in single project?
Something like:
http://localhost:8000/svc1/$metadata
http://localhost:8000/svc2/$metadata
http://localhost:8000/svc3/$metadata
Because I need to divide functionality into multiple components.
Can anyone help me?
UPDATE
Currently I'm using below code to create and expose Odata service.
public void Configuration(IAppBuilder app)
{
HttpConfiguration config = new HttpConfiguration();
ConfigureRoute(config);
ConfigureGlobalFilters(config);
HttpServer server = new HttpServer();
ODataBatchHandler batchHandler = new DefaultODataBatchHandler(server);
config.MapODataServiceRoute("Odata", "Odata", GenerateEdmModel(), batchHandler);
...
config.EnsureInitialized();
}
private IEdmModel GenerateEdmModel()
{
ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
builder.Namespace = "ServiceA";
builder.ContainerName = "DefaultContainer";
builder.EntitySet<Permission>("ApplicationPermissions");
return builder.GetEdmModel();
}
I would like to expose separate services for each component (under different namespaces?).
The following line should be the one you care about:
config.MapODataServiceRoute("Odata", "Odata", GenerateEdmModel(), batchHandler);
The second string parameter is the routePrefix, which means currently you're probably hitting http://yourhost.com/Odata/$metadata. If you simply create another mapping with a different prefix value (e.g. Odata2) you'd be able to make calls against http://yourhost.com/Odata/$metadata AND http://yourhost.com/Odata2/$metadata. You'll probably want to give them both a unique routeName as well though (the first string parameter), and you'll probably want to provide a different model as well so the two services will actually be different :).
TL;DR
I need a way to programtically choose which RoutePrefix is chosen when generating URLs based on the properties of a user in my MVC app
Not TL;DR
I have an MVC 4 app (with the AttributeRouting NuGet package)
Due to the requirements of the hosting environment I have to have two routes for a lot of my actions so that the hosting environment can have different permissions for access.
I am solving this by decorating my controller with with [RoutePrefix("full")] [RoutePrefix("lite)]. which allows each action method to be accessed via /full/{action} and /lite/{action}.
This works perfectly.
[RoutePrefix("full")]
[RoutePrefix("lite")]
public class ResultsController : BaseController
{
// Can be accessed via /full/results/your-results and /lite/results/your-results and
[Route("results/your-results")]
public async Task<ActionResult> All()
{
}
}
However, each user should only use either full or lite in their urls, which is determined by some properties of that user.
Obviously when I use RedirectToAction() or #Html.ActionLink() it will just choose the first available route and won't keep the "correct" prefix.
I figured I can override the RedirectToAction() method as well as add my own version of #Html.ActionLink() methods.
This will work, but it will involve some nasty code for me to generate the URLs because all I get is a string representing the action and controllers, but not the reflected types. Also there might be route attributes such as in my example, so I am going to have to replicated a lot of MVCs built in code.
Is there a better solution to the problem I am trying to solve?
How about something like:
[RoutePrefix("{version:regex(^full|lite$)}")]
Then, when you create your links:
#Url.RouteUrl("SomeRoute", new { version = "full" })
Or
#Url.RouteUrl("SomeRoute", new { version = "lite" })
You could even do the following to just keep whatever was already set:
#Url.RouteUrl("SomeRoute", new { version = Request["version"] })
I ended up finding a solution to this
I just overrided the default routes to include this. ASP.Net automatically keeps the usertype value and puts it back in when it regenerates the routes
const string userTypeRegex = "^(full|lite)$";
routes.Add("Default", new Route("{usertype}/{controller}/{action}/{id}",
new { controller = "Sessions", action = "Login", id = UrlParameter.Optional }, new { usertype = userTypeRegex }));
I found that this didn't work with the Route or RoutePrefix attributes, and so I had to remove all of them. Forcing me to add specific routes in these cases
routes.Add("Profile-Simple", new Route("{usertype}/profile/simple",
new { controller = "ProfileSimple", action = "Index" }, new { usertype = userTypeRegex }));
I thought that a half-dozen hard coded routes in my RouteConfig file was a better solution than having to manually add values to each place I generated a URL (as in Chris's solution).
I am trying to consume OData from a windows forms. So, what i have done to now is create a new project, i added a web service reference to the OData service and try to consume it.
My code is:
var VistaEntities = new VrExternalEntities("serviceURI");
var query = VistaEntities.VRtblCinemaType
.Where(
x =>
x.VRtblCinema_Operators
.Any
(
z =>
z.VRtblSessions
.Any
(
y =>
y.Session_dtmDate_Time > DateTime.Now
)
)
)
.Select
(
x =>
new
{
x.CinType_strCode,
x.CinType_strDescription
}
);
If i remove the Where clause it works. If i do it says that Any is not supported. I know i have to set MaxProtocolVersion to V3 but i do not know how to do it. I don't have an entity context or anything else. I only have what i have stated above.
Please provide steps on how to accomplish that.
Thanks in advance.
Giannis
You must retrieve the configuration of your DataService and set the MaxProtocolVersion of its behavior to V3.
The best place to do this is certainly in the InitializeService static method you can define in your service class, which will be given the proper configuration object as its config parameter by the environment. It will only be invoked once, typically at the first request.
Note #1: You need WCF Data Services 5.0 or greater. The best way to get it is probably via the Server NuGet package.
Note #2: Oddly enough, the DataServiceProtocolVersion type, although in the Common namespace, is included in the Client assembly (Microsoft.Data.Services.Client, provided by the Client NuGet package). I suggested a better organization here.
public class Vista : DataService<VistaContext>
{
public static void InitializeService(DataServiceConfiguration config)
{
config.SetEntitySetAccessRule(...);
config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V3;
...
}
}
Update:
The client may indeed specify the desired version in the requests by using the DataServiceVersion HTTP header. It's currently recommended that you specify and support a range of versions using the MinDataServiceVersion and MaxDataServiceVersion headers if you can, for obvious reasons. Note however that the MinDataServiceVersion will be removed in OData 4.0 (see appendix E.1 of part 1 and "What's new" documents drafts).
The relevant documentation for the WCF Data Services 5.x implementation is available here. The documentation specific to the client seems pretty scarce, but looking at the reference you can see that you must use this constructor for the DataServiceContext to specify the maximum protocol version, and it looks like you cannot change it at any one point for subsequent requests without rebuilding a new context. You may attempt to fiddle with the headers directly, but I wouldn't expect it to work reliably (or at all).
So, to answer your question, you really need control over how you create the context for the client.