Osy#betaRun it on Osyrin: osyrin.com

Reference / API

publishing a REST API (app.Apis)

app.Apis = [ new RestApi("Name") { Route = "…", Expose = [ new Crud<Entity>() { … } ], Endpoints = [ new Endpoint(Fn) { … } ] } ];

`app.Apis` publishes your app to a third party over HTTP. You never write a controller or an endpoint handler — you EXPOSE what already exists: `Expose` turns an entity into CRUD routes (`new Crud<Order>() { Operations = [...] }`), and `Endpoints` maps one of your functions to a route (`new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit" }`). Each `RestApi` has a `Route` prefix and an `Auth` that says who may call it — including `Anonymous = true` when the answer is everyone, which is a decision you write rather than one you reach by saying nothing. The entity in a `Crud<T>` and the function in an `Endpoint(...)` must exist — a name that doesn't resolve is a compile error that names what does.

stable3 examples compiled by CIapiresthttpconfig

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 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 },   // who may call
    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 omitted Version defaults to v1, so the simple case needs no version at all. A multi-major API gives each RestApi its own Version (e.g. "1.0", "2.0").
  • Auth (REQUIRED) — an ApiAuth { ApiKey = true, Bearer = true, OAuth = true }, or ApiAuth { Anonymous = true } to open the API to everyone deliberately. Those four 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 a security {} rule for an API.
  • Expose — a list of new Crud<Entity>() { Operations = [CrudOp.…] }. Each entry turns one entity into REST routes; Operations chooses which of Read / Create / Update / Delete exist. The entity must be defined in your app — a Crud<Unknown> is a compile error that lists the entities you do define.
  • Endpoints — a list of new Endpoint(Function) { Method = HttpMethod.…, Path = "…" }. Each maps one of your functions to a route. The function must be declared — an Endpoint(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.

Where an API is served: /api/rest/v{major}/{route}/…#

Every API is served at ONE address shape, on the app's own host:

https://<app-host>/api/rest/v{major}/{Route}{Path}           an Endpoint
https://<app-host>/api/rest/v{major}/{Route}/entities/{Entity}   a Crud<T>

So new RestApi("MailEvents") { Route = "mail-events", Endpoints = [ new Endpoint(Reported) { Path = "/resend" } ] } is called at https://<app-host>/api/rest/v1/mail-events/resend — that is the address to give a webhook provider. osy model prints each API's address (basePath, and each endpoint's url), so copy it from there rather than writing it by hand. An address you build in code for a provider to call back (a payment's status callback) is $"{App.BaseUrl}/api/rest/v1/{route}{path}".

Nothing else is an API's address. A request for one of your API's routes at a shorter spelling — /api/mail-events/resend, /api/v1/mail-events/resend, /api/rest/mail-events/resend, or a POST to /mail-events/resend — is answered 404 with a JSON body that names the address the API IS served at, so a provider's delivery log tells you what to type. No page is ever served under /api/.

What an endpoint binds its parameters from#

Exactly two places, and the list is closed:

  1. A {placeholder} in that endpoint's own Path. new Endpoint(Settle) { Path = "/settle/{paymentId}" } fills the function's paymentId parameter from the matched segment, coerced to its declared type.
  2. A property of the JSON request body, matched by name (camelCase or PascalCase). On a name collision the body wins.
string Settle(Guid paymentId, string reference, string status) { … }

new Endpoint(Settle) { Method = HttpMethod.Post, Path = "/payments/{paymentId}/settle" }
// → POST /api/rest/v1/bank/payments/9f3…/settle   {"reference":"wire-9001","status":"settled"}

A parameter that is an entity takes a row. A string id binds the existing row, read as the caller, so a row they may not read is a 404. An object without an id builds a new row from its fields.

An [Action] as an endpoint — listed, never automatic#

An [[function-action|[Action]]] is the verb a macro, a rule or an agent runs on a record: its first parameter is that record. It becomes part of your API only when you list it, like any other function. The path's {id} then binds the record:

[Action("Close")]
public string CloseJob(Job job, string note) { … }

new Endpoint(CloseJob) { Method = HttpMethod.Post, Path = "/jobs/{id}/close" }
// → POST /api/rest/v1/jobs/jobs/4b1…/close   {"note":"Tiles replaced"}   →   {"result":"Closed Fix the roof."}

The other parameters bind from the body as usual, and an entity among them takes its row's id ({"agent": "<id>"}). An id that names no row the caller may read is a 404; nothing is ever created from the request. ⛔ An action you do not list is not reachable over the API, by any path. Being an action makes a verb available to the app's own macros, rules and agents, never to a caller outside it.

⛔ The query string is not bindable, and [Header]/[Query] are not how you read one. Those two mark where an outgoing argument travels, and an outgoing client is a class that builds its own URL and header map — so they bind nothing anywhere, and are refused on a function you declare.

The request AS RECEIVED — the raw bytes and named headers — is a separate surface, and it is declared on the endpoint rather than bound to a parameter. See the next section.

Reading the request as it arrived: Raw and Headers#

A signed webhook — Slack, Stripe, GitHub, Shopify, Twilio, DocuSign — signs its headers over the body bytes. A body re-serialised from bound parameters is not those bytes, so verifying against the bound model is not awkward, it is wrong: it fails for whitespace the sender chose and you did not.

So an endpoint may ask for what it needs, and the function reads it back through Request:

On the endpointIn the functionAnswers
Raw = trueRequest.RawBodythe body as text, before any JSON parse
Headers = ["X-Sig", …]Request.Header("X-Sig")that header's value, or null if the sender sent none

Both are declared, never ambient, and the declaration is the grant: a header this endpoint did not name is not readable from this function, so what an app can see of its callers is visible in its own source. Header names are matched case-insensitively, because HTTP header names are.

⛔ A credential header may not be declared at all — Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-API-Key. Those are how the caller authenticates to the platform, and handing one to a handler would let an app replay its callers' credentials elsewhere. The compiler refuses the declaration by name. A webhook signature never travels in one of these anyway: the sender has no account here, which is why it signs.

⛔ Reading one you did not declare does not compile. Request.RawBody without Raw = true, or Request.Header("X") where X is not in Headers, is refused by the compiler, and the refusal lists the names the endpoint does declare — because the usual cause is two strings written minutes apart that differ by a character. The declaration and the read are one contract held in two places and joined by a string literal, so the compiler checks them against each other rather than leaving it to the first real request.

⚠ The same mistake one call deeper is a fault at run time, not a compile error. The compile-time check reads the endpoint function's own body; a Request.Header(…) inside a helper it calls is beyond it, and so is a name that is not a literal. Those still fail loudly, with the same message naming the one-line fix — a null was never on the table. Only "the sender sent no such header" is a null, because that is the app's to refuse and it must not look like a mistake in the app's own source. That fault also covers being reached some other way entirely (an MCP tool call, an agent, a workflow step, a [Test] body): there is no request to read, and saying so beats answering null to a verifier that would then "pass".

Raw is carried alongside the bound parameters, never instead of them — an endpoint can bind a typed payload and verify the signature over what actually arrived. A Raw = true endpoint whose body is not JSON at all (a form-encoded payload=…) is not refused; it simply binds nothing, and the handler decides.

app.Secrets = [ new Secret("SlackSigningSecret") ];

class Receipt { public bool Verified; }

[AllowAnonymous]
Receipt SlackEvents() {
  var body = Request.RawBody ?? "";
  var ts   = Request.Header("X-Slack-Request-Timestamp") ?? "";
  var sig  = Request.Header("X-Slack-Signature") ?? "";
  // Slack's own scheme: v0={hmac of "v0:{timestamp}:{body}"}, over the bytes it sent.
  var expected = "v0=" + Crypto.HmacSha256Hex(Secret.SlackSigningSecret, "v0:" + ts + ":" + body);
  return new Receipt { Verified = Crypto.FixedTimeEquals(expected, sig) };
}

app.Apis = [
  new RestApi("Slack") {
    Route = "slack",
    Auth  = new ApiAuth { Anonymous = true },
    Endpoints = [
      new Endpoint(SlackEvents) {
        Method = HttpMethod.Post, Path = "/events",
        Raw = true, Headers = ["X-Slack-Signature", "X-Slack-Request-Timestamp"],
      },
    ],
  },
];

Who is asking: Request.ClientAddress#

Request.ClientAddress is the end user's address — the first hop of X-Forwarded-For behind the gateway, else the connection's. It is what an identity provider asks for (BankID's endUserIp), and the one mistake it exists to prevent is sending the SERVER's own address, which works in development, where the two are one machine, and blinds the provider's fraud checks in production.

Unlike a header it is not declared, and it is readable from any function a request reached — a page's action as much as an endpoint — because an address is neither the caller's text nor a credential, and the function that needs it is usually a checkout step, which no endpoint reaches. It is "" when no request did: a workflow step on a timer, a resumed continuation, a [Test] body.

var order = new BankIdClient { BaseUrl = "https://appapi2.test.bankid.com/rp/v6.0" }.Auth(Request.ClientAddress);

⚠ A label, never an authority. Behind a proxy the header is the client's to write, so use the address for what a provider asks it for and never to decide what someone may do. It runs on the server only: a browser does not know its own public address.

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 policy403 — the caller may not do this
hits a genuine, unexpected error500 — 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 asOn the wire
enumits member name — "machine": "Washer", never the ordinal 0
enum with [Value("…")]that value, because you chose it — "risk": "risk-high"
Guid, DateTime, DateOnly, TimeOnly, TimeSpana string in the canonical form — "2026-09-04", "01:30:00"
int, decimal, boolthe 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 routeWhat comes back
an Endpoint whose function returns an entitythat row, flat — {"id": "…", "accession": "ACC-1"}
an Endpoint whose function returns a classits 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 stringwrapped: {"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 / updatethat row, flat
Crud<T> delete204, 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.

Reading many: $filter, $orderby, $top, $skip, $expand#

GET /entities/T takes OData's query options. A mistake in any of them is a 400 that names it, never a 500 and never a silently different answer.

OptionWhat it takesWhen it is wrong
$filtereq ne gt ge lt le, and / or, eq null, and contains(p,'…'), startswith(p,'…'), endswith(p,'…'). Those three are literal: contains(name,'50%') finds the text "50%", and '' inside the quotes is one quote400 INVALID_FILTER, quoting your filter and where it stopped parsing (name eq with no value, a trailing and, an unsupported function)
$orderbyname, name desc, comma-separated for more keys400, naming the term
$toprows per page; 50 when omitted, at most what fits about 2 MB of this entity's rows (How many rows a page is sent)a larger number is served at that size, and the answer's top says so. Rows larger than estimated end the answer early, with hasMore
$skiprows to skip — continue an answer from $skip plus the items it held—
$expandrelations, comma-separated; lines.product follows one further400 UNSUPPORTED_EXPAND_OPTION, naming the member and listing the relations you could have named. A value ($expand=name) is refused the same way

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:

Auththe request carriesuserIsAuthenticatedIsAnonymous
new ApiAuth { ApiKey = true }X-API-Key: pk_…the key OWNER's principal rowtruefalse
new ApiAuth { Bearer = true }Authorization: Bearer …that token's principal rowtruefalse
new ApiAuth { OAuth = true }an OAuth-issued bearer tokenthat token's principal rowtruefalse
new ApiAuth { Anonymous = true }anything, or nothingnullfalsetrue
an authenticating Authnothing——— → the request is 401 and never reaches your data

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 IsAnonymous to make an entity reachable over an authenticated API. The caller is somebody. An allow … when IsAnonymous rule 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 Anonymous = true runs anonymously, and there user is null — so a row filter correlating to user matches 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);      // somebody

Where 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.

Every API says who may call it#

Auth is required, and an API that omits it is a compile error listing the four things it could have said. The reason is what silence used to mean: a RestApi with no Auth published an open route, reachable by anyone who found the URL — and nothing in the source said so.

That is the inverse of how the rest of the platform reads silence, which is what made it dangerous:

you write nothing about…and you get
an entity's security { }denied — nobody may read or write it
a routed component's accessdenied — until it says [AllowAnonymous]
a RestApi's Authopen → now a compile error

Anonymous is still perfectly legal, and for a public catalogue or a health check it is the right answer. What is refused is arriving there by saying nothing:

new RestApi("PublicCatalogue") {
  Route = "catalogue",
  Auth  = new ApiAuth { Anonymous = true },   // deliberate, and visible in the diff that added it
  Expose = [ new Crud<Product>() { Operations = [CrudOp.Read] } ],
}

⚠ Anonymous = true cannot be combined with ApiKey/Bearer/OAuth — that would say two opposite things about one route — and it does not weaken your entities. Their security { } blocks still decide what an anonymous caller may read or write, which for most entities is nothing.

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 key

The app's own administrators can do the same from inside the app with [[security-people-kit#api-keys|the People kit's API keys page]]: issue a key to a teammate (shown once), see when each key was last used, and revoke one. When the principal also declares DateTime? ApiKeyUsedAt; and DateTime? ApiKey2UsedAt;, the platform records when each slot's key was last presented, to the minute. A revoked key is refused from the next request.

Because 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",
    // Every API says who may call it. This one is open on purpose; the next example authenticates.
    Auth   = new ApiAuth { Anonymous = true },
    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 }
[RoleGrant] 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#

Related

principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads

Two built-in `when` predicates say who a request is: `IsAuthenticated` is a signed-in user, `IsAnonymous` is an…

Calling your own REST API from a test

`Api.*` sends a real HTTP request to a route your own app publishes with `app.Apis`, from inside a `[Test]`, and…

[AuthMethod] — a function an unauthenticated visitor may call

`[AuthMethod]` marks a sign-in function — login, signup, password-reset — as reachable by a visitor who is not signed…

function

A function is a top-level unit of work, written like a C# method — a return type, a name, typed parameters, a body. It…

declaring secrets (app.Secrets)

`app.Secrets` declares the named secrets your app uses — API keys, tokens, client secrets. Each is `new…

JsonSerializer

Turn a value into a JSON string and a JSON string into a typed object — the C#-faithful System.Text.Json spelling…