# publishing a REST API (app.Apis)

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

<!-- id: api-rest · area: api · stability: stable · html: https://osysharp.com/reference/api/rest/ -->

## Summary        {#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.

```osy syntax
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      {#signature}
```osy syntax
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    {#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 `throw`s 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  {#wire-form}
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:

```json title="a value outside the vocabulary" syntax
{ "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  {#envelope}
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:

```osy syntax
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](https://osysharp.com/reference/testing/api-calls/) has this as a compiled example, and [JsonSerializer](https://osysharp.com/reference/json/serializer/) is the verb that reads it.

## Who is `user`?  {#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 `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](https://osysharp.com/reference/testing/api-calls/)
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:

```osy syntax
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  {#api-key-storage}
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:

```osy syntax
[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)](https://osysharp.com/reference/security/auth-bootstrap/) for the three lines that declare it, and [acting for another principal](https://osysharp.com/reference/security/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:

```bash
osyrin app user apikey generate alice@example.com     # prints the pk_… once
osyrin app user apikey revoke   alice@example.com     # --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       {#examples}
A complete app that publishes one entity as read/create CRUD and one function as a POST route:

```osy title="basic" test app=api-rest-example
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:

```osy title="with-api-key" test app=api-rest-authed
[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       {#see-also}
- [principal predicates (IsAuthenticated / IsAnonymous) and open reads](https://osysharp.com/reference/security/principal-predicates/) — `IsAuthenticated` / `IsAnonymous`, the predicates the table above resolves
- [[AuthMethod] — a function an unauthenticated visitor may call](https://osysharp.com/reference/security/auth-method/) — signing a user in, for bearer-authenticated routes
- [function](https://osysharp.com/reference/function/declaration/) — the functions an `Endpoint` publishes
- [declaring secrets (app.Secrets)](https://osysharp.com/reference/config/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](https://osysharp.com/reference/testing/api-calls/) — `Api.*`: calling these routes from your own `.test.osy`, and asserting the status each refusal answers
