Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

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 optional `Auth` (API key and/or bearer). 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.

stable2 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 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 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 (optional) — an ApiAuth { 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 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.

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.

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
any Auth at allnothing— → the request is 401 and never reaches your data
no Authanythingnullfalsetrue

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 an API with no Auth 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.

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

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",
    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#

Related

principal predicates (IsAuthenticated / IsAnonymous) 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…