Osy#betaRun it on Osyrin: osyrin.com

Reference / HTTP

a typed HTTP client (a class over Http.*)

class <Name> { string BaseUrl = "…"; public <Result> Op(<args>) { … } } // private Get/Post over Http.*

A typed HTTP client is an **ordinary class** that wraps an external API: a `BaseUrl` field, a credential field, one public method per endpoint, and a small private `Get`/`Post` that calls [[http-facade|`Http.*`]] and turns a non-2xx into a thrown error. Nothing about it is special — it is the class surface you already have, which is why you can read it, test it, and edit it. `osy import-api <spec>` generates exactly this shape from an OpenAPI document.

stable3 examples compiled by CIhttpclientapiintegration

Summary#

A typed client is a class that wraps an external HTTP API: you name the base URL once, hold the credential in a field, and write one method per endpoint over Http.*. Calling the method makes the request and hands back a typed result — the caller never builds a URL or reads a status code.

⚑ There is no client keyword. There was one, and it was retired (2026-09-27) because everything it did a class does better: you can read the request it makes, step through it, override one method, add a parameter the spec did not have, and test it with an ordinary Http.<Verb>.Stub. A generated client is now source you own rather than a shape the compiler interprets.

Signature#

class <Name> {
  public string Token   = "";                      // the credential — a FIELD, so the CALLER decides whose it is
  public string BaseUrl = "https://api.example";   // the root, named once

  public <Result> <Op>(<args>) {                   // one method per endpoint
    return JsonSerializer.Deserialize<<Result>>(Get("/path/" + Uri.EscapeDataString(arg)));
  }

  // the plumbing — private, four lines, the only place a URL or a failure is assembled
  string Get(string path) {
    var r = Http.Get(BaseUrl + path, Headers());
    if (!r.IsSuccess) { throw new ExternalServiceException("GET " + path + " → " + r.StatusCode + ": " + r.Body); }
    return r.Body;
  }
}

Description#

Every client is the same four things#

Every client in this repo — generated or hand-written — is the same four things, and nothing else:

