Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

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.

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:

Program.cs
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:

Program.cs
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:

Program.cs
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:

Program.cs
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 RequireRateLimiting would 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’s Location header carries it, as it carries a path base set with UsePathBase.

RoutePrefix is also the configuration key Alvo:Api:RoutePrefix; an empty string mounts the API at the root.

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:

Program.cs
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:

ExceptionMeansThe generated API answers
AlvoAuthorizationExceptionNo rule allows the operation, or the row a write would store fails the rule or a before-hook.403 forbidden
AlvoRecordNotFoundExceptionThe row of an update or delete does not exist, or the rule hides it. GetAsync returns null instead.404 not-found
ArgumentException, except ArgumentNullExceptionThe query or the values are malformed.422 validation or malformed-query
AlvoPreconditionFailedExceptionThe precondition names a version the row no longer has.412 precondition-failed
AlvoIdempotencyConflictExceptionThe idempotency key was used for a different request.409 idempotency-conflict
AlvoConstraintViolationExceptionA unique value or a restrict reference is in the way.409 conflict
InvalidOperationException, ArgumentNullExceptionAn invariant inside Alvo, or your call, is broken.500 internal, with AddAlvoProblemDetails()
ExceptionA 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.

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.

The generated routes under your prefix answer with Alvo’s problem types; your own endpoints answer with whatever you render.

StatusProblem typeWhenFixReturned by
403forbiddenAlvoAuthorizationException: no rule allows it, or the row fails the rule or a hook.Check the rule against the roles in your AlvoContext.every host
404not-foundAlvoRecordNotFoundException: the row is absent or hidden by the rule.Check the id and the rule.every host
422validationArgumentException: 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
500internalInvalidOperationException 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().

Custom CEL functions: give your descriptor’s hooks a function written in C#.