Custom CEL functions
Register a function written in C# in your embedded host, and call it from your descriptor's hook conditions and mutate values.
Before you start
Section titled “Before you start”- The repository’s embedded sample, which registers this same function, from Embed Alvo in ASP.NET Core, run from a clone with the
.NET 10 SDK, plus
curlandjq. A custom function exists only in a host that registers it: the Docker image of Run your own descriptor knows the built-in functions only. - Before-hooks: a function is called from a hook’s
conditionormutate.
1. Register the function
Section titled “1. Register the function”Add it at the end of the AddAlvo chain with AddCelFunction(name, function, summary). The function can be a lambda,
inline in Program.cs, and the summary is one sentence about it:
using MMLib.Alvo.Auth;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<AlvoAuthOptions>(builder.Configuration.GetSection("Alvo:Auth"));
builder.Services.AddAlvo(alvo => alvo .UseSqlite("Data Source=alvo.db") .FromDescriptor("vehicles.alvo.json") .AddDataApi(api => api.RoutePrefix = "/api/alvo") .AddCelFunction( "normalizeVin", (string vin) => new string([.. vin.Where(char.IsAsciiLetterOrDigit).Select(char.ToUpperInvariant)]), "Upper-cases a vehicle identification number and drops every character that is not a letter or a digit."));
var app = builder.Build();
app.MapAlvoHealth();app.MapAlvoDataApi();
app.Run();Alvo reads the signature off the delegate, lambda or method alike: each parameter’s name (vin) and type, and whether a parameter or the
result may be null, from the nullable annotations. Everything is checked at the call, so a mistake is an
ArgumentException while the host starts, never a surprise inside a write:
| What | Allowed |
|---|---|
| Name | A lower-case ASCII letter, then letters, digits or _; at most 64 characters; not a built-in function (such as now), a CEL keyword or macro, or a reserved name such as has, changed, old, new or math. |
| Parameters | At most four, each string, long, int, decimal, bool, DateTimeOffset or Guid, or a nullable one of those. |
| Result | One of the same types. |
| Shape | A synchronous delegate: no Task, ref, out or params, and not a multicast delegate. |
The name and the summary are visible to everyone with Viewer access to the Management API, so put no secret or internal-only wording in them.
2. Call it from a hook
Section titled “2. Call it from a hook”A hook’s condition and a before-hook’s mutate value call a custom function exactly like a built-in one. This copy of
the vehicle-registry descriptor rewrites vin on every create:
"hooks": { "beforeCreate": [ { "action": { "mutate": { "vin": { "$cel": "normalizeVin(new.vin)" } } } } ]}It works in a condition too, such as "condition": "normalizeVin(new.vin) != new.vin". A rule, a computed field and
the access block refuse it when the descriptor is applied, with the recipe in the refusal: store the value in a field
with a mutate, then compare that field.
The shared examples/vehicle-registry/vehicles.alvo.json deliberately has no such hook. The Docker image serves the
same file, and a hook calling normalizeVin would make the image refuse it as calling an unknown function. A
descriptor that calls a custom function only runs in hosts that register it.
3. Try it
Section titled “3. Try it”Start the sample over the descriptor with the hook, in the background and over a fresh database:
export FLEET_DESK_KEY_SECRET="$(openssl rand -hex 16)"Alvo__Auth__DevKeys__0__Secret="$FLEET_DESK_KEY_SECRET" \ dotnet run --project samples/MMLib.Alvo.Samples.EmbeddedHost -- \ --FleetDesk:DescriptorPath "$PWD/website/src/snippets/custom-cel-functions/host-only/vehicles.alvo.json" \ --FleetDesk:DatabasePath "$(mktemp -d)/fleet-desk.db" &until curl -sf localhost:5199/health/ready > /dev/null; do sleep 1; doneCreate an owner, then two vehicles: one with a lower-case VIN, one with dashes in it:
KEY="agent.$FLEET_DESK_KEY_SECRET"OWNER=$(curl -s -X POST localhost:5199/api/alvo/owners -H "X-Alvo-Api-Key: $KEY" \ -H "Content-Type: application/json" -d '{"name":"Fleet Desk Ltd"}' | jq -r .id)curl -s -X POST localhost:5199/api/alvo/vehicles -H "X-Alvo-Api-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"vin":"1hgcm82633a004352","plate":"BA-777AB","make":"Skoda","model":"Fabia","year":2020,"owner_id":"'"$OWNER"'"}' \ | jq '{vin, plate}'curl -s -X POST localhost:5199/api/alvo/vehicles -H "X-Alvo-Api-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"vin":"1hg-cm826-33a-004352","plate":"BA-778AB","make":"Skoda","model":"Fabia","year":2020,"owner_id":"'"$OWNER"'"}' \ | jq '{status, violations}'kill %1The first vehicle is stored with vin 1HGCM82633A004352: the row holds what the hook computed, not what the caller
sent. The second is refused with a 422 validation whose violation is max-length on /vin. The caller’s body is
checked against the field before any hook runs, so a function cannot rescue a value that is too long for its field.
Whatever a hook writes is then checked against the same field, and a value that does not fit refuses the write with a
403 forbidden naming the hook and the field, never the value.
4. Find it in the catalog
Section titled “4. Find it in the catalog”Every function a descriptor may call in a host is listed in that host’s function catalog, built-ins and yours alike:
GET /management/projects/<project>/cel/functions over HTTP, or IAlvoManagement.GetCelFunctionsAsync in process.
A custom function’s entry carries its signature, its summary, the provenance Host and the profiles Condition and
Mutate. The sample maps no Management API, so it does not answer that route.
A host that mounts the admin dashboard offers the function in the hook editor,
under Functions you can call here, with a this host badge where a built-in has built-in. The
schema assistant learns the same list through its get_cel_functions tool. The
built-ins are in the CEL function catalog.
5. Keep the function’s promises
Section titled “5. Keep the function’s promises”Alvo cannot check these, and breaking one breaks writes, not only your function.
- Pure. The same arguments give the same result, with no side effects. It runs inside the write’s transaction, and a write that rolls back must leave nothing behind. Sending mail or calling a service belongs in an after-hook.
- Fast. It runs while the row’s locks are held, with no time budget and no
CancellationToken. No network calls, no unbounded loops. - Thread-safe. One instance serves every request at once, and it cannot use a scoped service: it is a singleton closure.
- Tenant-aware. Alvo’s tenant filter does not reach inside your code. A function that reads stored data must take
the tenant as a parameter and filter by it: on a tenant-scoped entity, pass
new.tenant_id, which a condition and amutatecan both read.@tenant.idworks in a condition only. - Throw when you cannot answer; return
nullonly when there is no value. A throw refuses the write and rolls it back. Anullmakes the callnull, a condition overnulldoes not fire, and arejectguarded by your function would let the bad input through. - No caller data in exceptions. What a function throws, message and stack trace, is logged at Error and never shown to the caller, so a value in the message ends up in every log sink you ship to.
- A new meaning gets a new name. A descriptor stores names, not versions: register
vatRate2besidevatRate. Removing a function a stored descriptor still calls makes the next start refuse that descriptor.
How it works
Section titled “How it works”The registration adds the function to the catalog the descriptor is compiled against, so an unknown name, a wrong
number of arguments or a type mismatch is refused when the descriptor is applied, not when a write runs. Hook
conditions and mutate values are evaluated in memory inside the write’s transaction, which is why they can call C#;
rules and computed fields are compiled to SQL, which is why they cannot.
CEL in Alvo explains the profiles.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 500 | function-failed | The function threw, or a present argument does not fit its parameter (a fraction for an int, a text that is no Guid). Nothing was written; the detail names the function and never your exception’s text. | Fix the input the function reads, or guard the call with a condition; the exception is in the host’s log. | standalone; embedded only with AddAlvoProblemDetails() |
| 403 | forbidden | The value a mutate computed breaks a facet of its field, such as maxLength. | Make the function’s result fit the field. | every host |
| 422 | validation | The caller’s own value breaks the field before the hook runs. | Send a value that fits the field. | every host |
Without AddAlvoProblemDetails(), a function failure on a generated route reaches your app’s own error handling as an
exception, and an in-process IAlvoData caller receives it as an Exception
(Call Alvo from your endpoints).
Two failures stop the host instead. A registration Alvo cannot accept throws ArgumentException at the
AddCelFunction call. A descriptor that calls a function the host does not register, with the wrong number of
arguments, or from a rule or a computed field, is refused when it is applied: at start, the host does not start; through
the Management API, the apply answers 422 validation.
Reference
Section titled “Reference”- C# API:
AddCelFunction. - Functions: the CEL function catalog.
- Problem types:
function-failed,forbidden,validation. - Design notes: host functions in
cel.mdand registering a CEL function inextensibility.md.
Running in production: take a backend from a laptop to a server.