Skip to content

Routing and Restrictions

brookie edited this page Oct 8, 2026 · 9 revisions

Routing and Restrictions

This page covers remote APIs: how a remote is configured, how API Dock forwards requests to it, and how to limit which routes and methods clients can reach. For cookies and secrets, see Authentication and Cookies. For database routes, see SQL Database Support.

# api_dock_config/config.yaml
name: my-api
remotes:
  - service1
# api_dock_config/remotes/service1.yaml
name: service1
url: https://remote.api.com

GET /service1/users/123 is now forwarded to https://remote.api.com/users/123/. Because no routes list is set, every route is allowed.

Remote config files

Each entry under remotes: in the main config names a file in remotes/, next to the main config file, or an entry in remotes/config.yaml (see below):

Main config entry Config read
- service1 remotes/service1.yaml, or service1 in remotes/config.yaml
- versioned_service every remotes/versioned_service/<version>.yaml, plus the versions of versioned_service in remotes/config.yaml

The URL slug is the file's name: (it falls back to the entry name if the file can't be read). For versioned remotes the version is the first path segment (/versioned_service/1.0/... or /versioned_service/latest/...). See Versioning.

Key Required Meaning
name yes Slug used in the URL (/<name>/...)
url yes Base URL of the upstream API. Without it, requests return 500
description no Notes for people reading the config. API Dock doesn't return it
routes no Allow-list of routes, plus custom route mappings
restricted no Block-list of routes and methods
query_params no Which query parameters are forwarded (see Query parameters)
cookies no Which client cookies to forward, and cookies to inject (see Authentication and Cookies)

The main config is read once at startup, so adding a remote to it needs a restart. Remote files and remotes/config.yaml are read again on every request, so edits to them take effect right away.

Defining remotes in remotes/config.yaml

Instead of one file per remote, remotes can be defined in one shared file. Each key under remotes is a remote name, and its value holds the same keys as a remote file:

# api_dock_config/remotes/config.yaml
remotes:
  weather:                            # unversioned: /weather/...
    url: https://api.weather.example.com
    restricted:
      - route: "*"
        method: delete
  core:                               # one version: /core/0.5.0/... and /core/latest/...
    version: "0.5.0"
    url: https://api.example.com
  wolf:                               # several versions; other keys are defaults
    description: Wolves API
    versions:
      - version: "1.0"
        url: https://api.wolves.example.com
      - version: "0.1"
        url: https://beta.api.wolves.example.com
# api_dock_config/config.yaml
remotes:
  - weather
  - core
  - wolf
  • The key is the URL name, so entries don't need a name:.
  • A remote file wins over an entry with the same name, and a version file (remotes/wolf/1.0.yaml) wins over the same inline version. Other versions from both places are combined. See Versioning.
  • Entries are served only when listed in the main config's remotes:.
  • Startup fails if the file is malformed or a listed entry has no url.
  • config is reserved: don't name a remote file remotes/config.yaml.
  • A remote's versions can come from a lookup: versions: {from: <lookup>, version: "{{row.version}}", url: "{{row.url}}"}. Lookup values in a url must match the lookup's allow prefixes.

How a request is forwarded

