# 04 — HTTP Requests

Services make HTTP calls through a **shared `apiRequest` helper** declared once on the extension
(`createExtension({ apiRequest })`, see [01-service-structure.md](01-service-structure.md)). It centralizes
base URL, auth, envelope-unwrapping, error-normalization and redacted logging so individual handlers stay
declarative.

```js
apiRequest: async ({ url, method = 'get', body, query, context }) =>
  new Flowrunner.Request(`${ context.apiUrl }${ url }`, method, body)
    .set({ Authorization: `Bearer ${ context.apiKey }` })
    .query(query),
```

The constructor form takes the verb as a value, which is what you want when `method` arrives as an argument.
Avoid `Flowrunner.Request[method.toLowerCase()](url)`: an object lookup keyed by a variable resolves
inherited names such as `constructor` when the value is ever anything unexpected
([16-gotchas.md](16-gotchas.md)).

## `apiRequest` argument

Author-defined, but conventionally:

```
{ url, method = 'get', body, query, context, logger, logTag, ...custom }
```

- `context` is the return of `initContext` (see [01-service-structure.md](01-service-structure.md)) — the
  place to read resolved credentials/tokens.
- The helper is exposed in every handler's context bag as `apiRequest` (see [07-actions.md](07-actions.md)),
  and is also the natural building block for `helpers`.

The rest of this document is the client surface underneath that helper — `Flowrunner.Request`, the runtime
HTTP client, always on the global ([13-runtime-contracts.md](13-runtime-contracts.md)) and requiring no
import.

## Sending: the instance is thenable

Every chainable below returns the **same request instance**, and the instance has a `.then`. Nothing is sent
while you build the chain — **`await` is what issues the request**:

```js
await Flowrunner.Request.get(url)                       // sends
await Flowrunner.Request.post(url).set(headers)         // sends
await Flowrunner.Request.post(url).form(formData)       // sends
```

**Two methods are terminal instead: `.send(body?)` and `.toBuffer()`.** They issue the request immediately
and return a plain `Promise`, not the request — so nothing can be chained after them, and they must come
last:

```js
await Flowrunner.Request.post(url).set(headers).send(body)   // correct — .send() last
Flowrunner.Request.post(url).send(body).set(headers)         // TypeError: .set is not a function

await Flowrunner.Request.get(url).set(headers).toBuffer()    // correct — .toBuffer() last
```

By default the promise resolves to the **response body**. A non-2xx rejects — see *Failures*.

## Methods

| Static | Body argument |
|---|---|
| `Flowrunner.Request.get(url)` | – |
| `Flowrunner.Request.head(url)` | – |
| `Flowrunner.Request.options(url)` | – |
| `Flowrunner.Request.post(url, body?)` | ✓ |
| `Flowrunner.Request.put(url, body?)` | ✓ |
| `Flowrunner.Request.patch(url, body?)` | ✓ |
| `Flowrunner.Request.delete(url, body?)` | ✓ |
| `new Flowrunner.Request(url, method, body?)` | ✓ — verb as a value, either case |

The optional second argument is the body, identical to `.send(body)` except that it leaves the chain open:

```js
await Flowrunner.Request.post(url, { name: 'Bob' }).set(headers)   // body first, still chainable
await Flowrunner.Request.post(url).set(headers).send({ name: 'Bob' })
```

## Query parameters

`.query(obj)` appends to the URL's query string:

```js
await Flowrunner.Request.get('https://api.vendor.com/items')
  .query({ str: 'abc', num: 123, bool: true, list: [1, 2, 3] })
// → /items?str=abc&num=123&bool=true&list=1&list=2&list=3
```

- An **array repeats the key** (`list=1&list=2&list=3`); there is no bracket or comma form.
- `undefined` values are **dropped**; `null` is **serialized as the string `null`** (`nil=null`), which is
  almost never what an API wants. Strip empties before calling `.query()` — on `undefined`/`null` rather than
  on falsiness, because an empty string is a value ([16-gotchas.md](16-gotchas.md)).

