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:
| 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#
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/Specimenosy 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 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#
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.* | |
|---|---|---|
| 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#
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#
- publishing a REST API (app.Apis) —
app.Apis: publishing the routes this calls, and the full refusal-to-status table - Outbound calls in a test —
Http.*, the other direction: calling somebody else's host from a test - runas — being somebody in the test BODY, and why that never travels to the request
- Assert —
Assert.Equal/Assert.Contains, which is how a response is read - Testing (real app, real data, real rules) — what a test runs against, and who it acts as