HTTP Requests¶
Flowrunner.Request is the HTTP client the runtime provides. It resolves inside any handler with no
require and nothing in the service's package.json.
Most of the time you use it once, inside apiRequest, and every action goes through that.
Nothing is sent until you await it¶
Every chainable method returns the same request instance, and the instance is thenable. Building the chain
sends nothing; await issues the request:
.send(body?) and .toBuffer() are the exceptions: they are terminal. Each issues the request
immediately and returns a plain promise, so nothing chains after it:
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
By default the promise resolves to the response body.
The verbs¶
| Call | Takes a body |
|---|---|
Flowrunner.Request.get(url) |
no |
Flowrunner.Request.head(url) |
no |
Flowrunner.Request.options(url) |
no |
Flowrunner.Request.post(url, body?) |
yes |
Flowrunner.Request.put(url, body?) |
yes |
Flowrunner.Request.patch(url, body?) |
yes |
Flowrunner.Request.delete(url, body?) |
yes |
new Flowrunner.Request(url, method, body?) |
yes, with the verb as a value |
Use the constructor form when the method arrives as an argument, which is the normal case inside a
shared apiRequest:
Do not write Flowrunner.Request[method.toLowerCase()](url). An object lookup keyed by a variable resolves
inherited names, so a method of constructor resolves to the global Function, and the call throws
SyntaxError: Unexpected token '}' - an error that points nowhere near the cause.
The optional second argument is the body, identical to .send(body) except that it leaves the chain open.
Query parameters¶
.query(obj) appends to the query string:
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. There is no bracket or comma form.
undefinedvalues are dropped;nullis serialized as the stringnull, which is almost never what an API wants. Strip empty values before calling.query(), and strip onundefined/nullrather than on falsiness, because'',0andfalseare all legitimate.
function prune(query) {
return Object.fromEntries(
Object.entries(query ?? {}).filter(([, value]) => value !== undefined && value !== null)
)
}
Never 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().
Headers¶
.set() takes a name and a value, or an object, and may be called repeatedly:
.type(contentType) is shorthand for Content-Type, though one is set for you when the body is an object
(application/json) or a form (multipart with a generated boundary). .basicAuth({ token }) sets
Authorization: Basic <token> as given, so pass it the base64 of user:password yourself. .authKey(key)
sets a header named auth-key, which few APIs read. A Bearer token, an X-Api-Key or any other scheme is an
ordinary .set() header.
Bodies¶
An object body is serialized as JSON:
await Flowrunner.Request.post(url).send({ name: 'Bob', tags: ['a', 'b'] })
await Flowrunner.Request.post(url).type('text/plain').send('raw text')
For a multipart upload, .form(data) sets a form-data body, built with
Flowrunner.Request.FormData:
const form = new Flowrunner.Request.FormData()
form.append('title', 'Holiday photo') // text part
form.append('photo', buffer, { // binary part
filename : 'shot.png',
contentType: 'image/png',
})
await request.form(form)
This is a Node-style streaming FormData, not the browser one: values may be a string, Buffer or Stream, and
a binary part takes its name and type from the third argument. Pass contentType explicitly - omitted, one
is derived from the filename and you inherit whatever that lookup returns.
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.
Reading the response¶
| Call | Effect |
|---|---|
| (default) | resolves to the response body |
.unwrapBody(false) |
resolves to { status, statusText, headers, body } |
.toBuffer() |
sends the request and resolves the body as raw bytes, a Buffer. Like .send(), it comes last |
.setTimeout(ms) |
a per-request client timeout |
Use .unwrapBody(false) when the status or a header carries meaning: a pagination cursor in a Link
header, a Retry-After, a 204 that has to be told apart from an empty body.
Download a file with .toBuffer(). A PDF, an image or a video has to arrive as bytes, and a plain
await hands a binary body back as a string, which corrupts it. .toBuffer() resolves a Buffer you can
pass straight on - to a multipart upload, or to the Files API:
The body comes back byte for byte, even when it happens to be valid JSON, and an empty body is an empty
Buffer.
.setTimeout(ms) sets a per-request client timeout. It does not bound how long your handler runs.
Failures¶
A non-2xx rejects with a ResponseError:
| Property | Value |
|---|---|
status |
the HTTP status |
headers |
the response headers |
body |
the parsed response body |
message |
the body's own message when the body is an object that has one (code is then the body's code). Otherwise the response body when there is one, so it is not always a string. Otherwise Status Code <status> (<statusText>). A 502 always reads No connection with server. |
An unhandled failure shows that message on the block's error output - for a JSON API whose error has no
message field, the whole body. Turning it into one line a flow builder can act on is the main reason
apiRequest exists:
} catch (error) {
const failure = new Error(`TMDB ${ error?.status ?? '?' }: ${ describeFailure(error) }`)
failure.status = error?.status // dictionaries need this to tell 404 from 401
throw failure
}
function describeFailure(error) {
const body = error?.body
if (body && typeof body === 'object') {
return body.status_message ?? JSON.stringify(body)
}
return typeof error?.message === 'string' ? error.message : 'request failed'
}
A transport failure - a dropped connection, DNS, a refused socket - rejects with no status and no
body. Cover both shapes in your tests.
Mutual TLS¶
An API requiring a client certificate needs it on every call, so it belongs in apiRequest:
apiRequest: async ({ url, method = 'get', body, query, context }) =>
new Flowrunner.Request(`${ context.baseUrl }${ url }`, method, body)
.cert({ cert: context.clientCert, key: context.clientKey })
.query(query),
Related¶
- Service Structure - where
apiRequestis declared and what it receives - Actions - returning the provider's response to the flow
- Testing - asserting what your service actually sent