Skip to content
Back to skills

Api

ASecurity

How to use @owlmeans/api — the axios-based HTTP client service that carries entrypoint calls between services, its status-to-outcome mapping, typed transport errors that keep the HTTP status and incident id (the ./status subpath), and per-request timeout/abort. Auto-invoked when importing the API client, reading a failed call's status, or when ctx.entrypoint(...).call() under the hood is involved.

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 22, 2026
developmenttypescripttestingapisecurity

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 6, 2026

npx -y skills add owlmeans/common --skill api --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Api?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Api
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/owlmeans-api-common/badge)](https://www.skillsdirectory.com/skills/owlmeans-api-common)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: api
description: How to use @owlmeans/api — the axios-based HTTP client service that carries entrypoint calls between services, its status-to-outcome mapping, typed transport errors that keep the HTTP status and incident id (the ./status subpath), and per-request timeout/abort. Auto-invoked when importing the API client, reading a failed call's status, or when ctx.entrypoint(...).call() under the hood is involved.
user-invocable: false
---
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->

# @owlmeans/api

**Layer:** Core
**Install:** `"@owlmeans/api": "^0.1.18-rc.48"` in `dependencies`

## Key Exports

| Export | Description |
|--------|-------------|
| `createApiService(alias?)` | Factory for the HTTP client service (axios) |
| `appendApiClient(ctx, alias?)` | Register it and make it the context's default `webService` when none is set |
| `ApiClient` | Service interface — a single `handler(req, reply)` |
| `ApiError`, `ApiClientError`, `ServerCrashedError`, `ServerAuthError`, `ApiStatusError` | Typed transport errors; an `ApiClientError` carries the answered `status` and the server's `incidentId` |
| `apiStatusHelper` (`httpStatusOf`, `incidentIdOf`, `isAccessDenied`, `isIncidentBody`), `parseClientMarker`, `API_CLIENT_MARKER`, `API_STATUS_MARKER` | Read a failure's HTTP status, incident id and IAM denial marker (also the `./status` subpath) |
| Constants | Status codes (`OK`, `CREATED`, `ACCEPTED`, `FINISHED`, `UNAUTHORIZED_ERROR`, `FORBIDDEN_ERROR`, `SERVER_ERROR`), `INCIDENT_ID_HEADER` (`X-Incident-ID`), `DEFAULT_ALIAS` (`web-client`) |

Subpath `./status` — `apiStatusHelper` (`httpStatusOf`, `incidentIdOf`, `isAccessDenied`,
`isIncidentBody`), `parseClientMarker`, the markers and `INCIDENT_ID_HEADER`, importing only
`@owlmeans/error`: a browser package reads a status without pulling axios in.

`apiStatusHelper.isAccessDenied(error)` requires the server's `X-OwlMeans-Denial: access-denied`
marker and a 403. Do not classify all 403 responses as IAM denials: entitlement and other refusals
use that status too. The API client stamps the marker on its rejection even under production error
exposure.

## How a call is carried

`ep.call(...)` / `ep.invoke(...)` hand the request to whatever is bound to the route's protocol. When
a transport service is registered under `transportAlias(protocol)` it takes the call; otherwise this
package's client does, over HTTP. Either way the consumer writes the same line and never learns which
one ran.

When it is this client, the entrypoint answers where it lives — `path()` for the path, `address()` for
host, port, base, protocol and whether the hop is TLS — and `makeSecurityHelper` assembles the URL from
that. A per-request `host`, `base` or `unsecure` still overrides it. `:params` come from
`request.params` and a missing one is a `SyntaxError`, not a literal `:id` in the URL.

A request whose `canceled` flag is already set is dropped without a round trip.

## Per-request controls

These live on the request the caller passes and are forwarded to the transport:

- `timeout` — milliseconds for the single round trip; omitted or `0` means no timeout, so a stuck
  peer hangs forever unless a caller bounds it.
- `signal` — an `AbortSignal`, which aborts the request in flight.
- `headers` — a `content-type` of `application/x-www-form-urlencoded` makes the body serialize
  through `qs` instead of JSON; any other `content-type` the caller sets is kept.

## Request bodies

The body travels as JSON the server parses back to the value the caller passed:

| Body | On the wire |
|---|---|
| Object or array | JSON, serialized by axios — as always |
| String, number or boolean on a `POST` with no `content-type` | `content-type: application/json`, `JSON.stringify`'d (`'abc'` → `"abc"`, `42`, `true`) |
| String, number or boolean under a JSON `content-type` (`application/json`, `*+json`), any method | `JSON.stringify`'d |
| A string that already IS JSON text (`'{"a":1}'`, `'"abc"'`) | Sent as it is — a caller that serialized it itself keeps working |
| Any scalar under another `content-type` (`text/plain`), or with none on a non-`POST` | Untouched |

Under a body schema of `type: 'string'` (the protocol's contract, read from the entrypoint's
`filter.body`) a string is always a value: `'123'`, `'true'` or `'{…}'` arrive as those strings,
and only a JSON string literal counts as pre-serialized. Without a string contract a string that
parses as JSON is taken as serialized, so send a plain string that looks like JSON through a
`type: 'string'` contract. The caller's `headers` object is never rewritten. A bare unquoted string
under `application/json` is invalid JSON — `@owlmeans/server-api` answers it 400 — which is why it
is never sent.

## Reading the answer

`validateStatus` is `() => true`, so **no status throws**: every answer the peer sends, 4xx and 5xx
included, comes back and is mapped here.

| Status | Result |
|--------|--------|
| 200 / 202 | Resolves with the body and an `Ok` / `Accepted` outcome |
| 201 / 204 | Resolves with the body, or with the response headers when the body is empty |
| any other | Rejects |

Every rejection keeps the HTTP status and, when the server sent one, its incident id (the
`X-Incident-ID` header, read from `AxiosHeaders` or a plain object, else a bare UUID body — a
browser reads that header cross-origin only when the server exposes it, the body covers the rest):

| Body | Rejects with |
|---|---|
| Development — the marshaled `ResilientError` (carries the separator) | The original class, rebuilt through the registry, stamped `responseStatus` and `incidentId` |
| Production — a bare incident UUID; also proxy HTML, framework JSON, anything else | `ServerCrashedError` `api:client:crashed:<id>` (500) · `ServerAuthError` `api:client:auth:<id>` (401) · `ApiClientError` `api:client:forbidden[:<id>]` (403) · `ApiStatusError` `api:client:status:<n>[:<id>]` (every other status) |

An `@owlmeans/server-api` boundary in production exposure sends ONLY the incident id, so a typed
refusal's class never reaches the browser — its status does. Read it with
`apiStatusHelper.httpStatusOf(e)`: the stamped `responseStatus`, else an `ApiClientError`'s parsed
`status`, else a class's declared `httpStatus` (a 4xx; a 5xx only with `allowServerErrorStatus`,
exactly what the server answers), else an `api:client:*` marker in the message or type; `null` when
nothing states one. A consumer that acts on a refusal checks the class/marker AND the status
(`consentRefusalOf(e) || apiStatusHelper.httpStatusOf(e) === 428`). Markers without an id stay as
they were (`api:client:crashed:error`, `api:client:forbidden`). The status and id are rebuilt from
the marker in `finalizeUnmarshal()`, so they survive a marshal. No class of this family declares a
static `httpStatus`: a server that rethrows one still answers 500.

