# a typed HTTP client (a class 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.

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

## Summary        {#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.*`](https://osysharp.com/reference/http/facade/). 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`](https://osysharp.com/reference/testing/outbound-calls/). A generated client is now source you
own rather than a shape the compiler interprets.

## Signature      {#signature}
```osy syntax
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    {#description}

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

| part | why it is that and not something else |
|---|---|
| a **`BaseUrl` field** | named once, and *settable*, so a caller can point the same client at a sandbox without a second class |
| a **credential field** | see [[#credential|below]] — this is what makes per-user auth one line of app source |
| one **public method per endpoint** | its signature is the API's contract in your own type system |
| a **private `Get`/`Post`** | the single place a URL, a header set, and a failure message are assembled |

### The plumbing is the point   {#plumbing}
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   {#params}
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   {#credential}
⭐ **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:

```osy title="whose credential it is — the app says, in one line" syntax
// 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)](https://osysharp.com/reference/function/secret-read/)). 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).

```osy title="one factory, and the credential stays in it" test app=http-client-factory
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); }
```

```osy title="…and the field is what cannot come back out" syntax
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)](https://osysharp.com/reference/function/secret-read/) and, for tokens the
platform acquires for you, [OAuth clients (app.OAuthClients)](https://osysharp.com/reference/config/oauth-clients/).

### A failure is DATA until your client decides otherwise   {#failure}
`Http.*` returns a 4xx/5xx as an `HttpResponse` with `IsSuccess` false — it does not throw ([HttpResponse](https://osysharp.com/reference/http/response/)). 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   {#generate}
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.

```bash title="generate a typed email client from a spec, just the email operations"
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`:

| `--auth` | the client sends | fields | secrets it tells you to declare |
|---|---|---|---|
| `bearer` | `Authorization: Bearer <ApiKey>` | `ApiKey` | `<Client>ApiKey` (or `--secret`) |
| `basic` | `Authorization: Basic base64(<Username>:<Password>)` | `Username`, `Password` | `<prefix>Username`, `<prefix>Password` — `--secret` is the prefix |
| `apiKey-header` / `apiKey-query` | the 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       {#examples}

```osy title="a typed client for an external tracking API" test app=http-client
// 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;
  }
}
```

```osy title="a POST with a JSON body, and the call site" test app=http-client
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](https://osysharp.com/reference/function/async-await/).) 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       {#see-also}
- [Http.*](https://osysharp.com/reference/http/facade/) — `Http.*` itself: every verb, its arguments, and streaming
- [HttpResponse](https://osysharp.com/reference/http/response/) — the `StatusCode` / `Body` / `IsSuccess` your plumbing branches on
- [Outbound calls in a test](https://osysharp.com/reference/testing/outbound-calls/) — `Http.<Verb>.Stub(…)` answers the call, so a client's URL and headers are assertable
- [reading a secret's value (Secret.Name)](https://osysharp.com/reference/function/secret-read/) — `Secret.Name`, and what `UserScoped` changes
- [OAuth clients (app.OAuthClients)](https://osysharp.com/reference/config/oauth-clients/) — when the platform should acquire the token rather than you holding one
- [class methods](https://osysharp.com/reference/class/methods/) — the class surface a client is built out of
- [publishing a REST API (app.Apis)](https://osysharp.com/reference/api/rest/) — the other direction: publishing *your* app as a REST API
