# 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 returns what came back — `StatusCode`, `Body`, `IsSuccess`, `Headers`. The request goes through the same handler a stranger's `curl` reaches, so it exercises routing, authentication, JSON binding and the refusal-to-status mapping. It is ANONYMOUS unless you name a credential, because that is what a third party is.

<!-- id: testing-api-calls · area: testing · stability: preview · html: https://osysharp.com/reference/testing/api-calls/ -->

## Summary        {#summary}
[`app.Apis`](https://osysharp.com/reference/api/rest/) publishes your app over HTTP for other systems to call. `Api.*` is how you **call it back** —
from your own `.test.osy`, over real HTTP, into the same handler a stranger reaches.

That matters because everything interesting about a published API happens on the way IN: the route has to resolve, the
credential has to be accepted, the JSON body has to bind to your function's parameters, and a refusal has to come back
as the right status. Calling the function directly proves none of it. `Api.*` proves all of it, and the assertion is
an ordinary `Assert.Equal` over a number:

```osy syntax
var r = Api.Post("/api/rest/v1/lab/specimens", "{\"accession\":\"ACC-1\"}");
Assert.Equal(201, r.StatusCode);
```

## Signature      {#signature}
```osy title="the five verbs, and where a credential goes" syntax
Api.Get(path)                  Api.Delete(path)
Api.Post(path, body)           Api.Put(path, body)           Api.Patch(path, body)

// every verb also takes a body, and every verb also takes either credential — both BY NAME
Api.Post(path, body, apiKey: "pk_…")
Api.Get(path, bearer: token)
```

`path` and `body` are positional; `apiKey:` and `bearer:` are named-only. `body` is optional on **every** verb — a
call with no body is simply the one-argument form.

Two more verbs answer the question those arguments raise — *where do I get one?*:

```osy title="where a credential comes from — you name the principal, not the secret" syntax
Api.KeyFor(P)      // that principal's API key (`pk_…`), for `apiKey:`
Api.TokenFor(P)    // a bearer token for that principal, for `bearer:`
```

Each takes a declared [`principal`](https://osysharp.com/reference/testing/runas/) — the same operand as `Ui.SignInAs(P)`, and for the same reason:
it names WHO, and who is a row your fixture seeded, never a string somebody typed.

Each call returns an `ApiResponse`:

| member | what it is |
|---|---|
| `StatusCode` | the status your app answered with — `200`, `201`, `400`, `401`, `403`, `404`, `409`, `500` |
| `Body` | the response body as text. `JsonSerializer.Deserialize<T>(r.Body)` reads it into a `class` you declare |
| `IsSuccess` | true when the status is 2xx |
| `Headers` | every response header, as `ApiHeader { Name, Value }` — `r.Headers.Single(h => h.Name == "Location").Value` |

A 4xx or 5xx is a **normal return**, never a throw. Asserting that a refusal carries the right status is the whole
point of the verb, and a throw would make exactly those cases unassertable.

## Description    {#description}

### Which path do I call?   {#path}
The **published** one. An `Endpoint`'s own `Path = "/scans"` is only the tail of it; the address the world calls is
built from the API's `Route`, its major `Version` (v1 when you omit it), and then that tail:

```text
/api/rest/v{major}/{route}/{the Endpoint's Path}          →  /api/rest/v1/carrier/scans
/api/rest/v{major}/{route}/entities/{Entity}              →  /api/rest/v1/vault/entities/Specimen
```

`osy model` prints each API's base path, so the reliable move is to copy it from there. Writing the short form is a
compile error that spells the full shape out — it is refused rather than 404'd, because a 404 reads as "my route is
broken" and there is nothing in it to correct you with.

### Who is the caller?   {#who-is-calling}
**Nobody, unless you say otherwise.** A request with no `apiKey:` and no `bearer:` arrives anonymous, exactly as a
stranger's does.

⚠ **`runas` does not reach it, deliberately.** [`runas(P)`](https://osysharp.com/reference/testing/runas/) rebinds the principal on the ENGINE; the
request is a separate call arriving at your front door with whatever it carries. If the enclosing `runas` leaked into
it, an authorization test would go green because the ENGINE was somebody — on a request that presented nothing. That
is a test passing while the door stands open, so the verb refuses to make it possible.

Which means the negative test is the easy one, and it is the one worth writing first:

```osy syntax
// no credential → the app's own gate answers, and it is the gate you are testing
Assert.Equal(401, Api.Get("/api/rest/v1/vault/entities/Specimen").StatusCode);
```

To be somebody, name a credential. The two map to the two things [[api-rest#who-is-user|`ApiAuth`]] accepts:

| you write | it is sent as | it satisfies | where the value comes from |
|---|---|---|---|
| `apiKey:` | `X-API-Key` | `new ApiAuth { ApiKey = true }` | `Api.KeyFor(P)` |
| `bearer:` | `Authorization: Bearer …` | `new ApiAuth { Bearer = true }` (and `OAuth`) | `Api.TokenFor(P)`, or your own `[AuthMethod]` |

An authenticated caller is **a user of your app** — the same `user`, the same role grants, the same row filters as
that person signed in. Nothing about an API call is a special machine identity.

### Where a credential comes from   {#credentials}
Both mints are **test-only**, and the compiler refuses them outside a `[Test]`/`[TestFixture]`. Handing out a working
credential for a principal without presenting one is impersonation anywhere else; it is legitimate here because a
fixture seeded that row and naming it is an authoring act — the same rule, and the same reason, as `Ui.SignInAs`.

`Api.KeyFor(P)` mints a real per-user API key, exactly as `osyrin app user apikey generate` does for a live app, and
stores its hash on the principal's own row — so the app's `[Principal]` must declare the two columns
[[api-rest#api-key-storage|`ApiKeyHash` and `ApiKeyHash2`]], as it must to accept keys at all. It is **stable for the
life of the run**: ask twice and you get the same key. That is deliberate — a principal has exactly two key slots (the
second is the rotation one), so a verb that minted afresh on every mention would run out on the third call.

`Api.TokenFor(P)` mints a bearer token — the same ticket `Ui.SignInAs(P)` signs the browser in with, so a test that
drives a page and a test that calls the API as the same person hold one credential, not two that could disagree.

⚑ **Your app's own login also works, and is often the better test.** `Security.IssueJwt` — what an `[AuthMethod]`
returns — issues a real ticket in a served run, so `bearer: Login(email, password)` calls your API as somebody who
authenticated the way a real caller does, through your own password check:

```osy syntax
Api.Get("/api/rest/v1/vault/entities/Specimen", bearer: Login("curator@lab.test", "hunter2"))
```

Reach for `Api.TokenFor(P)` when the app has no login to call, or when the test is about the API rather than about
signing in.

### What can run this?   {#where-it-runs}
A runner that is actually **serving** your app. `osy test` is one — it starts or finds a local platform for you, so
nothing is required of you beyond writing the call. An engine-only runner has no door to knock on, and there the test
is **skipped with a reason, before its body runs at all** — never a red. That distinction is the whole point: an error
part-way through a body reads as "this app is broken" and sends you to read an endpoint that is working, while a skip
reads as "this runner does not do that". It is decided up front, from the source, and it follows helpers: a test that
posts through a `Signed(...)` of your own is still a test that calls your API.

That applies to `Api.KeyFor` / `Api.TokenFor` as well, and for the same reason: a key is hashed against the id of the
app being served and a token is signed with the platform's key, so only a runner that IS the server can make either.
A runner that cannot says so in a sentence, rather than handing back a credential nothing will accept.

⚑ **Every fence on this page is `test`, not `run` — deliberately.** A `run` fence is EXECUTED by the docs gate, and
the docs harness has a database and no web server, so every `Api.*` call in one would fail on the missing door rather
than on anything the example got wrong. `test` compiles them, which is the strongest check this harness can honestly
make: it catches a wrong verb, a wrong argument name, a bad path and a type error. The behaviour they describe — every
status below, and the credentialed calls above — is executed for real by the platform's own acceptance suite for this
verb, which runs against a live host.

### Why isn't this `Http.Post`?   {#not-http}
Because they are opposite directions, and conflating them costs you a capability you do not want:

| | [`Http.*`](https://osysharp.com/reference/testing/outbound-calls/) | `Api.*` |
|---|---|---|
| direction | OUT, to somebody else's host | IN, to a route **your** app publishes |
| gate | `use Osysharp.Http;` — an egress capability you declare | none; publishing an API is enough |
| returns | `HttpResponse` (needs `using Osysharp.Http;`) | `ApiResponse`, always in scope |
| available in app code | yes | no — there is nothing for an app to gain by calling its own endpoint |

A loopback `Http.Post("http://localhost/…")` is not a workaround for this: the platform's egress guard refuses an
internal address, which is correct and is why this verb exists.

## Examples       {#examples}
The app: one entity with a `[Unique]` column, one function published as a route, and a second API gated on an API key.

```osy title="an app that publishes an open route and a gated one" test app=testing-api-calls
// The two columns an API key needs somewhere to live. Declaring `ApiAuth { ApiKey = true }` without them is a
// compile error — no key could be minted and none could be verified, so the route would be a 401 forever.
[Role] enum LabRole { Authenticator, Curator }
entity RoleGrant {
  [Required("Name the curator this grant belongs to.")] Curator Grantee;
  [Required("Choose the role this grant confers.")] LabRole Level;
  security { allow read when IsAuthenticated; }
}
policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == LabRole.Authenticator);

[Principal] entity Curator {
  [Unique, MaxLength(200)] string Email;
  [MaxLength(255)] string? ApiKeyHash;
  [MaxLength(255)] string? ApiKeyHash2;
  security {
    allow read when IsAuthenticated;
    // ⛔ BOTH SLOTS. The platform ships the [Principal] to the client for `Session.CurrentUser.*` minus its
    //    MASKED properties, so a key hash with no field-level `deny read` rides that payload to the browser.
    deny read ApiKeyHash  when !IsAuthenticator;
    deny read ApiKeyHash2 when !IsAuthenticator;
  }
}

entity Specimen {
  [Required, MaxLength(50), Unique("That accession number is already recorded.")] string Accession;
  [MaxLength(100)] string Species;
  security { allow read, create when IsAnonymous || IsAuthenticated; }
}

class SpecimenReceipt {
  [MaxLength(50)] public string Accession;
  [MaxLength(100)] public string Species;
}

// The wrapper a LIST route needs — see "Reading the body back into a class" below. `Result` binds the `result`
// the platform wraps a non-entity, non-class return in.
class SpecimenPage { public List<SpecimenReceipt> Result; }

[AllowAnonymous]
SpecimenReceipt RecordSpecimen(string accession, string species) {
  if (accession == "RETIRED") {
    throw new ConflictException("That accession number was retired and cannot be reused.");
  }
  var s = new Specimen { Accession = accession, Species = species };
  return new SpecimenReceipt { Accession = s.Accession, Species = s.Species };
}

[AllowAnonymous]
List<SpecimenReceipt> ListSpecimens() {
  return Specimen.OrderBy(s => s.Accession)
                 .Select(s => new SpecimenReceipt { Accession = s.Accession, Species = s.Species })
                 .ToList();
}

app.Apis = [
  new RestApi("Lab") {
    Route = "lab",
    Endpoints = [
      new Endpoint(RecordSpecimen) { Method = HttpMethod.Post, Path = "/specimens", SuccessStatus = 201 },
      new Endpoint(ListSpecimens)  { Method = HttpMethod.Get,  Path = "/specimens" },
    ],
  },
  // Gated. It accepts either credential, so one route exercises both columns of the table above.
  new RestApi("Vault") {
    Route  = "vault",
    Auth   = new ApiAuth { ApiKey = true, Bearer = true },
    Expose = [ new Crud<Specimen>() { Operations = [CrudOp.Read] } ],
  },
];
```

```osy title="the happy path — and the row it really wrote" test app=testing-api-calls
[Test]
void a_post_to_the_published_route_runs_the_function_and_the_row_lands() {
  var r = Api.Post("/api/rest/v1/lab/specimens", "{\"accession\":\"ACC-1\",\"species\":\"Bufo bufo\"}");

  Assert.Equal(201, r.StatusCode);            // the endpoint's own SuccessStatus, not a generic 200
  Assert.Contains("Bufo bufo", r.Body);       // the function's return value came back down the wire
  Assert.Contains("json", r.Headers.Single(h => h.Name == "Content-Type").Value);

  // …and an ordinary query in the same test reads the row the request created.
  Assert.Equal("Bufo bufo", Specimen.Single(s => s.Accession == "ACC-1").Species);
}
```

```osy title="every refusal, as the status it is documented to be" test app=testing-api-calls
[Test]
void each_refusal_carries_its_documented_status() {
  Assert.Equal(201, Api.Post("/api/rest/v1/lab/specimens", "{\"accession\":\"ACC-2\"}").StatusCode);

  // a [Unique] collision is the language's ValidationException → 400, and nothing is written twice
  Assert.Equal(400, Api.Post("/api/rest/v1/lab/specimens", "{\"accession\":\"ACC-2\"}").StatusCode);
  Assert.Equal(1, Specimen.Where(s => s.Accession == "ACC-2").Count());

  // a refusal the function THREW → 409, carrying its own message
  var conflict = Api.Post("/api/rest/v1/lab/specimens", "{\"accession\":\"RETIRED\"}");
  Assert.Equal(409, conflict.StatusCode);
  Assert.Contains("retired", conflict.Body);

  // a route nothing publishes → 404
  Assert.Equal(404, Api.Get("/api/rest/v1/nosuchapi/entities/Specimen").StatusCode);
}
```

### Reading the body back into a class  {#reading-the-body}
`Assert.Contains("Bufo bufo", r.Body)` above is a substring match on raw JSON, and it is the weakest thing you can
say about a response — it passes when the value is in the wrong field. Read the body into a `class` instead:

```osy title="the response, read into a class you declare" test app=testing-api-calls
[Test]
void the_response_body_reads_back_into_a_declared_class() {
  var r = Api.Post("/api/rest/v1/lab/specimens", "{\"accession\":\"ACC-DTO\",\"species\":\"Hyla arborea\"}");

  var receipt = JsonSerializer.Deserialize<SpecimenReceipt>(r.Body);
  Assert.Equal("ACC-DTO", receipt.Accession);
  Assert.Equal("Hyla arborea", receipt.Species);
}
```

The response spells its keys in **camelCase** (`"accession"`) while your class declares `Accession` — that is fine,
because names bind ignoring case, and an `[ExternalName("…")]` on a member overrides the spelling entirely.

⚠ **A route whose function returns a LIST does not answer a bare array.** Anything that is not an entity, a `class`
or `void` arrives inside the `{"result": …}` envelope ([[api-rest#envelope]]), so the DTO is a **wrapper**:

```osy title="a list endpoint answers an envelope — read through the wrapper" test app=testing-api-calls
[Test]
void a_list_endpoint_answers_the_result_envelope() {
  Api.Post("/api/rest/v1/lab/specimens", "{\"accession\":\"ACC-L1\",\"species\":\"Rana\"}");
  Api.Post("/api/rest/v1/lab/specimens", "{\"accession\":\"ACC-L2\",\"species\":\"Bufo\"}");

  var page = JsonSerializer.Deserialize<SpecimenPage>(Api.Get("/api/rest/v1/lab/specimens").Body);
  Assert.Equal(2, page.Result.Count);
  Assert.Equal("ACC-L1", page.Result[0].Accession);
}
```

If a body matches **none** of a class's members, `Deserialize` refuses and names both vocabularies — what your class
declares and what the body actually carries — rather than handing you an object with every field null. A member that
matched nothing while the body carried a key you did not declare is reported through `osy logs` and on the test's own
notes, without refusing: a small DTO over a large response is the ordinary case.

```osy title="the same door, with a credential" test app=testing-api-calls
[TestFixture]
void Seed() {
  new Curator { Email = "curator@lab.test" };
}

principal Chief => Curator.Single(c => c.Email == "curator@lab.test");

[Test(Seed)]
void a_named_principals_credential_opens_the_gated_route() {
  // Nobody gets in…
  Assert.Equal(401, Api.Get("/api/rest/v1/vault/entities/Specimen").StatusCode);

  // …and so does a credential that is not the real one, which is what makes the two below mean something.
  Assert.Equal(401, Api.Get("/api/rest/v1/vault/entities/Specimen", apiKey: "pk_not-a-real-key").StatusCode);

  // Either credential, naming the principal it belongs to.
  Assert.Equal(200, Api.Get("/api/rest/v1/vault/entities/Specimen", apiKey: Api.KeyFor(Chief)).StatusCode);
  Assert.Equal(200, Api.Get("/api/rest/v1/vault/entities/Specimen", bearer: Api.TokenFor(Chief)).StatusCode);
}
```

```osy title="a third party writing through the API, colliding with a row the app made" test app=testing-api-calls
[Test(Seed)]
void a_third_party_writing_through_the_api_collides_with_a_row_the_app_made() {
  // In-app: an ordinary engine-side write.
  runas (Chief) { new Specimen { Accession = "ACC-SHARED", Species = "Bufo bufo" }; }

  // Over the wire: the same accession, arriving at the front door with a credential. The app's own `[Unique]`
  // sentence comes back as the status it is documented to be, and nothing is written twice.
  var r = Api.Post("/api/rest/v1/lab/specimens",
                   "{\"accession\":\"ACC-SHARED\",\"species\":\"Rana\"}",
                   apiKey: Api.KeyFor(Chief));
  Assert.Equal(400, r.StatusCode);
  Assert.Equal(1, Specimen.Where(s => s.Accession == "ACC-SHARED").Count());
}
```

## See also       {#see-also}
- [publishing a REST API (app.Apis)](https://osysharp.com/reference/api/rest/) — `app.Apis`: publishing the routes this calls, and the full refusal-to-status table
- [Outbound calls in a test](https://osysharp.com/reference/testing/outbound-calls/) — `Http.*`, the other direction: calling somebody else's host from a test
- [runas](https://osysharp.com/reference/testing/runas/) — being somebody in the test BODY, and why that never travels to the request
- [Assert](https://osysharp.com/reference/testing/assert/) — `Assert.Equal` / `Assert.Contains`, which is how a response is read
- [Testing (real app, real data, real rules)](https://osysharp.com/reference/testing/index/) — what a test runs against, and who it acts as
