Summary#
app.Apis publishes your application over HTTP for other systems to call. The guiding idea is that you expose what
you already have rather than write a web layer: you do not author controllers, routes, DTOs or handlers. Two things
can be published — an entity, as a set of CRUD routes, and a function, mapped to a single route. Each API has a
Route prefix and an optional Auth gate.
app.Apis = [
new RestApi("Orders") {
Route = "orders",
Auth = new ApiAuth { ApiKey = true },
Expose = [ new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create] } ],
Endpoints = [ new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit" } ],
},
];Signature#
app.Apis = [
new RestApi("Name") { // one entry per published API
Route = "orders", // the URL prefix for every route below
Version = "1.0", // optional — omit and it defaults to v1
Auth = new ApiAuth { ApiKey = true, Bearer = true }, // optional gate
Expose = [ // entities → CRUD routes
new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create, CrudOp.Update, CrudOp.Delete] },
],
Endpoints = [ // functions → one route each
new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit", SuccessStatus = 201 },
],
},
];app.Apis is a list — an app may publish several independent APIs, each with its own Route and Auth.
Description#
A RestApi has:
Route— the URL prefix under which its routes live.Version,Deprecated,Sunset(optional) — API lifecycle metadata. Every route lives under a/v{major}/prefix; an omittedVersiondefaults to v1, so the simple case needs no version at all. A multi-major API gives eachRestApiits ownVersion(e.g."1.0","2.0").Auth(optional) — anApiAuth { ApiKey = true, Bearer = true, OAuth = true }. Those three flags are its WHOLE vocabulary; it names no secret and takes no other member. Absent, the API is unauthenticated. Who each of them makes the caller is the next section — read it before you write asecurity {}rule for an API.Expose— a list ofnew Crud<Entity>() { Operations = [CrudOp.…] }. Each entry turns one entity into REST routes;Operationschooses which ofRead/Create/Update/Deleteexist. The entity must be defined in your app — aCrud<Unknown>is a compile error that lists the entities you do define.Endpoints— a list ofnew Endpoint(Function) { Method = HttpMethod.…, Path = "…" }. Each maps one of your functions to a route. The function must be declared — anEndpoint(NoSuchFn)is a compile error that lists your functions.
You write no request parsing, no serialization, and no routing table: the shape of the entity and the signature of the function are the contract.
Refusals map to the right status. When an endpoint's function refuses — it throws a typed exception, or a
declared security policy denies the caller — the response carries the matching 4xx, not a blanket 500, and the client
sees the refusal's own message:
| The function… | Response |
|---|---|
throw new ValidationException("…") | 400 — the request was invalid |
throw new NotFoundException("…") | 404 — the target does not exist / is not visible |
throw new ConflictException("…") | 409 — conflicts with current state |
| is denied by a security / authorization policy | 403 — the caller may not do this |
| hits a genuine, unexpected error | 500 — a generic message; the details stay in the server log, never the response |
So a business rule like "a backup can only be downloaded once it is ready" is a throw new ValidationException(…)
that reaches the caller as a 400 with your wording — not a server crash.
What a value looks like on the wire#
JSON has no literal for most of what your entities hold, so each type has one agreed spelling — the same one going out as coming in, which is what makes a response postable straight back:
| Declared as | On the wire |
|---|---|
enum | its member name — "machine": "Washer", never the ordinal 0 |
enum with [Value("…")] | that value, because you chose it — "risk": "risk-high" |
Guid, DateTime, DateOnly, TimeOnly, TimeSpan | a string in the canonical form — "2026-09-04", "01:30:00" |
int, decimal, bool | the JSON number or boolean, unquoted |
An enum also accepts its stored key inbound — the ordinal, or the [Value] of a string-backed enum — so a client
written against an older payload keeps working. The name is what a read ANSWERS and what the published OpenAPI
advertises; the key is a compatibility affordance.
Anything else is a 400 naming the field and what it accepts, with an enum's whole closed set spelled out:
{ "error": { "code": "VALIDATION_FAILED", "details": [
{ "field": "machine", "code": "INVALID_ENUM_VALUE",
"message": "'machine' expects one of Machine: Washer, Dryer — received a string ('Toaster')." } ] } }…and what the response is WRAPPED in#
The table above is about one VALUE. This is about the object it arrives inside, which is not always the value itself — and every key in it is camelCase, whatever the member is called in your source.
| The route | What comes back |
|---|---|
an Endpoint whose function returns an entity | that row, flat — {"id": "…", "accession": "ACC-1"} |
an Endpoint whose function returns a class | its fields, flat — {"accession": "ACC-1", "species": "Rana"} |
an Endpoint whose function returns void | {"status": "ok"} |
an Endpoint whose function returns anything else — a List<T>, a Dictionary<K,V>, an int, a string | wrapped: {"result": …} |
Crud<T> read-many (GET /entities/T) | {"items": [ … ], "total": 12, "top": 50, "skip": 0, "hasMore": false} — plus "included" when you asked to expand |
Crud<T> read-one / create / update | that row, flat |
Crud<T> delete | 204, no body |
⚠ The result wrapper is the one that catches people, because the obvious DTO is the one that does not work. A
route whose function returns List<Receipt> does not answer a bare JSON array, so
JsonSerializer.Deserialize<List<Receipt>>(body) fails — the body is an object. Declare the wrapper instead, and
read through it:
class ReceiptPage { public List<Receipt> Result; } // `Result` binds the body's `result`
var page = JsonSerializer.Deserialize<ReceiptPage>(r.Body);
Assert.Equal(2, page.Result.Count);Calling your own REST API from a test has this as a compiled example, and JsonSerializer is the verb that reads it.
Who is user?#
Every rule you already wrote applies to an API call unchanged, because an authenticated API caller is a user of your
app — not a special anonymous "machine" identity. This is the one thing about app.Apis worth reading before you
design anything: guessing it the other way leads you to open your entities to anonymous callers so the API "works",
which opens them to the whole world on every other surface too.
An API key is a PER-USER credential. There is no app-wide API key. A key is minted against exactly one
[Principal] row, and a request carrying it runs as that user:
Auth | the request carries | user | IsAuthenticated | IsAnonymous |
|---|---|---|---|---|
new ApiAuth { ApiKey = true } | X-API-Key: pk_… | the key OWNER's principal row | true | false |
new ApiAuth { Bearer = true } | Authorization: Bearer … | that token's principal row | true | false |
new ApiAuth { OAuth = true } | an OAuth-issued bearer token | that token's principal row | true | false |
any Auth at all | nothing | — | — | — → the request is 401 and never reaches your data |
no Auth | anything | null | false | true |
So an API key and a bearer token for the same person produce the same context: the same user, the same role
grants, the same row filters. A rule like allow read where OwnerEmail == user.Email returns that person's rows over
the API exactly as it does in the app's own UI, and allow read, create when IsAuthenticated admits them.
Three consequences, each of which is a mistake if you assume the opposite:
- You do not need
IsAnonymousto make an entity reachable over an authenticated API. The caller is somebody. Anallow … when IsAnonymousrule is in fact the one thing an API-key caller can never satisfy. - A missing credential is a
401, not an anonymous request.ApiAuth { ApiKey = true }never falls through to anonymous access; the request stops at the door. - Only an API with no
Authruns anonymously, and thereuseris null — so a row filter correlating tousermatches nothing and denies, which is the correct answer for a caller who is nobody.
⚑ You can check every row of that table against your own app rather than take it from here. Calling your own REST API from a test
sends a real request to your published route from inside a [Test], with or without a credential, and hands you the
status back — so "an API key caller is authenticated" is a claim you can make the app answer:
Assert.Equal(401, Api.Get("/api/rest/v1/catalog/entities/Product").StatusCode); // nobody
Assert.Equal(200, Api.Get("/api/rest/v1/catalog/entities/Product", apiKey: key).StatusCode); // somebodyWhere the key lives#
The platform stores each user's key as a hash on that user's own principal row, in two columns your [Principal]
entity must declare by these exact names:
[Principal] entity User {
[Unique, MaxLength(200)] string Email;
[MaxLength(255)] string? ApiKeyHash; // the key in force
[MaxLength(255)] string? ApiKeyHash2; // a second, so a key can be rotated without a gap
security {
allow read when IsAuthenticated;
// ⛔ BOTH SLOTS. A stored key hash with no field-level `deny read` rides `Session.CurrentUser` to the
// browser — the platform ships the whole [Principal] minus its MASKED properties.
deny read ApiKeyHash when !IsAuthenticator;
deny read ApiKeyHash2 when !IsAuthenticator;
}
}IsAuthenticator is a policy you declare over your own grant table — it is not built in. See
auth bootstrap (login, before anyone is signed in) for the three lines that declare it, and acting for another principal for
the whole shape worked through on an app whose API caller is a machine.
Declaring Auth = new ApiAuth { ApiKey = true } on an app whose principal lacks them is a compile error naming
both columns — without them no key can be minted and none can be verified, so the published route would be a 401
forever.
You never write a key into source. The platform mints one for a named user and prints the plaintext pk_… once;
only its hash is stored, and the caller then presents it in the X-API-Key header:
osyrin app user apikey generate [email protected] # prints the pk_… once
osyrin app user apikey revoke [email protected] # --secondary to drop only the rotation keyBecause there are two columns, rotation has no gap: generate a second key while the first still works, move the callers over, then revoke the old one.
⚑ In a test, ask for one by naming the principal — [[testing-api-calls#credentials|Api.KeyFor(P)]]. "You never
write a key into source" is about your APP's source and stays true; a [Test] mints a real one against its own
throwaway branch, so the gated route above can actually be exercised rather than only refused.
⚠ app.Secrets is a different thing and is not involved. app.Secrets declares secrets your app consumes — an
LLM key, an OAuth client secret. ApiAuth has no member that names a Secret, so there is nothing to declare there
for an API key, and declaring one does not gate anything.
Examples#
A complete app that publishes one entity as read/create CRUD and one function as a POST route:
entity Order {
[Required, MaxLength(200)] string CustomerEmail;
decimal Total;
}
decimal SubmitOrder(decimal total, decimal taxRate) {
return total + total * taxRate;
}
app.Apis = [
new RestApi("Orders") {
Route = "orders",
Expose = [ new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create] } ],
Endpoints = [
new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit", SuccessStatus = 201 },
],
},
];Require an API key on every route. The key belongs to a USER, so the principal declares the two columns that hold
it — and the entity's security {} then governs the API caller exactly as it governs that same person signed in:
[Role] enum AppRole { Authenticator, Member }
entity RoleGrant {
[Required("Name the user this grant belongs to.")] User Grantee;
[Required("Choose the role this grant confers.")] AppRole Level;
security { allow read when IsAuthenticated; }
}
policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);
[Principal] entity User {
[Unique, MaxLength(200)] string Email;
[MaxLength(255)] string? ApiKeyHash;
[MaxLength(255)] string? ApiKeyHash2;
security {
allow read when IsAuthenticated;
// ⛔ BOTH SLOTS, or the stored key hash rides `Session.CurrentUser` to the browser.
deny read ApiKeyHash when !IsAuthenticator;
deny read ApiKeyHash2 when !IsAuthenticator;
}
}
entity Product {
[Required, MaxLength(200)] string Name;
decimal Price;
security {
allow read when IsAuthenticated; // the API-key caller IS authenticated — no anonymous grant needed
}
}
app.Apis = [
new RestApi("Catalog") {
Route = "catalog",
Auth = new ApiAuth { ApiKey = true },
Expose = [ new Crud<Product>() { Operations = [CrudOp.Read] } ],
},
];See also#
- principal predicates (IsAuthenticated / IsAnonymous) and open reads —
IsAuthenticated/IsAnonymous, the predicates the table above resolves - [AuthMethod] — a function an unauthenticated visitor may call — signing a user in, for bearer-authenticated routes
- function — the functions an
Endpointpublishes - declaring secrets (app.Secrets) —
app.Secrets, for the secrets your app CONSUMES; an API key is not one of them - Calling your own REST API from a test —
Api.*: calling these routes from your own.test.osy, and asserting the status each refusal answers