Bound Functions and Actions
OData distinguishes between functions (side-effect-free, HTTP GET) and actions (may have side effects, HTTP POST). OhData supports both at the collection level and the entity level.
Collection-bound operations
Bound to the entity set, not to a specific entity instance.
| Kind | Route | HTTP |
|---|---|---|
| Function | GET /{EntitySet}/{FunctionName}?param=value |
GET |
| Action | POST /{EntitySet}/{ActionName} |
POST |
Register with BindFunction / BindAction inside the profile constructor. The method name becomes the operation name - the handler must be a named method, not a lambda. Passing a lambda (whose compiler-generated method name isn't a valid OData identifier) throws InvalidOperationException at startup:
public class ProductProfile : EntitySetProfile<int, Product>
{
private readonly AppDbContext _db;
public ProductProfile(AppDbContext db) : base(x => x.Id)
{
_db = db; // named-method handlers below capture it via the field
BindFunction(GetCheapest); // GET /Products/GetCheapest?maxPrice=10.00
BindAction(ApplyDiscount); // POST /Products/ApplyDiscount { "percent": 10 }
GetAll = async (ct) => await _db.Products.ToListAsync(ct);
}
private async Task<IEnumerable<Product>> GetCheapest(decimal maxPrice, CancellationToken ct) =>
await _db.Products.Where(p => p.Price <= maxPrice).ToListAsync(ct);
private async Task ApplyDiscount(decimal percent, CancellationToken ct)
{
var products = await _db.Products.ToListAsync(ct);
foreach (var p in products) p.Price *= (1 - percent / 100);
await _db.SaveChangesAsync(ct);
}
}
Entity-bound operations
Bound to a specific entity instance identified by key.
| Kind | Route | HTTP |
|---|---|---|
| Function | GET /{EntitySet}({key})/{FunctionName}?param=value |
GET |
| Action | POST /{EntitySet}({key})/{ActionName} |
POST |
Register with BindEntityFunction / BindEntityAction. The handler's first parameter (after excluding a trailing CancellationToken) must be the entity key (TKey) — this is validated at bind time: a handler with no parameters, or whose first parameter isn't TKey, throws InvalidOperationException naming the operation, its entity set, and the expected signature. (Before this validation existed, both cases registered without error and only failed at request time — a zero-parameter handler with an uncaught IndexOutOfRangeException, a wrong-first-parameter-type handler with a DynamicInvoke failure.)
public class OrderProfile : EntitySetProfile<Guid, Order>
{
private readonly AppDbContext _db;
public OrderProfile(AppDbContext db) : base(x => x.Id)
{
_db = db; // named-method handlers below capture it via the field
BindEntityFunction(GetLineCount); // GET /Orders(id)/GetLineCount
BindEntityAction(Cancel); // POST /Orders(id)/Cancel
GetById = (id, ct) => _db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
}
// First param is the key - the framework extracts it from the URL
private async Task<int> GetLineCount(Guid orderId, CancellationToken ct) =>
await _db.Orders.Where(o => o.Id == orderId).Select(o => o.Lines.Count).FirstOrDefaultAsync(ct);
private async Task Cancel(Guid orderId, CancellationToken ct)
{
var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
if (order is not null)
{
order.Status = "Cancelled";
await _db.SaveChangesAsync(ct);
}
}
}
Parameters
Functions - query string
Function parameters are read from the query string. Any CLR type that can be parsed from a string (including primitives, Guid, DateTimeOffset, enums) is supported:
GET /Products/GetCheapest?maxPrice=10.00
GET /Orders/CreatedBetween?from=2024-01-01&to=2024-03-31
Actions - JSON body
Action parameters are read from a JSON request body as named properties:
POST /Products/ApplyDiscount
Content-Type: application/json
{ "percent": 10.0 }
CancellationToken
If the handler method includes a CancellationToken as its last parameter, the framework detects it and passes the request's CancellationToken automatically. It does not appear as an OData parameter.
Optional parameters
Mark a parameter as optional with a default value:
private async Task<IEnumerable<Product>> GetCheapest(decimal maxPrice = 100m, CancellationToken ct = default) =>
await _db.Products.Where(p => p.Price <= maxPrice).ToListAsync(ct);
Optional parameters and their defaults are reflected in $metadata.
Return types
Any return type is supported - the result is serialized as JSON. Wrap in Task<T> for async operations, or return void/Task for no-content responses. ValueTask and ValueTask<T> are also supported alongside Task/Task<T> - the framework detects the return type via reflection at startup and dispatches accordingly:
// Returns a single entity
private Task<Product?> GetCheapest(CancellationToken ct) => ...;
// Returns a collection
private Task<IEnumerable<Product>> GetAllOnSale(CancellationToken ct) => ...;
// No return value (action with side effect only)
private Task Archive(Guid orderId, CancellationToken ct) => ...;
EDM and $metadata
Bound operations are registered in the EDM model and appear in GET /$metadata. Functions are registered on the entity set (or entity type for entity-bound), making them discoverable by OData-aware clients.
Error handling
An exception thrown from the handler propagates up to the group-level exception filter and comes
back as a 500 Internal Server Error with the standard OData error envelope
(code: "InternalServerError", a generic message - the exception's own message/stack trace is
never echoed to the client, only logged). See
Error responses ("Unhandled handler exceptions" row) for the
full behavior. To return a more specific OData error from within a handler, catch the failure yourself
and return ODataError-shaped Results.Json(...)/Results.BadRequest(...) (matching the
{"error":{"code":...,"message":...}} shape), rather than relying on the generic 500 fallback.
Unbound functions and actions
BindFunction/BindEntityFunction and BindAction/BindEntityAction (above) are always attached to an entity set. OData also allows unbound functions and actions that live at the service root, with no entity set in the route at all. Register these on OhDataBuilder - inside the AddOhData(...) callback, not inside a profile:
| Kind | Route | HTTP |
|---|---|---|
| Unbound function | GET /{prefix}/{Name}?param=value |
GET |
| Unbound action | POST /{prefix}/{Name} |
POST |
builder.Services.AddOhData(o => o
.AddEntitySetProfile<ProductProfile>()
.AddFunction((Func<string, Task<string>>)(name => Task.FromResult($"Hello, {name}!")), "Greet")
.AddAction((Func<int, int, Task<int>>)((a, b) => Task.FromResult(a + b)), "AddNumbers"));
GET /odata/Greet?name=World → "Hello, World!"
POST /odata/AddNumbers { "a": 3, "b": 4 } → 7
AddFunction(Delegate handler, string? name = null) and AddAction(Delegate handler, string? name = null) take any delegate - unlike BindFunction/BindAction, a lambda is fine, since the route name is either taken from the delegate's method name or supplied explicitly via name. Pass name whenever the handler is a lambda (its compiler-generated method name isn't a usable route segment). Parameters, CancellationToken detection, optional-parameter defaults, and return-type dispatch (Task/Task<T>/ValueTask/ValueTask<T>/void) all follow the same rules as bound functions/actions described above.
Response shape is not the same, though. Bound functions/actions (both collection- and
entity-level) wrap their result per JSON §11: a TModel result gets the entity/collection
@odata.context treatment described above, and a recognized Edm-primitive result (string, numeric
types, bool, Guid, date/time types, byte[]) gets the individual-value envelope
({"@odata.context":".../$metadata#Edm.<Type>","value":<primitive>}). Unbound functions/actions
do not get any of this: the handler's result is returned as a bare JSON body with no
@odata.context and no value envelope, even for a TModel or primitive result (result is not null ? Results.Ok(result) : Results.NoContent()). This asymmetry is a known post-1.0 cleanup
candidate, not a bug fix planned for this release — treat unbound-operation responses as
unenveloped JSON when writing a client against them. Unbound operations are registered in the EDM
as FunctionImport/ActionImport and appear in GET /$metadata and the service document.
Assembly-scanning registration (AddProfilesFrom/AddProfilesFromAssemblyOf/AddProfilesFromAssembly) is documented in docs/architecture.md.
Route collisions
Several distinct constructs can end up claiming the same (route template, HTTP method) pair. Since two endpoints can't otherwise register the same pair, every case below is caught by a startup validation pass — resolving the OhDataRegistration (which happens the first time MapOhData() runs) throws InvalidOperationException naming the conflicting pair, rather than deferring to an AmbiguousMatchException the first time a client hits the route:
| Collision | Route shape | Guard |
|---|---|---|
| Unbound function vs. another unbound function/action of the same kind | GET/POST /{prefix}/{Name} |
Duplicate unbound operation name (case-insensitive) within a registration. |
| Unbound function/action vs. an entity set | GET/POST /{prefix}/{Name} vs. GET/POST /{prefix}/{EntitySet} |
An unbound function's name matches an entity set with a registered collection GET (GetAll/GetQueryable); an unbound action's name matches an entity set with a registered Post (case-insensitive). |
| Entity-level bound function vs. a structural property | GET /{EntitySet}({key})/{Name} |
A bound function's name matches a structural (non-navigation) property name. |
Navigation property post handler vs. an entity-level bound action |
POST /{EntitySet}({key})/{Name} |
A navigation property configured with a post handler shares a name with an entity-level bound action. |
Navigation vs. structural-property routes never collide by construction (structural properties are computed as "every public readable CLR property minus every declared navigation property name"), so there is no guard for that pairing.