**A failure with no answer at all is a different family.** Suppressing status errors does not wrap
the call: an expired `timeout` (`ECONNABORTED`), an aborted `signal` (`CanceledError` /
`ERR_CANCELED`), a refused connection or a DNS failure rejects with the raw axios error, which is
none of the typed errors above. A caller that bounds a call has to catch that too — testing for
`ApiClientError` alone lets a timeout through as an unhandled rejection.

An `auth-token-refresh` response header is consumed here: when the context has an auth service, the
rotated token is handed to it, which is what keeps a long session alive without the caller doing
anything.

Building a URL without making the call is a different question — that is
`apiCallOf(ref).entrypointUrl(req, opts)` from `@owlmeans/client-entrypoint/utils`, or
`ep.url(req, { absolute })`.

## Usage

Most apps don't import this directly — they register it once in their context factory:

```typescript
import { appendApiClient } from '@owlmeans/api'
appendApiClient(context)
```

`appendApiClient` only claims `cfg.webService` when it is unset, so an app that has already named a
transport keeps it.

## Depends On

- `@owlmeans/context` — service registration
- `@owlmeans/entrypoint` — the entrypoint being addressed, `@owlmeans/client-route` — `clientRouteHelper.extractParams`
- `@owlmeans/config` — `makeSecurityHelper`, `@owlmeans/client-config` — the config shape
- `@owlmeans/auth-common` — the token-refresh header and the auth service it updates
- `@owlmeans/error`, `@owlmeans/route`
- `axios`, `qs` (runtime)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…