API Dock handles /<remote>/<path> for GET, POST, PUT, DELETE and PATCH. Other methods get a 405.

  1. Remote and version. An unknown remote returns 404. On a versioned remote, a missing or unknown version returns 404. A request to the bare remote (/versioned_service/) returns {"versions": [...]}.
  2. Allow and block lists. The path after the remote (and after the version) is checked against routes and restricted. A blocked route returns 403.
  3. Custom mapping. If a remote_route mapping matches, the path is rewritten.
  4. URL. The URL is url + / + path. With add_trailing_slash: true (the default), a trailing / is added when the path doesn't already end with one. A request to the remote root (/service1/) goes to url with no slash added.
  5. Query string. Query parameters are filtered by query_params. Repeated keys such as ?id=1&id=2 are forwarded with every value.
  6. Headers and body. All request headers are forwarded except Host and these hop-by-hop headers: Connection, Keep-Alive, Proxy-Authorization, TE, Trailers, Transfer-Encoding, Upgrade. Content-Length is also dropped and recalculated. Authorization is forwarded. The client's Cookie header is not: the upstream Cookie header holds only what the remote's cookies setting allows (none if it isn't set; see Authentication and Cookies). Before api_dock 0.10.0 the raw Cookie header was forwarded. The body is sent for POST, PUT and PATCH.

The upstream response comes back with its status code, body and headers, including 4xx and 5xx responses. API Dock drops hop-by-hop headers and Content-Length. Headers such as Cache-Control, ETag, Location and Content-Disposition are kept.

FastAPI (default) Flask
Response body Streamed to the client as it arrives Read fully into memory, then sent
Content-Encoding Kept: compressed bytes are passed through unchanged Dropped: the body is sent decompressed

Several upstream Set-Cookie headers reach the client as separate headers (before api_dock 0.10.0 they were joined into one).

Settings that affect proxying

These go under settings: in the main config.

Setting Default Effect
add_trailing_slash true Add a trailing / to forwarded paths. This avoids redirects from APIs that expect one. Set it to false for APIs that don't
follow_redirects true true: API Dock follows upstream redirects. false: the 3xx response and its Location are passed to the client, which is useful for presigned S3 URLs
timeout 10 Upstream timeout in seconds. null or false disables it. A timeout returns 502
base_path none Also serve the API under a prefix. See Python API and Deployment

Allow-list: routes

If a remote has a routes list, only matching routes are allowed. Everything else returns 403. Without a list, every route is allowed (subject to restricted).

routes:
  - users                         # /users, any method
  - users/{{user_id}}             # /users/123, any method
  - route: reports/{{}}
    method: get                   # GET only
  - route: reports/{{}}
    method: post                  # POST too
  • String entries allow every method. To limit methods, use dict entries with method:, or block methods with restricted.
  • A dict entry without method also matches every method. Methods are case-insensitive.
  • The root of a remote (/service1/) is always allowed.
  • A routes list in the main config is used for remotes that don't have their own. A remote's own routes replaces the main list (they aren't merged).

Block-list: restricted

restricted:
  - admin/*                       # admin and everything below it
  - users/{{}}/private            # one exact shape
  - route: "*"
    method: delete                # DELETE on every route

restricted in the main config and restricted in the remote file are combined: a route blocked by either is blocked. Restrictions are checked even when a route is in the allow-list. Main-config routes and restricted apply to remotes only. Database routes ignore them.

Pattern syntax

The same matching is used by routes, restricted and query_params:

Pattern Matches
users exactly users
users/{{user_id}}, users/{{}}, users/* users/ plus exactly one segment
admin/* (trailing /*) admin, admin/x, admin/x/y, ... (prefix match)
"*" (string) any single-segment route (foo, not foo/bar)
{route: "*", method: delete} every route, for that method

A segment matches only when the whole segment is a placeholder. user-{{id}} is a literal segment, not a placeholder.

Custom route mapping: remote_route

A dict entry with remote_route sends a matching request to a different upstream path:

name: legacy_api
url: https://old.example.com
routes:
  # 1. allow the incoming route (path without the remote name)
  - route: users/{{user_id}}/profile
    method: get
  # 2. map it; `route` starts with the remote name (or {{route_name}})
  - route: "{{route_name}}/users/{{user_id}}/profile"
    remote_route: legacy/user-info/{{user_id}}
    method: get

GET /legacy_api/users/42/profile is forwarded to https://old.example.com/legacy/user-info/42/.

Mappings currently have these rules (checked against config.py and by running them):

  • The mapping's route must start with the remote's name, either written out (legacy_api/users/...) or as {{route_name}}. A mapping written as users/{{user_id}}/profile never matches, and the request is forwarded unchanged.
  • The incoming route also needs its own allow entry (entry 1 above). Mapping entries live in routes, so the allow-list is always on, and a mapping entry alone doesn't allow the incoming path. Without entry 1, the request returns 403.
  • Use {{name}} (or {name}) in route and remote_route. Variables can be reordered: route: "{{route_name}}/search/{{category}}/{{term}}" with remote_route: api/search/{{term}}/in/{{category}} works.
  • remote_route can also use {{route_name}} and {{cookies.<name>}} (the request's cookie). Values are filled in once, so a value containing {{...}} isn't filled in again.
  • A query string in remote_route (v2/items?kind=a) is sent with the request, before the forwarded query parameters, and the trailing slash (with add_trailing_slash: true) goes on the path.
  • (Before api_dock 0.10.0, {{route_name}} / {{cookies.<name>}} in remote_route came out wrapped in braces, and a query string there was dropped.)
  • method: on a mapping limits which method is rewritten. Other methods allowed by the allow-list are forwarded unmapped.

Query parameters

By default every query parameter is forwarded. A query_params key on the remote, or on a dict route, filters them by name:

query_params: [page, limit]        # remote default: forward only these
routes:
  - users
  - route: search/{{term}}
    method: get
    query_params: [q]              # this route: forward only q
  - route: export
    query_params: false            # this route: forward none
Value Effect
not set forward all
true forward all
false forward none
list of names forward only those names (with all their values)

The first routes entry that matches the request decides. If it's a dict with query_params, that setting wins and replaces the remote-level one. Otherwise (no key, or a string entry), the remote-level query_params applies. Put route-level query_params on the allow entry, not on a remote_route mapping, because mapping patterns include the remote name and don't match here.

Database routes use a different query_params (SQL fragments). See Query Parameters.

Errors

Status When
403 Route blocked by routes or restricted: {"error": "Route 'x' not allowed for remote 'r'"}
404 Unknown remote, unknown version, or a path that isn't /<remote>/...
405 Method other than GET, POST, PUT, DELETE, PATCH
500 Remote file has no url
502 Upstream unreachable or timed out: {"error": "Error connecting to remote API"} (the reason is in the server log, not the response)
upstream's Any response the upstream sends, passed through unchanged

Clone this wiki locally