The core request dispatcher for semantic.works stacks. Routes incoming HTTP requests to the appropriate microservice based on path, HTTP verb, Accept header, and hostname, using a configuration file written in Elixir.
This tutorial walks through adding mu-dispatcher to a mu-project and wiring up a backend API service alongside a frontend application.
Add the dispatcher to docker-compose.yml
dispatcher:
image: semtech/mu-dispatcher:2.1.0
volumes:
- ./config/dispatcher:/configCreate a configuration file in ./config/dispatcher/dispatcher.ex. This file must define an Elixir module named Dispatcher that uses the Matcher macro:
defmodule Dispatcher do
use Matcher
define_accept_types []
# routing rules go here
endExtend the file to route JSON API requests to a resource microservice and serve an Ember frontend for all other HTML requests:
defmodule Dispatcher do
use Matcher
define_accept_types [
html: [ "text/html", "application/xhtml+html" ],
json: [ "application/json", "application/vnd.api+json" ],
any: [ "*/*" ]
]
match "/themes/*path", %{ accept: %{ json: true } } do
forward conn, path, "http://resource/themes/"
end
match "/assets/*path", %{ accept: %{ any: true } } do
forward conn, path, "http://frontend/assets/"
end
match "/*_path", %{ accept: %{ html: true } } do
forward conn, [], "http://frontend/index.html"
end
match "/*_", %{ last_call: true, accept: %{ json: true } } do
send_resp(conn, 404, ~s|{ "error": { "code": 404, "message": "Route not found." } }|)
end
endStart the stack
docker compose up -d dispatcherThe dispatcher now accepts and forwards connections to the configured services.
Use forward to proxy a request to an upstream service. The second argument is the list of remaining path segments captured by the *path wildcard. Pass an empty list [] to forward to the base URL without appending anything.
# Forward /sessions and all sub-paths to the login service
match "/sessions/*path", _ do
forward conn, path, "http://login/sessions/"
end
# Forward a single fixed endpoint
match "/health", _ do
forward conn, [], "http://health/status"
endUse the verb-specific macros get, post, put, patch, delete, head, or options instead of match to restrict a rule to a single method:
post "/sessions", _ do
forward conn, [], "http://login/sessions"
end
delete "/sessions", _ do
forward conn, [], "http://login/sessions"
endDefine short aliases for groups of MIME types with define_accept_types, then use them in the accept option of a rule:
defmodule Dispatcher do
use Matcher
define_accept_types [
json: [ "application/json", "application/vnd.api+json" ],
img: [ "image/jpg", "image/jpeg", "image/png" ],
gif: [ "image/gif" ]
]
get "/images/*path", %{ accept: %{ json: true } } do
forward conn, path, "http://resource/images"
end
get "/images/*path", %{ accept: %{ img: true } } do
forward conn, path, "http://images/images"
end
get "/images/*path", %{ accept: %{ gif: true } } do
forward conn, path, "http://gifs/images"
end
endThe first matching rule wins, so order matters.
Use the host option to restrict a rule to a specific virtual host. A string value supports * wildcards. An array value lists domain parts in reverse order (TLD first) and allows capturing sub-domains as variables.
# Exact match
get "/employees", %{ host: "api.example.com" } do
forward conn, [], "http://employees/"
end
# Wildcard, matches api.example.com, dev.example.com, etc.
get "/employees", %{ host: "*.example.com" } do
forward conn, [], "http://employees/"
end
# Array match with variable capture
get "/employees", %{ host: ["com", "example", subdomain | rest] } do
IO.inspect(subdomain, label: "Subdomain")
IO.inspect(rest, "Array of subdomains under subdomain" )
forward conn, [], "http://employees/"
endThe array form requires at least the specified number of domain parts to be present, so ["com", "example", subdomain | _rest] will not match example.com (no subdomain).
Serve all HTML requests from the frontend, route assets through a dedicated path, and direct API calls to backend services:
defmodule Dispatcher do
use Matcher
define_accept_types [
json: [ "application/json", "application/vnd.api+json" ],
html: [ "text/html", "application/xhtml+html" ],
any: [ "*/*" ]
]
@html %{ accept: %{ html: true } }
@json %{ accept: %{ json: true } }
@any %{ accept: %{ any: true } }
# all backend dispatching rules here
# ...
# static assets
match "/assets/*path", @any do
forward conn, path, "http://frontend/assets/"
end
# SPA catch-all
match "/*_path", @html do
forward conn, [], "http://frontend/index.html"
end
match "/*_", %{ last_call: true, accept: %{ json: true } } do
send_resp( conn, 404, "{ \"error\": { \"code\": 404, \"message\": \"Route not found. See config/dispatcher.ex\" } }" )
end
endReturn the required CORS headers in response to browser preflight OPTIONS requests. Any 200 response makes the browsers accept those headers. Place this rule before any other rules that match the same paths:
options "*path", _ do
conn
|> Plug.Conn.put_resp_header("access-control-allow-headers", "content-type,accept")
|> Plug.Conn.put_resp_header("access-control-allow-methods", "*")
|> send_resp(200, "{ \"message\": \"ok\" }")
endUse last_call: true to define rules that only activate once no other rule has matched. Multiple fallback rules can serve different content types:
define_accept_types [
html: [ "text/html", "application/xhtml+html" ],
json: [ "application/json", "application/vnd.api+json" ],
text: [ "text/*" ],
png: [ "image/png" ]
]
get "/*_", %{ last_call: true, accept: %{ json: true } } do
send_resp(conn, 404, ~s|{ "error": { "code": 404, "message": "Route not found." } }|)
end
get "/*_", %{ last_call: true, accept: %{ html: true } } do
send_resp(conn, 404, "<html><body><h1>404 - Not found</h1></body></html>")
end
get "/*_", %{ last_call: true, accept: %{ text: true } } do
send_resp(conn, 404, "404 - page not found\n\nSee config/dispatcher.ex")
end
get "/*_", %{ last_call: true, accept: %{ png: true } } do
forward conn, [], "http://static/404.png"
endLayers partition route evaluation into ordered phases. All rules in the first layer are evaluated before any rule in the second layer is considered. Within a layer, top-to-bottom order still applies.
define_layers [ :api, :frontend ]
define_accept_types [
html: [ "text/html", "application/xhtml+html" ],
json: [ "application/json", "application/vnd.api+json" ]
]
match "/*path", %{ accept: %{ json: true }, layer: :api } do
forward conn, path, "http://resource/"
end
match "/*path", %{ accept: %{ html: true }, layer: :frontend } do
forward conn, path, "http://frontend/"
endThis is useful when API rules must take precedence over frontend catch-all rules regardless of how the rules are ordered in the file.
The rule body is plain Elixir, so you can log, inspect, or modify the connection before forwarding. Module attributes can consolidate repeated option maps:
@json %{ accept: %{ json: true } }
match "/sessions/*path", @json do
IO.inspect(conn, label: "conn for /sessions")
forward conn, path, "http://sessions/login"
end
match "/images/*path", @json do
forward conn, path, "http://resource/images"
endThe following environment variables can be configured on the service.
| Variable | Description | Default |
|---|---|---|
MAX_URL_LENGTH |
Maximum permitted URL length in bytes. Requests with a longer URL are rejected by the HTTP server before reaching the dispatcher. | 10000 |
IDLE_TIMEOUT |
Idle connection timeout in milliseconds. Connections that carry no traffic for this duration are closed. | 300000 |
LOG_BACKEND_COMMUNICATION |
Log raw communication with upstream services (true/false). |
false |
LOG_FRONTEND_COMMUNICATION |
Log raw communication with downstream clients (true/false). |
false |
LOG_FRONTEND_PROCESSING |
Log request processing steps (true/false). |
false |
LOG_BACKEND_PROCESSING |
Log response processing steps (true/false). |
false |
LOG_CONNECTION_SETUP |
Log connection setup details (true/false). |
false |
LOG_REQUEST_BODY |
Log request bodies (true/false). |
false |
LOG_RESPONSE_BODY |
Log response bodies (true/false). |
false |
LOG_AVAILABLE_LAYERS |
Log the set of available layers on each request (true/false). |
false |
LOG_LAYER_START_PROCESSING |
Log when a layer starts being evaluated (true/false). |
false |
LOG_LAYER_MATCHING |
Log whether each layer produced a match (true/false). |
false |
Boolean environment variables accept true, yes, 1, or on (case-insensitive) as truthy values.
All LOG_* variables are experimental and only there for debugging purposes. They may be added or removed in future non-major releases.
The dispatcher is configured by an Elixir module placed at /config/dispatcher.ex (mounted in the Docker container). The module must be named Dispatcher and must use the Matcher macro.
defmodule Dispatcher do
use Matcher
define_accept_types []
# routing rules
enduse Matcher imports the matcher and makes forward, send_resp, and all route macros available inside the module.
Declares short aliases that map to lists of MIME types. Wildcards (*) are permitted.
define_accept_types [
json: [ "application/json", "application/vnd.api+json" ],
html: [ "text/html", "application/xhtml+html" ],
any: [ "*/*" ]
]Accept type aliases are used in route options as %{ accept: %{ json: true } }.
Raw MIME type matching is fragile. A browser typically sends text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 in a single Accept header, and a JSON:API client may send application/vnd.api+json while a rule was written for application/json. The define_accept_types mechanism decouples the MIME types that clients send from the identifiers used inside routing rules. A rule tagged with %{ accept: %{ json: true } } fires for any request whose best acceptable MIME type overlaps with the list declared under json:, regardless of how specific or generic the client's header is.
Declares an ordered list of evaluation layers. Rules are assigned to a layer with the layer: option.
define_layers [ :api, :frontend ]Layers are used in route options as %{ layer: :frontend }.
Order matters: layers listed first are evaluated first.
Without layers, rules are evaluated strictly from top to bottom and the first match wins. Layers partition this evaluation into phases. All rules in the first layer are tried before any rule in the second layer is considered. Within each layer top-to-bottom order still applies. This is useful when you need to guarantee that API rules always take precedence over frontend catch-all rules even when the configuration file interleaves them, or when different teams contribute independent sets of rules that need to compose predictably.
The following macros match an incoming request. The first matching rule in evaluation order wins.
| Macro | Matched HTTP methods |
|---|---|
match |
all |
get |
GET |
post |
POST |
put |
PUT |
patch |
PATCH |
delete |
DELETE |
head |
HEAD |
options |
OPTIONS |
Syntax
<verb> <path_pattern>, <options> do
# body — plain Elixir with access to `conn` and captured `path`
endpath_pattern: a string such as"/themes/*path". The*pathsegment captures all remaining path components as a list into the variablepath. Use*_or*_nameto capture and discard remaining segments.options: an Elixir map or_to match unconditionally.conn: the Plug.Conn struct, available inside the body.path: the captured path list, available inside the body when*pathis used in the pattern.
Route options
| Key | Type | Description |
|---|---|---|
accept |
%{ json: true } |
Only match if the request Accept header includes a MIME type covered by the given alias from define_accept_types. |
host |
String or [String] |
Only match if the request Host header satisfies the given value. String form supports * wildcards. Array form lists domain parts in reverse order (TLD first) and allows variable capture. |
layer |
atom | Assign this rule to the named layer defined with define_layers. |
last_call |
true |
Only evaluate this rule after all non-last_call rules have failed to match. Used for fallback and 404 responses. |
Request forwarding is built on top of plug_mint_proxy, which uses the Mint library to make efficient upstream HTTP connections. Incoming connections are accepted by Cowboy 2, which supports both HTTP/1.1 and HTTP/2. The dispatcher is wired into the Plug pipeline in plug_router_dispatcher.ex.
Proxies the request to an upstream URL and streams the response back.
forward conn, path, "http://service/base-path/"conn: the connection struct.path: list of path segments to append to the base URL. Pass[]to forward to the base URL without appending anything.url: base URL of the upstream service (string). Make sure theurlends with a trailing slash/if path segments will be appended.
Sends an immediate response without forwarding to an upstream service.
send_resp(conn, status_code, body)Standard Plug.Conn.send_resp/3.
The dispatcher knows about certain header manipulations to smoothen out configuration. These are configured using plug_mint_proxy's manipulators as seen in the Proxy module.
| Manipulator | Applied to | Description |
|---|---|---|
AddXRewriteUrlHeader |
Outgoing request | Adds x-rewrite-url containing the original request URL so backend services can reconstruct it if needed. |
RemoveAcceptEncodingHeader |
Outgoing request | Strips accept-encoding because compression is handled at the edge and must not be negotiated by backend services. |
AddVaryHeader |
Incoming response | Adds Vary: accept, cookie so intermediate caches consider both the Accept header and session cookie when storing responses. |
Incoming requests are matched against routing rules as follows:
- Parse the request
Acceptheader and group MIME types by quality score (highest first). - For each quality group, determine which
define_accept_typesaliases overlap with the requested MIME types. - Work through each alias and try every rule (in definition order, within the current layer) that matches the path, host, verb, and that alias. Return on the first match.
- If layers are defined, advance to the next layer and repeat step 3.
- If no rule matched, repeat steps 2-4 considering only rules with
last_call: true. - If still no match, return a 500 response.