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 / Testing

Calling your own REST API from a test

Api.Get(path) · Api.Post(path, body) · Api.Put/Patch(path, body) · Api.Delete(path) — each also takes apiKey: / bearer:, filled by Api.KeyFor(P) / Api.TokenFor(P)

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

preview7 examples compiled by CItestingapiresthttp

Summary#

app.Apis 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:

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

Signature#

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?:

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 — 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:

memberwhat it is
StatusCodethe status your app answered with — 200, 201, 400, 401, 403, 404, 409, 500
Bodythe response body as text. JsonSerializer.Deserialize<T>(r.Body) reads it into a class you declare
IsSuccesstrue when the status is 2xx
Headersevery 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#

Which path do I call?#

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:

/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?#

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) 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:

// 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 writeit is sent asit satisfieswhere the value comes from
apiKey:X-API-Keynew 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#

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:

Api.Get("/api/rest/v1/vault/entities/Specimen", bearer: Login("[email protected]", "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?#

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?#

Because they are opposite directions, and conflating them costs you a capability you do not want:

Http.*Api.*
directionOUT, to somebody else's hostIN, to a route your app publishes
gateuse Osysharp.Http; — an egress capability you declarenone; publishing an API is enough
returnsHttpResponse (needs using Osysharp.Http;)ApiResponse, always in scope
available in app codeyesno — 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#

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

// 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] } ],
  },
];
[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);
}
[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#

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:

[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:

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

[TestFixture]
void Seed() {
  new Curator { Email = "[email protected]" };
}

principal Chief => Curator.Single(c => c.Email == "[email protected]");

[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);
}
[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#

Related

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…

Testing (real app, real data, real rules)

A test in Osy# is not a unit test with the world mocked out. It is your app, running against its own throwaway…

Assert

The assertions a test makes. Beyond the usual equality and null checks there are comparisons (Greater, Less, InRange)…

Outbound calls in a test

A `[Test]` reaches the network for real — `Http.*` and a typed `client { }` operation both run inside a test, against…

runas

Runs a block as a given principal, so security rules apply exactly as they would for that user. It is how you test that…

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…

JsonSerializer

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