Skip to main content
Version: ACE 5

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 pathCaptured value of rest
/proxy/postsposts
/proxy/posts/42/commentsposts/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}/fixed does 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/health and /proxy/{...rest} registered, a request to /proxy/health is routed to the specific operation, not the catch-all.
note

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 }}"
Why not just use 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 populate Target path/Header path as 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.

note

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:

  1. Put the catch-all API operation under an apis/ folder that's covered by a route entry.
  2. Point it at a flow that reads $request and 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​

apis/proxy-get.yaml
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: {}
apis/proxy-post.yaml
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​

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 $api global, so the same flow serves all four operations above.
  • Incoming headers are forwarded, minus hop-by-hop ones. $headers is spread into the outgoing request, but host, content-length, and connection are stripped first — these describe the incoming connection and would be wrong (or actively break the outgoing request) if forwarded verbatim to the downstream. An x-api-key is then injected for downstream auth.
  • The body is only sent for verbs that have one. restRequest is 'None' for GET/DELETE and 'JSON' otherwise, with json: "{{ $request.body }}" — this is $request.body, not doc, so it's exactly the raw incoming body regardless of what the flow may have done to doc.
  • Response headers are relayed back, minus hop-by-hop ones again. content-length, transfer-encoding, and connection from the downstream's response are stripped in the response step for the same reason — they describe that connection's framing and are invalid (or wrong) if passed straight back to the original caller.
  • stopAPIExecution: true on the response step 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/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.