defmodule HttpDouble.Server do
@moduledoc """
OTP process that runs an HTTP server via Plug.Cowboy, holds static routes
and mock rules, and records HTTP call history.
Users normally interact with this module indirectly via the `HttpDouble`
facade, but the functions here are public to make supervision and advanced
control easier when needed.
"""
use GenServer
require Logger
alias HttpDouble.{MockEngine, MockRule, PlugAdapter, Request, Route}
@typedoc "Internal server mode."
@type mode :: :mock_first | :routes_only | :mock_only
@typedoc "Server state kept in the GenServer."
@type t :: %__MODULE__{
cowboy_ref: reference() | nil,
port: non_neg_integer() | nil,
ip: :inet.ip_address(),
host: String.t(),
mode: mode(),
routes: [Route.t()],
rules: [MockRule.t()],
calls: [HttpDouble.call_record()],
max_calls: pos_integer(),
next_conn_id: non_neg_integer()
}
defstruct cowboy_ref: nil,
port: nil,
ip: {127, 0, 0, 1},
host: "127.0.0.1",
mode: :mock_first,
routes: [],
rules: [],
calls: [],
max_calls: 1_000,
next_conn_id: 1
## Public API
@doc """
Starts a new server process.
Options:
* `:port` – TCP port to listen on (0 = random free port, default)
* `:ip` – IP tuple to bind to (defaults to `{127, 0, 0, 1}`)
* `:host` – logical host string reported by `HttpDouble.host/1` (defaults to `"127.0.0.1"`)
* `:routes` – list of static route definitions
* `:mode` – `:mock_first` (default), `:routes_only`, or `:mock_only`
* `:name` – optional registered name (e.g. via `Registry`)
"""
@spec start_link(Keyword.t()) :: GenServer.on_start()
def start_link(opts \\ []) do
name_opt =
case Keyword.get(opts, :name) do
nil -> []
name -> [name: name]
end
GenServer.start_link(__MODULE__, opts, name_opt)
end
@doc """
Returns a child spec suitable for supervision trees.
"""
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) when is_list(opts) do
%{
id: Keyword.get(opts, :id, __MODULE__),
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :temporary
}
end
@doc """
Registers a new stub or expectation rule.
This is the backing implementation for `HttpDouble.stub/2`, `stub/3`
and `HttpDouble.expect/3`.
"""
@spec add_rule(GenServer.server(), MockRule.kind(), Keyword.t()) ::
{:ok, reference()}
def add_rule(server, kind, opts) when kind in [:stub, :expect] do
GenServer.call(server, {:add_rule, kind, opts})
end
@doc """
Adds one or more static routes at runtime.
"""
@spec add_routes(GenServer.server(), [map()]) :: :ok
def add_routes(server, route_specs) do
GenServer.call(server, {:add_routes, route_specs})
end
@doc """
Replaces the full static route table.
"""
@spec set_routes(GenServer.server(), [map()]) :: :ok
def set_routes(server, route_specs) do
GenServer.call(server, {:set_routes, route_specs})
end
@doc """
Updates a single route identified by `:id` or by `{method, path}`.
"""
@spec update_route(GenServer.server(), map()) :: :ok
def update_route(server, route_spec) do
GenServer.call(server, {:update_route, route_spec})
end
@doc """
Deletes a route identified by `:id` or by `{method, path}`.
"""
@spec delete_route(GenServer.server(), map()) :: :ok
def delete_route(server, route_spec) do
GenServer.call(server, {:delete_route, route_spec})
end
@doc """
Returns the list of recorded calls, ordered from oldest to newest.
"""
@spec calls(GenServer.server()) :: [HttpDouble.call_record()]
def calls(server) do
GenServer.call(server, :calls)
end
@doc """
Resets the server state (routes, rules, history).
"""
@spec reset!(GenServer.server()) :: :ok
def reset!(server) do
GenServer.call(server, :reset)
end
@doc """
Returns the TCP port the server is listening on.
"""
@spec port(GenServer.server()) :: non_neg_integer()
def port(server) do
GenServer.call(server, :port)
end
@doc """
Returns the logical host string reported by the server.
"""
@spec host(GenServer.server()) :: String.t()
def host(server) do
GenServer.call(server, :host)
end
@doc """
Returns a convenient endpoint map `%{host, port, base_url}`.
"""
@spec endpoint(GenServer.server()) :: HttpDouble.endpoint()
def endpoint(server) do
GenServer.call(server, :endpoint)
end
@doc """
Handles a single request (Plug.Conn). Called from HttpDouble.Plug.
Reads the request body, converts to HttpDouble.Request, dispatches via the
server state, and returns a Plug.Conn response.
"""
@spec serve_plug_conn(Plug.Conn.t(), GenServer.server()) :: Plug.Conn.t()
def serve_plug_conn(conn, server) do
conn = Plug.Conn.fetch_query_params(conn)
{:ok, body, conn} = Plug.Conn.read_body(conn)
request = PlugAdapter.conn_to_request(conn, body, 0, 0)
resp_spec = GenServer.call(server, {:handle_request, request})
PlugAdapter.response_spec_to_conn(conn, resp_spec)
end
## GenServer callbacks
@impl GenServer
def init(opts) do
mode = Keyword.get(opts, :mode, :mock_first)
unless mode in [:mock_first, :routes_only, :mock_only] do
raise ArgumentError, "invalid :mode option: #{inspect(mode)}"
end
port = Keyword.get(opts, :port, 0)
ip = Keyword.get(opts, :ip, {127, 0, 0, 1})
host = Keyword.get(opts, :host, "127.0.0.1")
routes = Keyword.get(opts, :routes, []) |> Enum.map(&Route.from_spec/1)
cowboy_ref = make_ref()
# Pass registered name if any so the Plug can resolve current pid after restarts; else pass self().
# Optional :plug_server_resolver e.g. {Module, :fun} is called when whereis returns nil (e.g. cross-node).
plug_opts =
[server: Keyword.get(opts, :name) || self()]
|> maybe_add_resolver(opts)
case Plug.Cowboy.http(HttpDouble.Plug, plug_opts,
port: port,
ref: cowboy_ref,
ip: ip
) do
{:ok, _cowboy_pid} ->
# Do not link to Cowboy so that terminate/2 can shut it down without
# the Server receiving EXIT and exiting with :shutdown (which would
# kill linked test processes).
:ok
{:error, :eaddrinuse} ->
raise "HttpDouble: port #{port} already in use. Stop the process using it or choose another port."
{:error, reason} ->
raise "HttpDouble: failed to start Cowboy: #{inspect(reason)}"
end
actual_port = if port == 0, do: :ranch.get_port(cowboy_ref), else: port
Logger.info(
"[HttpDouble.Server] listening on #{host}:#{actual_port} mode=#{mode} routes=#{length(routes)}"
)
state = %__MODULE__{
mode: mode,
cowboy_ref: cowboy_ref,
port: actual_port,
ip: ip,
host: host,
routes: routes
}
{:ok, state}
end
@impl GenServer
def handle_call({:handle_request, %Request{} = req}, _from, state) do
conn_id = state.next_conn_id
request_index = length(state.calls)
req = %{req | conn_id: conn_id, request_index: request_index}
state = %{state | next_conn_id: conn_id + 1}
Logger.info(
"[HttpDouble.Server] request conn_id=#{conn_id} #{req.method} #{req.path} mode=#{state.mode}"
)
# Run dispatch in an unlinked task so route callbacks run in a different process. This avoids
# "process attempted to call itself" when the Cowboy/Hackney pool process would otherwise
# run code that uses the same pool. If the task crashes we return 500 and keep the Server alive.
{resp_spec, new_state, rule_id, route_id} =
case mockserver_control(req, state) do
nil ->
task =
Task.Supervisor.async_nolink(HttpDouble.TaskSupervisor, fn ->
dispatch_request(req, state)
end)
case Task.yield(task, 15_000) || Task.shutdown(task) do
{:ok, result} ->
result
{:exit, reason} ->
Logger.warning("[HttpDouble.Server] dispatch task exited: #{inspect(reason)}")
{%{status: 500, body: "Internal Server Error"}, state, nil, nil}
nil ->
Logger.warning("[HttpDouble.Server] dispatch task timeout")
{%{status: 504, body: "Gateway Timeout"}, state, nil, nil}
end
result ->
result
end
call_record = %{
conn_id: conn_id,
request: req,
timestamp: System.system_time(:millisecond),
rule_id: rule_id,
route_id: route_id,
action: resp_spec
}
calls = [call_record | new_state.calls] |> Enum.take(new_state.max_calls)
state = %{new_state | calls: calls}
{:reply, resp_spec, state}
end
@impl GenServer
def handle_call({:request, _conn_id, %Request{} = req}, _from, state) do
# Legacy path for custom TCP connections; same dispatch as handle_request.
Logger.info(
"[HttpDouble.Server] request conn_id=#{req.conn_id} #{req.method} #{req.path} mode=#{state.mode}"
)
{resp_spec, new_state, rule_id, route_id} = dispatch_request(req, state)
call_record = %{
conn_id: req.conn_id,
request: req,
timestamp: System.system_time(:millisecond),
rule_id: rule_id,
route_id: route_id,
action: resp_spec
}
calls = [call_record | new_state.calls] |> Enum.take(new_state.max_calls)
state = %{new_state | calls: calls}
{:reply, resp_spec, state}
end
@impl GenServer
def handle_call({:add_routes, route_specs}, _from, state) do
new_routes = Enum.map(route_specs, &Route.from_spec/1)
{:reply, :ok, %{state | routes: state.routes ++ new_routes}}
end
@impl GenServer
def handle_call({:set_routes, route_specs}, _from, state) do
new_routes = Enum.map(route_specs, &Route.from_spec/1)
{:reply, :ok, %{state | routes: new_routes}}
end
@impl GenServer
def handle_call({:update_route, route_spec}, _from, state) do
updated_routes = Route.update_in_list(state.routes, route_spec)
{:reply, :ok, %{state | routes: updated_routes}}
end
@impl GenServer
def handle_call({:delete_route, route_spec}, _from, state) do
remaining_routes = Route.delete_from_list(state.routes, route_spec)
{:reply, :ok, %{state | routes: remaining_routes}}
end
@impl GenServer
def handle_call({:add_rule, kind, opts}, _from, state) do
rule = MockRule.build(kind, opts)
{:reply, {:ok, rule.id}, %{state | rules: [rule | state.rules]}}
end
@impl GenServer
def handle_call(:calls, _from, state) do
{:reply, Enum.reverse(state.calls), state}
end
@impl GenServer
def handle_call(:reset, _from, state) do
new_state = %{
state
| rules: [],
routes: [],
calls: []
}
{:reply, :ok, new_state}
end
@impl GenServer
def handle_call(:port, _from, state) do
{:reply, state.port, state}
end
@impl GenServer
def handle_call(:host, _from, state) do
{:reply, state.host, state}
end
@impl GenServer
def handle_call(:endpoint, _from, state) do
base_url = "http://" <> state.host <> ":" <> Integer.to_string(state.port)
endpoint = %{
host: state.host,
port: state.port,
base_url: base_url
}
{:reply, endpoint, state}
end
@impl GenServer
def terminate(_reason, state) do
if state.cowboy_ref do
Plug.Cowboy.shutdown(state.cowboy_ref)
end
:ok
end
## MockServer-compat (PUT /expectation, PUT /mockserver/clear, PUT /reset, PUT /verify)
## Method is normalised so both atom :put (from Plug) and string "PUT" match.
defp mockserver_control(%Request{method: method, path: path}, state)
when path in ["/mockserver/clear", "/mockserver/clear/", "/clear", "/clear/"] do
if norm_method(method) == "PUT" do
# Clear rules and call history so verify (e.g. want=0) sees 0 calls after clear.
new_state = %{state | rules: [], calls: []}
{%{status: 200}, new_state, nil, nil}
else
nil
end
end
defp mockserver_control(%Request{method: method, path: path}, state)
when path in ["/reset", "/reset/", "/mockserver/reset", "/mockserver/reset/"] do
if norm_method(method) == "PUT" do
new_state = %{state | rules: [], calls: []}
Logger.info("[HttpDouble.Server] mockserver reset (rules and calls cleared)")
{%{status: 200}, new_state, nil, nil}
else
nil
end
end
# AuthUtils (JWKS) uses PUT /verify; TransFrameProductsClientUtils uses PUT /mockserver/verify. Same semantics.
defp mockserver_control(%Request{method: method, path: path, body: body}, state)
when path in ["/verify", "/verify/", "/mockserver/verify", "/mockserver/verify/"] do
if norm_method(method) == "PUT" do
case Jason.decode(body) do
{:ok, %{"httpRequest" => req_spec, "times" => times} = _full} ->
{want_path, path_regex?} =
case req_spec do
%{"pathRegex" => pat} when is_binary(pat) ->
{pat, true}
%{"path" => pat} when is_binary(pat) ->
{pat, path_has_wildcards?(pat)}
_ ->
{"/", false}
end
method = (req_spec["method"] || "GET") |> String.upcase()
# Support MockServer-style times: count/exact or atLeast+atMost (equal => exact).
want_count = times["atMost"] || times["atLeast"] || times["count"] || 1
exact =
(times["atLeast"] != nil and times["atMost"] != nil and
times["atLeast"] == times["atMost"]) or times["exact"] == true
{count, _re} =
state.calls
|> Enum.reduce({0, nil}, fn %{request: req}, {acc, re} ->
path_ok =
if path_regex? and is_binary(want_path) do
compiled =
case re do
%Regex{} = already -> already
_ -> Regex.compile!(want_path)
end
Regex.match?(compiled, norm_path(req.path))
else
norm_path(req.path) == norm_path(want_path)
end
if path_ok and norm_method(req.method) == method do
{acc + 1, if(path_regex?, do: re || Regex.compile!(want_path), else: re)}
else
{acc, re}
end
end)
status = verify_match(count, want_count, exact)
Logger.info(
"[HttpDouble.Server] verify #{method} #{want_path} count=#{count} want=#{want_count} exact=#{exact} -> #{status}"
)
{%{status: status}, state, nil, nil}
_ ->
nil
end
else
nil
end
end
defp mockserver_control(%Request{method: method, path: path, body: body}, state)
when path in [
"/expectation",
"/expectation/",
"/mockserver/expectation",
"/mockserver/expectation/"
] do
handle_expectation_control(method, body, state)
end
# Fallback for minor path variants (query string, accidental extra slash etc.)
# so /expectation never silently drops to route dispatch.
defp mockserver_control(%Request{method: method, path: raw_path, body: body}, state) do
path = raw_path |> to_string() |> normalize_control_path()
if path in ["/expectation", "/mockserver/expectation"] do
handle_expectation_control(method, body, state)
else
nil
end
end
defp mockserver_control(_, _), do: nil
defp handle_expectation_control(method, body, state) do
if norm_method(method) == "PUT" do
case Jason.decode(body) do
{:ok, raw_full} ->
# Support both string and atom keys in decoded payloads.
full = stringify_keys(raw_full)
req_spec = full["httpRequest"]
if is_map(req_spec) do
method = (req_spec["method"] || "GET") |> String.upcase()
path = req_spec["path"] || "/"
resp_spec = full["httpResponse"]
error_spec = full["httpError"]
drop_connection? =
case error_spec do
%{"dropConnection" => v} when v in [true, "true", 1] -> true
%{"drop_connection" => v} when v in [true, "true", 1] -> true
%{"dropConnection" => %{} = _} -> true
%{"drop_connection" => %{} = _} -> true
_ -> false
end
times = full["times"] || %{}
max_calls =
cond do
times["unlimited"] == true -> :infinity
r = times["remainingTimes"] -> coerce_remaining_times(r)
true -> :infinity
end
delay_ms_from_resp =
case resp_spec do
%{} ->
case resp_spec["delay"] do
%{"timeUnit" => unit, "value" => value} ->
ms =
case {unit, value} do
{"SECONDS", v} when is_number(v) -> trunc(v * 1000)
{"MILLISECONDS", v} when is_number(v) -> trunc(v)
_ -> 0
end
if ms > 0, do: ms, else: nil
_ ->
nil
end
_ ->
nil
end
matcher =
if path_has_wildcards?(path) do
%{
method: method,
path_regex: Regex.compile!(path)
}
else
%{
method: method,
path: path
}
end
respond_spec =
cond do
# MockServer's dropConnection should terminate the connection (transport error),
# not return a valid HTTP response.
drop_connection? or is_map(error_spec) ->
case delay_ms_from_resp do
nil -> :close
ms -> {:delay, ms, :close}
end
is_map(resp_spec) ->
status = resp_spec["statusCode"] || 200
body_obj = resp_spec["body"] || %{}
response_body =
if status >= 500 and status < 600 and body_obj == %{} do
"Service Unavailable"
else
mockserver_body_bytes(body_obj)
end
base_response = %{status: status, body: response_body}
case delay_ms_from_resp do
nil -> base_response
ms -> {:delay, ms, base_response}
end
true ->
nil
end
if respond_spec do
rule =
MockRule.build(:stub,
matcher: matcher,
respond: respond_spec,
max_calls: max_calls
)
new_state = %{state | rules: state.rules ++ [rule]}
status_for_log =
if drop_connection? do
":close(dropConnection)"
else
(resp_spec && (resp_spec["statusCode"] || 200)) || 200
end
Logger.info(
"[HttpDouble.Server] mockserver expectation added #{method} #{path} -> #{status_for_log}"
)
{%{status: 201}, new_state, nil, nil}
else
{%{status: 400, body: "Invalid expectation"}, state, nil, nil}
end
else
{%{status: 400, body: "Invalid expectation"}, state, nil, nil}
end
{:error, reason} ->
Logger.warning(
"[HttpDouble.Server] /expectation JSON decode failed: #{inspect(reason)}"
)
{%{status: 400, body: "Invalid expectation"}, state, nil, nil}
end
else
{%{status: 405, body: "Method Not Allowed"}, state, nil, nil}
end
end
defp normalize_control_path(path) when is_binary(path) do
path
|> String.split("?", parts: 2)
|> List.first()
|> then(&(&1 || "/"))
|> String.trim()
|> String.downcase()
|> String.replace(~r{/+}, "/")
|> String.trim_trailing("/")
|> then(&if &1 == "", do: "/", else: &1)
end
defp stringify_keys(%{} = map) do
Enum.into(map, %{}, fn {k, v} -> {to_string(k), stringify_keys(v)} end)
end
defp stringify_keys(list) when is_list(list) do
Enum.map(list, &stringify_keys/1)
end
defp stringify_keys(other), do: other
defp verify_match(count, want, true) when count == want, do: 202
defp verify_match(_, _, true), do: 417
defp verify_match(count, want, _) when count >= want, do: 202
defp verify_match(_, _, _), do: 417
defp norm_path(""), do: "/"
defp norm_path(p) when is_binary(p),
do: p |> String.trim_trailing("/") |> then(&if &1 == "", do: "/", else: &1)
defp norm_method(m) when is_atom(m), do: m |> Atom.to_string() |> String.upcase()
defp norm_method(m) when is_binary(m), do: m |> String.trim() |> String.upcase()
defp path_has_wildcards?(path) when is_binary(path) do
String.contains?(path, ".*")
end
defp path_has_wildcards?(_), do: false
defp maybe_add_resolver(plug_opts, opts) do
case Keyword.get(opts, :plug_server_resolver) do
{_mod, _fun} = resolver -> Keyword.put(plug_opts, :server_resolver, resolver)
_ -> plug_opts
end
end
defp coerce_remaining_times(n) when is_integer(n) and n >= 0, do: n
defp coerce_remaining_times(s) when is_binary(s) do
case Integer.parse(s) do
{n, _} when n >= 0 -> n
_ -> :infinity
end
end
defp coerce_remaining_times(_), do: :infinity
defp mockserver_body_bytes(%{"type" => "JSON", "json" => encoded}) when is_binary(encoded),
do: encoded
defp mockserver_body_bytes(%{"type" => "STRING", "string" => s}) when is_binary(s), do: s
defp mockserver_body_bytes(%{} = map), do: Jason.encode!(map)
defp mockserver_body_bytes(_), do: ""
## Dispatch (shared by handle_request and legacy :request)
defp inspect_status(%{status: s}), do: "status=#{s}"
defp inspect_status(other), do: inspect(other)
defp dispatch_request(req, state) do
case state.mode do
:routes_only ->
{route_resp, route_id} = dispatch_routes(state.routes, req)
Logger.info(
"[HttpDouble.Server] routes_only -> route_id=#{inspect(route_id)} resp=#{inspect_status(route_resp)}"
)
{route_resp, state, nil, route_id}
mode when mode in [:mock_first, :mock_only] ->
result =
MockEngine.apply(state.rules, req, req.conn_id, mode, fn ->
dispatch_routes(state.routes, req)
end)
state1 = %{state | rules: result.rules}
case result.response do
{:no_route, default} ->
{default, state1, nil, nil}
# Route fallback returns {response_spec, route_id} with a reference
{resp, route_id} when is_reference(route_id) ->
{resp, state1, result.rule_id, route_id}
# Matched mock rule: map or other response_spec (e.g. {:delay, ms, _}, :close, {:raw, _})
resp ->
{resp, state1, result.rule_id, nil}
end
end
end
@spec dispatch_routes([Route.t()], Request.t()) ::
{HttpDouble.response_spec(), reference() | nil}
defp dispatch_routes(routes, %Request{} = req) do
case Route.find_match(routes, req) do
{:ok, route, response_spec} ->
Logger.info(
"[HttpDouble.Server] route matched #{req.method} #{req.path} -> #{inspect_status(response_spec)}"
)
{response_spec, route.id}
:nomatch ->
Logger.warning(
"[HttpDouble.Server] no route for #{req.method} #{req.path} (#{length(routes)} routes), returning 404"
)
{%{status: 404, body: "Not found"}, nil}
end
end
end