> **Do not combine an inline query string with `.query()`.** The two are concatenated without normalizing the
> separator, so `Request.get('/p?a=1').query({ b: 2 })` puts **`/p?a=1?b=2`** on the wire — a second `?`
> instead of an `&`. Pass a bare path and put every parameter in `.query()`; a unit suite then reads `query`
> back as a parsed object ([15-unit-tests.md](15-unit-tests.md)).

## Headers

`.set()` takes either a name and a value, or an object, and may be called repeatedly:

```js
await Flowrunner.Request.get(url)
  .set('X-Api-Key', apiKey)
  .set({ Accept: 'application/json', 'X-Trace': traceId })
```

`.type(contentType)` is shorthand for setting `Content-Type`. One is set for you when the body is an object
(`application/json`) or a form (multipart with a generated boundary), so set it by hand only to override
that. `.basicAuth({ token })` and `.authKey(key)` are shorthands for the two common schemes; anything else is
an ordinary header.

## Request body

An object body is serialized as JSON:

```js
await Flowrunner.Request.post(url).send({ name: 'Bob', tags: ['a', 'b'] })
await Flowrunner.Request.post(url).type('text/plain').send('raw text')
```

### Forms and file uploads

`.form(data)` sets a form-data body — this is the multipart path, and there is no `.attach()`. It takes a
**plain object**, the **global WHATWG `FormData`** (the Node 18+ one, same as a browser's), or a
`Flowrunner.Request.FormData`. The plain object is the shortest and the one to reach for:

```js
await request.form({
  chat_id: String(chatId),                                        // text part
  photo  : new File([bytes], 'shot.png', { type: 'image/png' }),  // name + type in one go

  // the explicit form, when the value is a Buffer or a stream
  doc    : { value: bytes, options: { filename: 'report.pdf', contentType: 'application/pdf' } },
})                                              // note: awaited, not .send()
```

An array value repeats the key, one part per element. Whichever shape you pass, this is a Node-style
**streaming** multipart body underneath, and these are the values a part may hold:

| Value | What is sent |
|---|---|
| string, number, boolean | a text part — numbers and booleans are coerced to strings |
| `Buffer`, `Uint8Array` | a binary part; give it a name via the `{ value, options }` form |
| `File` | a binary part, **streamed** — the shortest way to send a named file: `filename` comes from its name and `contentType` from its type, so no options are needed. `size` sets `Content-Length`, so a large file is not held in memory twice |
| `Blob` | the same, but it has no name — every Blob part is sent as `filename="blob"`, and a Blob with no `type` as `application/octet-stream`. Use a `File`, or the `{ value, options }` form, when the filename matters |
| a stream | a binary part; pass `knownLength` in the options or the length cannot be computed |
| `{ value, options }` | any of the above plus explicit `filename` / `contentType` / `knownLength` — **these options win** over anything derived from a Blob. **Both keys must be present**: `{ value: bytes }` alone is read as an ordinary object and refused |

Pass `contentType` explicitly for a Buffer or a stream — omitted, one is derived from `filename` and you
inherit whatever that lookup returns (`a.bin` → `application/octet-stream`).

Anything else — a plain object, a class instance — is refused **with the field named**, before the body
starts streaming:

```
The form field "photo" can not be sent, expected a Buffer, a stream, a Blob or a primitive value,
received an instance of ReadableStream.
```

> **A `Blob` or a `File` goes to `.form()`, never onto a `Flowrunner.Request.FormData`.** That instance
> takes only a Buffer, a stream or a primitive, so `form.append('file', new Blob([bytes]), 'a.png')` throws
> **`source.on is not a function`**. Put it in the object you hand to `.form()`, or on a global `FormData`,
> and it is streamed for you.

Reach for `Flowrunner.Request.FormData` when you want the encoded body in hand: it exposes `getBoundary()`,
`getHeaders()`, `getBuffer()` and `getLength()`, which are how a test or a scratch check inspects what would
go out.

> **Never follow `.form(data)` with `.send()`.** `.form()` only stages the body; `.send()` is what issues the
> request, and a bare `.send()` issues it with **no body at all** — no `Content-Type`, no fields,
> `Content-Length: 0`. The vendor then answers as though no parameters were passed, which reads like a bug in
> your parameter mapping rather than in the transport.
>
> ```js
> await request.form(form)      // correct
> await request.send(form)      // also correct — .send() accepts the form
> request.form(form).send()     // WRONG — one empty request, silently
> ```

## Response handling

| Call | Effect |
|---|---|
| *(default)* | resolves to the response **body** |
| `.unwrapBody(false)` | resolves to `{ status, statusText, headers, body }` |
| `.toBuffer()` | **terminal** — sends, and resolves the body as raw bytes |
| `.setTimeout(ms)` | per-request client timeout |

Use `.unwrapBody(false)` when the status or a response header carries meaning — a pagination cursor in a
`Link` header, a `Retry-After`, a `204` that must be told apart from an empty body.

### Binary downloads

**`.toBuffer()` downloads bytes** — a PDF, an image, a video. It is terminal, so it comes last, and it
resolves a `Buffer` you pass straight on:

```js
const bytes = await Flowrunner.Request.get(fileUrl).set(headers).toBuffer()

await request.form({ file: new File([bytes], 'report.pdf', { type: 'application/pdf' }) })  // forward it
await files.uploadFile(bytes, { filename: 'report.pdf' })                                 // or store it
```

- The body is returned byte for byte, including when its content happens to be valid JSON.
- A response with no body — a `204`, a `Content-Length: 0` — resolves to an **empty buffer**, never `''`.
- A non-2xx rejects as everywhere else, with the *error* body parsed when it is JSON, so
  `error.status` and `error.body.message` read normally.
- `.unwrapBody(false)` composes: the whole response resolves and `body` is the bytes.

Use it for **every** binary download; do not reach for a response encoding.

Per platform policy **no execution timeout** is imposed around service code; `.setTimeout(ms)` is a
per-request client timeout, which is a different thing.

## Client certificates

An API that requires mutual TLS needs a client certificate on **every** call, including the OAuth2 token
request, so it belongs in `apiRequest` rather than in individual actions:

```js
apiRequest: async ({ url, method = 'get', body, query, context }) =>
  new Flowrunner.Request(`${ context.apiUrl }${ url }`, method, body)
    .cert({ cert: context.clientCert, key: context.clientKey })
    .query(query),
```

`.cert()` and its failure modes are [17-client-certificates.md](17-client-certificates.md); read it when the
vendor requires one.

## Failures

A non-2xx **rejects** with a `ResponseError`:

| Property | Value |
|---|---|
| `status` | HTTP status |
| `headers` | response headers |
| `body` | parsed response body |
| `message` | the response body when there is one — **so it is not always a string** — else `Status Code <status> (<statusText>)`. A 502 is `No connection with server`. |

Because `message` is the raw body for most JSON APIs, reading it as a string used to produce an unreadable
`[object Object]`. The platform now renders an unhandled failure as the provider's response body — the
Execute tab, a Run Block result and a flow's error exit all show that body rather than `[object Object]` —
so a service that does nothing still fails legibly.

Normalising it yourself in `apiRequest` is still worth doing, and is still the main reason `apiRequest`
exists: the raw body is whatever the vendor chose to send, often wrapped in an envelope and carrying fields
a flow author has no use for. A one-line `Error` naming the service and the actual reason reads better than
a dump of it, and you write it once here rather than per action.

A transport failure — a dropped connection, DNS, a refused socket — rejects with **no `status` and no
`body`**. Services routinely handle one kind and not the other; both are worth a test
([15-unit-tests.md](15-unit-tests.md)).