partwhy it is that and not something else
a BaseUrl fieldnamed once, and settable, so a caller can point the same client at a sandbox without a second class
a credential fieldsee [[#credentialbelow]] — this is what makes per-user auth one line of app source
one public method per endpointits signature is the API's contract in your own type system
a private Get/Postthe single place a URL, a header set, and a failure message are assembled

The plumbing is the point#

Put the request-shaped work in two or three private methods and every public method becomes one line. That is not tidiness — it is where a fix lands once instead of per endpoint. A retry, a header the API started requiring, a different way of reading an error body: one edit, in Get, and every operation has it.

Where each argument goes#

You build the URL, so there is no attribute deciding for you — and that is the part worth getting right:

  • a path segment is concatenated, and must be escaped: Get("/users/" + Uri.EscapeDataString(upn)). An un-escaped / or # in a value silently addresses a different resource.
  • a query parameter is appended, escaped the same way, and is worth making optional rather than always sending: if (fields != null) { url = url + "&$select=" + Uri.EscapeDataString(fields); }.
  • a body is the second argument to Post/Put/Patch, serialized with JsonSerializer.Serialize.
  • a header goes in the Dictionary<string, string> every verb takes last — and the credential header belongs in the shared Headers(), never at a call site.

The credential is a FIELD, never a Secret read inside the class#

⭐ This is the whole per-user story, and it is the one design decision in this page that matters. A client that reads Secret.Token in its own body has decided whose credential it is. A client that takes it as a field has not — the app decides, in one line:

// the app's own credential: application mode, one identity for everybody
app.Secrets = [ new Secret("ApiToken") ];
string Track(string code) { return new TrackingClient { Token = Secret.ApiToken }.Track(code); }

// the CALLER's credential: delegated, "as me" — the same call site, one word different in the declaration
app.Secrets = [ new Secret("ApiToken") { UserScoped = true } ];

⭐ Name the credential in ONE place — a factory — and every call site uses it. A function that RETURNS the configured client compiles, with no annotation anywhere: the compiler notes that the field was assigned a credential (a Secret.X, an OAuth token, or an app setting's value), and from then on the OBJECT may travel between your functions while the FIELD may not come back out. Reading it is refused wherever it would escape — returned, logged, stored in a row, copied into another object that is then returned, serialized, interpolated, or read in a body sent to the browser — exactly as Secret.X itself is (reading a secret's value (Secret.Name)). Two more things follow from the object carrying a credential: a component may not hold it at all (an object reaches the browser with its fields, whether or not anything reads them), and the factory is callable by your code but not by a request (its response would carry the field).

app Tracker {
  model "model/**/*.osy";
  use Osysharp.Http;
}

using Osysharp.Http;

app.Secrets = [ new Secret("ApiToken") ];

public class TrackingClient {
  public string Token = "";
  public string BaseUrl = "https://track.example";
  public string Status(string code) {
    var headers = new Dictionary<string, string>();
    headers.Add("Authorization", "Bearer " + Token);   // the one place the credential is spent
    return Http.Get(BaseUrl + "/parcels/" + Uri.EscapeDataString(code), headers).Body;
  }
}

TrackingClient Tracking() { return new TrackingClient { Token = Secret.ApiToken }; }

string Where(string code) { return Tracking().Status(code); }
string Leak() { return Tracking().Token; }                  // ✗ `TrackingClient.Token` holds a credential
void Trace() { Log.Information($"{Tracking()}"); }          // ✗ writes the object out, field and all

Secret.ApiToken resolves against the signed-in user when the declaration says UserScoped, so the client is unchanged and the reviewer reads the decision where it is made. See reading a secret's value (Secret.Name) and, for tokens the platform acquires for you, OAuth clients (app.OAuthClients).

A failure is DATA until your client decides otherwise#

Http.* returns a 4xx/5xx as an HttpResponse with IsSuccess false — it does not throw (HttpResponse). So a client has to make a choice, and the choice that works is: throw, with the API's own words in the message. A caller that has to test IsSuccess after every operation has gained nothing over calling Http.Get itself.

⚠ Read the status code before you write the message. The two codes that look alike are the two that mean completely different work: 401 is the token (expired, or minted for the wrong audience — re-acquire it) and 403 is the permission (the token is fine, the app was never granted the scope — a consent change, not a code change). A client that says "request failed: 403" has thrown away the only part that was actionable.

Generating one from an OpenAPI spec#

You rarely hand-write a client for a large API — generate it. osy import-api <spec> reads an OpenAPI 2.0–3.2 document (a local file, a URL, or a GitHub blob URL) and writes an .osy file containing the client class: one method per endpoint with its URL, query parameters, headers and body already assembled, the private plumbing, the request/response class types, and the credential field(s). Import just what you need with --tag, --filter or --select.

The output is ordinary source — review it, trim it, and commit it like any other class. That is the point of generating a class rather than a declaration: a spec is usually wrong somewhere, and here you can just fix it.

osy import-api ./resend-openapi.yaml --tag Emails --auth bearer --secret ResendApiKey -n Mail.Resend -o resend.osy
# → resend.osy: `namespace Mail.Resend;` then `using Osysharp.Http;`, then
#   `public class ResendClient { public string ApiKey = ""; public Email SendEmail(SendEmailRequest body) { … } }`
#   plus the request/response classes and the private plumbing. It reminds you to declare the secret in your app:
#     app.Secrets = [ new Secret("ResendApiKey") ];   // value injected out-of-band, never committed

Each generated file lands in its own namespace, so two APIs that both declare an Address or a Weight live side by side in one app or kit. -n names it (dotted is fine: -n Sendcloud.Shipments; a kit uses its own Osysharp.<Kit>.*, which an app may not declare); without -n it is the API's name from the spec title — "Parcel documents API" → ParcelDocuments. Inside it the types keep their plain names, and the client is named after the namespace's LAST segment — -n Sendcloud.Shipments → ShipmentsClient — so two generations from one spec into sibling namespaces are two different classes. From your own code, one using reaches it: using Sendcloud.Shipments;, then new ShipmentsClient { … } (the full name works too). The secrets it suggests follow the client: ShipmentsApiKey, or ShipmentsUsername + ShipmentsPassword for Basic.

Auth — --auth is one of auto (the default: the spec's own scheme), bearer, basic, apiKey-header, apiKey-query or none:

--auththe client sendsfieldssecrets it tells you to declare
bearerAuthorization: Bearer <ApiKey>ApiKey<Client>ApiKey (or --secret)
basicAuthorization: Basic base64(<Username>:<Password>)Username, Password<prefix>Username, <prefix>Password — --secret is the prefix
apiKey-header / apiKey-querythe key under the scheme's name (--key-name), exactly as it is — even when that name is Authorization; with the spec's x-token-format: "ShippoToken {token}" or x-token-prefix: ShippoToken, as ShippoToken <key>ApiKey<Client>ApiKey (or --secret)

When a spec offers several schemes, the generator takes the first one it can write, in the spec's own order, and says which in the output and in the file's header — e.g. "Auth: HTTP Basic — the spec's HTTPBasicAuth scheme. The spec also offers OAuth2ClientCreds (oauth2 client credentials), which import-api cannot generate." A spec whose only schemes it cannot write (OAuth2, OpenID Connect, mutual TLS, a cookie key) is refused by name, listing the --auth values — it is never guessed as a bearer token. --auth none generates the client without auth, for you to add the token exchange to.

What the classes look like:

  • Composed schemas are flattened. allOf merges every member's properties and required lists; a oneOf/anyOf of objects becomes one class carrying every variant's fields (required only where every variant requires them), so you set the fields of the variant you mean. oneOf: [X, {type: 'null'}] and 3.1's type: [X, 'null'] are just X.
  • A closed set of strings is an enum whose members carry the API's spelling with [ExternalName] where it differs — [ExternalName("application/pdf")] ApplicationPdf — so it reads and writes exactly what the API sends ([[json-serializer#enum-wire-names]]). It is [Type(string)], so a member whose name already IS the wire value goes out as that string too. An array of a closed set stays string[], with /// One of: `kg`, `g`, … on the field.
  • Every field says its wire name with [ExternalName("…")] when it differs from the field at all, case included — the serializer writes the declared name, as C#'s does.
  • Optional fields are nullable, arrays included, so an unset one is left out of the request rather than sent as null or [].
  • --depth (default 3) is how many levels of nested objects become classes; past it a field is Json.
  • A non-JSON success response answers byte[] (a PDF, an image) or string (text/*).
  • A parameter is camelCased by word: SHIPPO-API-VERSION → shippoApiVersion, Sendcloud-Partner-Id → sendcloudPartnerId.

Method names come from the operation's summary when it is a short phrase ("Create and announce a shipment synchronously" → CreateAndAnnounceShipmentSynchronously), or from its operationId minus the prefix every operationId in the spec shares (sc-public-v3-scp-post-announce_shipment_with_rules → PostAnnounceShipmentWithRules). An operationId written as an identifier (addPet) is the author's chosen name and is kept. Names are unique across the spec. The operationId is still what --list shows, --select takes, and a failure message quotes.

Use --list to see the available operations and tags before importing, and --json to preview the source it would generate without writing a file.

Examples#

// TWO declarations, and they are not the same thing: `use` in the manifest is the project-wide dependency, and
// `using` is what brings `Http`/`HttpResponse` into THIS file.
app Shop {
  model "model/**/*.osy";
  use Osysharp.Http;
}

using Osysharp.Http;

app.Secrets = [ new Secret("ShippingKey") ];

class TrackResult {
  string Status;
  string Location;
}

public class Tracking {
  public string ApiKey  = "";
  public string BaseUrl = "https://api.tracking.example";

  /// One parcel's whereabouts. `carrier` is a query parameter; `code` is a path segment, so it is escaped.
  public TrackResult Track(string code, string carrier) {
    return JsonSerializer.Deserialize<TrackResult>(
      Get("/track/" + Uri.EscapeDataString(code) + "?carrier=" + Uri.EscapeDataString(carrier)));
  }

  // ── the plumbing: the only place a URL, a header set or a failure is assembled ──
  string Get(string path) {
    var r = Http.Get(BaseUrl + path, Headers());
    if (!r.IsSuccess) { throw new ExternalServiceException(Failure("GET " + path, r)); }
    return r.Body;
  }

  Dictionary<string, string> Headers() {
    var headers = new Dictionary<string, string>();
    headers.Add("X-Api-Key", ApiKey);
    return headers;
  }

  /// 401 is the KEY, 403 is the PERMISSION — a message that does not say which costs the reader an hour.
  string Failure(string what, HttpResponse r) {
    var hint = r.StatusCode == 401 ? " — the key is wrong or expired; granting permissions will not help."
             : r.StatusCode == 403 ? " — the key is fine and lacks this permission; that is a consent change."
             : "";
    return what + " failed: " + r.StatusCode + " " + r.Body + hint;
  }
}
class ShipmentRequest {
  public string OrderCode;
  public string Address;
}

class ShipmentResult {
  public string TrackingNumber;
}

public class Shipping {
  public string ApiKey  = "";
  public string BaseUrl = "https://api.ship.example";

  public ShipmentResult CreateShipment(ShipmentRequest request) {
    return JsonSerializer.Deserialize<ShipmentResult>(Post("/shipments", JsonSerializer.Serialize(request)));
  }

  string Post(string path, string body) {
    var headers = new Dictionary<string, string>();
    headers.Add("X-Api-Key", ApiKey);
    var r = Http.Post(BaseUrl + path, body, "application/json", headers);
    if (!r.IsSuccess) { throw new ExternalServiceException("POST " + path + " → " + r.StatusCode + ": " + r.Body); }
    return r.Body;
  }
}

// One factory names the credential; the call site uses it ({#credential}).
Shipping Ship() { return new Shipping { ApiKey = Secret.ShippingKey }; }

string Book(string orderCode, string address) {
  var result = Ship().CreateShipment(new ShipmentRequest { OrderCode = orderCode, Address = address });
  return result.TrackingNumber;
}

⚑ A call needs no async and no await. (To have two requests in flight at once, start them from async methods without awaiting — async / await and Task.) The platform makes the request in place and resumes your function with the result; inside a workflow every outbound call is also wrapped in a durable step, so a crash-resume reuses the recorded response instead of sending a second time.

See also#

Related

Http.*

Make an outbound HTTP call to a URL you build at runtime — a webhook, a third-party API, a discovered endpoint…

HttpResponse

The result of an `Http.*` call. Carries the HTTP status code, the body as text AND as bytes, every response header, and…

Outbound calls in a test

A `[Test]` answers an outbound call from a stub — `Http.<Verb>.Stub(url => …)` — or, when the test says so with…

declaring secrets (app.Secrets)

`app.Secrets` declares the named secrets your app uses — API keys, tokens, client secrets. Each is `new…

reading a secret's value (Secret.Name)

`Secret.Name` in a function body evaluates to the declared secret's value — the key itself, as a string, read at the…

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…

async / await and Task

C#'s async and await, with C#'s meaning. A call to a Task-returning method that you do not await where you make it…

OAuth clients (app.OAuthClients)

`app.OAuthClients` declares the third-party OAuth providers your app uses — for signing users in (Login), for…

class methods

Behaviour attached to a class — a method with a receiver, called as value.Method(). Classes are in-memory values, so a…