Call Alvo from your endpoints
Read and write Alvo's data from your own minimal-API endpoints, in process and under the descriptor's rules, and mount the generated API beside them under your own prefix.
Before you start
Section titled “Before you start”- An app that embeds Alvo as in Embed Alvo in ASP.NET Core. This page reads one
Program.csthat adds a cookie sign-in and two endpoints of its own. - A way to build an
AlvoContextfor your signed-in user: Use your own authentication shows the lines that build one. - To try the endpoints, the commands in Use your own authentication, step 4, or a descriptor of your own from Run your own descriptor.
1. Call IAlvoData from an endpoint
Section titled “1. Call IAlvoData from an endpoint”IAlvoData is the one port every Alvo read and write goes through, the same one the generated API calls. Resolve it
in a minimal-API delegate like any service, and pass the caller you built. A read:
app.MapGet("/app/vehicles", async (HttpContext http, IAlvoData data, IRoleCatalogProvider roles, CancellationToken ct) =>{ if (roles.DeclaredRoles is not { } catalog) { return Results.Problem(detail: "Alvo has not applied a descriptor yet.", statusCode: StatusCodes.Status503ServiceUnavailable); }
if (!Guid.TryParse(http.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) { return Results.Unauthorized(); }
var caller = new AlvoContext { User = new UserId(userId), Roles = catalog.Resolve(["authenticated", .. http.User.FindAll(ClaimTypes.Role).Select(c => c.Value).Where(r => catalog.TryGet(r, out _))]), };
try { var page = await data.QueryAsync(new AlvoQuery { Entity = "vehicles", Limit = 50 }, caller, ct); return Results.Ok(page.Items.Select(row => row.Values)); } catch (AlvoAuthorizationException exception) { return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status403Forbidden); }}).RequireAuthorization();QueryAsync takes an AlvoQuery: Entity, and optionally Filter, Sort, Select, Limit, After (a keyset
cursor) or Offset, and IncludeTotalCount. It returns an AlvoPage with Items, NextCursor and TotalCount;
its Items are records whose Values hold each record’s fields. The rows are the ones the
list rule admits for this caller; the endpoint filters nothing itself. In process, Limit may be left null to read
the whole visible set, which the HTTP API never allows.
A write takes your own request type and maps it to the field dictionary Alvo expects:
app.MapPatch("/app/vehicles/{id:guid}", async (Guid id, RepaintRequest request, HttpContext http, IAlvoData data, IRoleCatalogProvider roles, CancellationToken ct) =>{ if (roles.DeclaredRoles is not { } catalog) { return Results.Problem(detail: "Alvo has not applied a descriptor yet.", statusCode: StatusCodes.Status503ServiceUnavailable); }
if (!Guid.TryParse(http.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) { return Results.Unauthorized(); }
var caller = new AlvoContext { User = new UserId(userId), Roles = catalog.Resolve(["authenticated", .. http.User.FindAll(ClaimTypes.Role).Select(c => c.Value).Where(r => catalog.TryGet(r, out _))]), };
try { var record = await data.UpdateAsync("vehicles", id, new Dictionary<string, object?> { ["color"] = request.Color }, caller, cancellationToken: ct); return Results.Ok(record.Values); } catch (AlvoAuthorizationException exception) { return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status403Forbidden); } catch (AlvoRecordNotFoundException) { return Results.NotFound(); } catch (ArgumentException exception) { return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity); }}).RequireAuthorization();Map your DTO to a Dictionary<string, object?> yourself rather than binding one straight from JSON: a dictionary bound
from JSON carries JsonElement values, which IAlvoData has no field type for and refuses. UpdateAsync also takes
an optional precondition (the If-Match of the HTTP API) and an idempotency token. CreateAsync, GetAsync,
ReplaceAsync, DeleteAsync and the three batch methods follow the same shape; each is in the
C# API reference.
The before-hooks, the rules and the after-hook events all run inside these calls, exactly as for an HTTP request.
2. Mount the generated API under your prefix
Section titled “2. Mount the generated API under your prefix”The generated Data API defaults to /api. An embedded host usually moves it out of its own way with
AlvoApiOptions.RoutePrefix, set where Alvo is registered:
builder.Services.AddAlvo(alvo => alvo .UseSqlite("Data Source=alvo.db") .FromDescriptor("vehicles.alvo.json") .AddDataApi(api => api.RoutePrefix = "/api/alvo"));Then map it. MapAlvoDataApi() returns a convention builder over Alvo’s generated routes and nothing else, so a
convention you attach reaches those routes only. Here they are grouped under one tag in the OpenAPI document:
app.MapAlvoHealth();app.MapAlvoDataApi().WithTags("alvo-data-api");- Attach conventions before the first request. The route table is built once, at the first request; a convention
attached later throws, rather than being silently ignored like a late
RequireRateLimitingwould be. - Health maps first and takes no conventions.
MapAlvoHealth()is not chainable, so an authorization policy can never reach/health/live, which a container probe calls without a credential. - A route group works too.
app.MapGroup("/backend").MapAlvoDataApi()mounts the API under the group’s prefix, and a created row’sLocationheader carries it, as it carries a path base set withUsePathBase.
RoutePrefix is also the configuration key Alvo:Api:RoutePrefix; an empty string mounts the API at the root.
3. Render what IAlvoData refuses
Section titled “3. Render what IAlvoData refuses”IAlvoData refuses by throwing, one exception type per kind of refusal. The generated API turns them into problem
documents; your endpoint decides what to answer. The write above renders three kinds as its own responses:
var record = await data.UpdateAsync("vehicles", id, new Dictionary<string, object?> { ["color"] = request.Color }, caller, cancellationToken: ct); return Results.Ok(record.Values);}catch (AlvoAuthorizationException exception){ return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status403Forbidden);}catch (AlvoRecordNotFoundException){ return Results.NotFound();}catch (ArgumentException exception){ return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity);The read catches AlvoAuthorizationException too: a denied list throws exactly as a denied update does, so an
endpoint that catches only around its writes ships a 500 for the first descriptor with a narrower read rule. The
complete list, with what the generated API answers for each:
| Exception | Means | The generated API answers |
|---|---|---|
AlvoAuthorizationException | No rule allows the operation, or the row a write would store fails the rule or a before-hook. | 403 forbidden |
AlvoRecordNotFoundException | The row of an update or delete does not exist, or the rule hides it. GetAsync returns null instead. | 404 not-found |
ArgumentException, except ArgumentNullException | The query or the values are malformed. | 422 validation or malformed-query |
AlvoPreconditionFailedException | The precondition names a version the row no longer has. | 412 precondition-failed |
AlvoIdempotencyConflictException | The idempotency key was used for a different request. | 409 idempotency-conflict |
AlvoConstraintViolationException | A unique value or a restrict reference is in the way. | 409 conflict |
InvalidOperationException, ArgumentNullException | An invariant inside Alvo, or your call, is broken. | 500 internal, with AddAlvoProblemDetails() |
Exception | A CEL function failed: a built-in refused its input, or a custom function threw. | 500 function-failed, with AddAlvoProblemDetails() |
Catch the ones your endpoint can cause. The write sends no precondition, no idempotency key and no unique field, so
it catches three. Let InvalidOperationException and ArgumentNullException propagate: they mean a bug, and your
logging needs the stack trace. A CEL function that fails during your write (a built-in refusing its input, or a
custom function that throws) reaches you as a plain Exception, outside
these families, and nothing is written.
The messages of AlvoAuthorizationException and AlvoRecordNotFoundException are safe to pass on: they never name
the entity, the row or whether it exists.
How it works
Section titled “How it works”The generated API’s endpoints are thin: each resolves its caller from the API key, calls IAlvoData, and maps the
exception families to problem documents. Your endpoint does the same with a caller of its own, which is why both
surfaces enforce identical rules. Because IAlvoData takes the caller as a parameter instead of reading it from the
request, the same call works from a background job or a message handler, where no HTTP request exists.
Architecture shows where the port sits.
What can go wrong
Section titled “What can go wrong”The generated routes under your prefix answer with Alvo’s problem types; your own endpoints answer with whatever you render.
| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 403 | forbidden | AlvoAuthorizationException: no rule allows it, or the row fails the rule or a hook. | Check the rule against the roles in your AlvoContext. | every host |
| 404 | not-found | AlvoRecordNotFoundException: the row is absent or hidden by the rule. | Check the id and the rule. | every host |
| 422 | validation | ArgumentException: a value does not fit its field, or a field is unknown. | Map your DTO to the entity’s declared fields and types. | every host |
| 500 | internal | InvalidOperationException or ArgumentNullException: a broken invariant. | Read the stack trace in your log. | standalone; embedded only with AddAlvoProblemDetails() |
Two failures happen outside a request. MapAlvoDataApi() on a host whose Data API services are missing stops the app
at startup; call AddAlvo first. A convention that throws while the routes are built does not stop the host: the
route table stays empty, /health/ready reports the failure, and the log entry names MapAlvoDataApi().
Reference
Section titled “Reference”- C# API:
IAlvoData,MapAlvoDataApi,AlvoApiOptions,AddAlvoProblemDetails. - Configuration:
Alvo:Api. - Design notes: what a host may attach to the generated routes and endpoints as a separate seam.
Custom CEL functions: give your descriptor’s hooks a function written in C#.