ACE Proxy
Overview
ACE can expose an API route that forwards ("proxies") requests through to a flow without requiring every possible sub-path to be declared as its own API operation. This is done with a catch-all path parameter: a single API operation matches an entire family of incoming paths, and the unmatched tail of the URL is made available to the flow so it can build a downstream request from it (typically via a REST step).
Catch-all path parameters
A normal API operation path looks like /orders/{orderId} — each segment is either a literal or
a single named parameter. A catch-all path ends with a special token: {...name}. The three
dots mark it as "match everything from here to the end of the path, including slashes", and
name is the name it will be captured under.
path: /proxy/{...rest}
This matches /proxy/posts, /proxy/posts/42/comments, /proxy/anything/at/all, etc. It does
not match /proxy on its own — at least one segment after the prefix is required.
The captured value is a single string containing the full remaining path, slashes included. For example:
| Incoming request path | Captured value of rest |
|---|---|
/proxy/posts | posts |
/proxy/posts/42/comments | posts/42/comments |
Rules to keep in mind:
{...name}is only recognized as a catch-all when it's the last segment of the path. A path like/proxy/{...rest}/fixeddoes not behave as a catch-all.- More specific sibling routes always win over a catch-all at the same level. For example, if you
have both
/proxy/healthand/proxy/{...rest}registered, a request to/proxy/healthis routed to the specific operation, not the catch-all.
Catch-all operations are excluded from the generated OpenAPI/Swagger document, because there is
no standard OpenAPI syntax for "rest of path" (AWS's {proxy+} is a de facto, non-standard
convention). They still work at runtime — they're just not documented in the generated spec.
Document them here or in your own API reference instead.
The $request global
Every flow invoked through an API operation has access to a $request global variable in both
JSONata/mustache expressions and Code steps. Its shape is:
{
query: Record<string, unknown>; // parsed query string
pathParams: Record<string, unknown>; // named + catch-all path params
body: unknown; // raw request body
}
Example usage inside a step config (JSONata/mustache syntax):
url: "{{ $request.pathParams.rest }}"
query: "{{ $request.query.foo }}"
title: "{{ $request.body.title }}"
doc?doc is the flow's single working document. When a request comes in, ACE builds the initial
doc by flattening body, query, and path params into one object. If a query param and a body
field happen to share a name, one silently overwrites the other, and once that happens there's no
way to tell which source a given doc key came from.
$request is a separate, structured, read-only snapshot of the request kept specifically to avoid
that collision. Use $request.query.foo / $request.body.foo when you need to address the query
and body versions of the same key unambiguously — doc is fine for anything that doesn't have
this ambiguity, and for anything a later step in the flow needs to have mutated.
Suppressing HTTP errors on the REST step
The REST step (stepType: rest-new) has a suppressHttpErrors config
flag:
config:
suppressHttpErrors: true
- Default (
false): a non-2xx response from the downstream endpoint throws, which stops the flow with an error — you'd normally need a Catch step to handle it. true: a non-2xx response (e.g.404,500) is treated like a normal response. The downstream status/body/headers populateTarget path/Header pathas usual, and the flow continues to the next step, instead of stopping with an error.
This is the setting a passthrough proxy needs: whatever the backend returns, the flow should just forward it back, without explicit error handling for every possible status code.
Two cases are not affected by this flag, and still stop the flow with an error regardless:
- A true network-level failure (DNS resolution failure, connection refused/timed out) — there's no response to pass through in this case.
- A response configured to be saved as a file (
expectedResponseLocation: file) — a non-2xx response still stops the flow with an error even with the flag set.
Making an API route reachable through the proxy
An API route only becomes reachable if it's included in your workspace's routes
configuration (routes/*.yaml). Each entry maps a folder of API operations to a base path
prefix:
routes:
- apis: /
basePath: /
apis selects which API operation files (by their folder, under the workspace apis/ folder)
the entry applies to; basePath is prepended to each matched operation's path. An operation
whose location doesn't match any route entry is not routed at all — the designer will show
"API operation is not included in your routes configuration and can't be executed."
So, to expose a proxy route:
- Put the catch-all API operation under an
apis/folder that's covered by a route entry. - Point it at a flow that reads
$requestand forwards the request downstream.
Full example: generic reverse proxy
This is a complete, runnable example: one operation per HTTP verb, all pointing at the same flow, which forwards to a downstream service (injecting an API key header along the way) and relays the downstream's response back to the caller as-is — status code, body, and headers included.
It uses two steps: rest-new to call downstream (with suppressHttpErrors: true, so a downstream
4xx/5xx doesn't stop the flow), followed by a response step that writes the downstream's
status/body/headers straight back to the caller. Using response here — rather than just reading
the REST step's output at the end of the flow — is what makes this a transparent proxy: it lets you
short-circuit and return immediately with whatever the downstream sent, instead of needing the API
operation's own response schema to describe every possible passthrough shape.
API operations: one per verb, same path, same flow
path: /proxy/{...rest}
method: get
flow: proxy-flow.yaml
definition:
operationId: proxyGet
summary: "Generic proxy (GET)"
description: "Forwards to the configured downstream, injecting a fixed x-api-key header."
tags: []
parameters: []
responses: {}
path: /proxy/{...rest}
method: post
flow: proxy-flow.yaml
definition:
operationId: proxyPost
summary: "Generic proxy (POST)"
description: "Forwards to the configured downstream, injecting a fixed x-api-key header."
tags: []
parameters: []
responses: {}
apis/proxy-put.yaml and apis/proxy-delete.yaml follow the same shape (method: put /
method: delete, operationId: proxyPut / proxyDelete).
Flow: flows/proxy-flow.yaml
tags: []
sampleInputSchema: ""
sampleData: {}
description: "Forwards any method/path/query/body under /proxy/** to a downstream service, injecting a fixed x-api-key header."
steps:
- name: callDownstream
description: "Forward the request to the downstream service, injecting the API key."
stepType: rest-new
condition: ""
config:
endpoint:
url: "{{ 'http://localhost:4000/' + $request.pathParams.rest }}"
method: "{{ $api.method }}"
targetPath: upstreamBody
headerPath: upstreamHeaders
queryParams: "{{ $request.query }}"
headers: "{{ (() => { const h = {...$headers}; delete h['host']; delete h['content-length']; delete h['connection']; h['x-api-key'] = 'demo-fixed-api-key-12345'; return h; })() }}"
restRequest: "{{ ($api.method === 'GET' || $api.method === 'DELETE') ? 'None' : 'JSON' }}"
json: "{{ $request.body }}"
expectedResponseLocation: body
suppressHttpErrors: true
- name: respondToCaller
description: "Return the downstream's status/body/headers as-is."
stepType: response
condition: ""
config:
body: "{{ upstreamBody }}"
statusCode: "{{ upstreamHeaders.status }}"
headers: "{{ (() => { const h = {...upstreamHeaders.response}; delete h['content-length']; delete h['transfer-encoding']; delete h['connection']; return h; })() }}"
contentType: application/json
stopAPIExecution: true
A few things worth noting:
method: "{{ $api.method }}"— the flow doesn't hardcode a verb; it reads which verb the caller actually used from the$apiglobal, so the same flow serves all four operations above.- Incoming headers are forwarded, minus hop-by-hop ones.
$headersis spread into the outgoing request, buthost,content-length, andconnectionare stripped first — these describe the incoming connection and would be wrong (or actively break the outgoing request) if forwarded verbatim to the downstream. Anx-api-keyis then injected for downstream auth. - The body is only sent for verbs that have one.
restRequestis'None'forGET/DELETEand'JSON'otherwise, withjson: "{{ $request.body }}"— this is$request.body, notdoc, so it's exactly the raw incoming body regardless of what the flow may have done todoc. - Response headers are relayed back, minus hop-by-hop ones again.
content-length,transfer-encoding, andconnectionfrom the downstream's response are stripped in theresponsestep for the same reason — they describe that connection's framing and are invalid (or wrong) if passed straight back to the original caller. stopAPIExecution: trueon theresponsestep ends flow execution immediately after sending the response — required here since it's not the last step lexically in a template driven by "return whatever comes back", but the pattern is the same even when it is the last step.
Routes config: routes/routes.yaml
routes:
- apis: /
basePath: /
Trying it end to end
curl "http://localhost:5050/proxy/posts/42?foo=bar"
curl -X POST "http://localhost:5050/proxy/posts" -H "Content-Type: application/json" -d '{"title":"hello"}'
curl -X PUT "http://localhost:5050/proxy/posts/42" -H "Content-Type: application/json" -d '{"title":"updated"}'
curl -X DELETE "http://localhost:5050/proxy/posts/42"
curl "http://localhost:5050/proxy/deeply/nested/path?x=1&y=2"
Each of these is routed to proxy-flow.yaml, which sees (for the first request, for example):
$request.pathParams.rest === "posts/42"
$request.query.foo === "bar"
$api.method === "GET"
and forwards it to http://localhost:4000/posts/42?foo=bar with the injected x-api-key header,
relaying back whatever status/body/headers the downstream returns.