defmodule HttpDouble do
@moduledoc """
Public API for starting and controlling dummy HTTP servers for integration tests.
The main entry points are:
* `start_link/1` – start a new HTTP dummy server
* `child_spec/1` – embed the server directly in your supervision tree
* `stub/2` and `stub/3` – define stub rules for HTTP requests
* `expect/3` – define expectation rules for HTTP requests
* route management helpers – `add_route/2`, `add_routes/2`, `set_routes/2`,
`update_route/2`, `delete_route/2`
* `calls/1` – fetch the history of received requests
* `reset!/1` – reset server state between tests
* `host/1`, `port/1`, `endpoint/1`, `url/2` – discover where the server is listening
* `stop/1` – stop the server
**Documentation:** [README on GitHub](https://github.com/aszymanskiit/http_double/blob/main/README.md)
and [HexDocs](https://hexdocs.pm/http_double).
"""
@typedoc "A server reference; can be a PID or a registered name."
@type server :: GenServer.server()
@typedoc """
Matcher used to select which HTTP requests should be affected by a stub/expectation.
Supported shapes:
* `:any` – match any request
* `{:method, method}` – e.g. `{:method, :get}` or `{:method, "POST"}`
* `{:path, path}` – exact path match, e.g. `{:path, "/health"}`
* `{:prefix, prefix}` – prefix path match, e.g. `{:prefix, "/api"}`
* `{:path_regex, re}` – regular expression on the request path
* `{:route, method, path}` – method + path match
* `{:fn, fun}` – predicate `fun.(request, meta) :: boolean`
* a map with keys like `:method`, `:path`, `:host`, `:query`, `:headers`, `:body`
that will be interpreted by `HttpDouble.MockRule`
"""
@type matcher ::
:any
| {:method, atom() | String.t()}
| {:path, String.t()}
| {:prefix, String.t()}
| {:path_regex, Regex.t()}
| {:route, atom() | String.t(), String.t()}
| {:fn, (HttpDouble.Request.t(), HttpDouble.MockRule.meta() -> boolean())}
| map()
@typedoc """
Response specification used by stubs, expectations and static routes.
Supported forms:
* simple maps:
* `%{status: 200, body: "ok"}`
* `%{status: 201, json: %{result: "accepted"}}`
* `%{status: 204}` (empty body)
* explicit response struct `HttpDouble.Response.t()`
* `{:delay, ms, inner}` – delay sending `inner` by `ms` milliseconds
* `:timeout` or `:no_reply` – do not send any reply
* `:close` – immediately close the TCP connection
* `{:close, inner}` – send `inner` then close the connection
* `{:raw, iodata}` – send raw bytes as-is (can be invalid HTTP)
* `{:partial, [iodata()]}` – send raw chunks one by one
* `{:fun, fun}` – callback `fun.(request, meta) :: response_spec`
* a non-empty list of response specs – used sequentially per matcher
"""
@type response_spec ::
map()
| HttpDouble.Response.t()
| {:delay, non_neg_integer(), response_spec()}
| :timeout
| :no_reply
| :close
| {:close, response_spec()}
| {:raw, iodata()}
| {:partial, [iodata()]}
| {:fun, (HttpDouble.Request.t(), HttpDouble.MockRule.meta() -> response_spec())}
| [response_spec()]
@typedoc """
A recorded HTTP call, suitable for assertions in tests.
"""
@type call_record :: %{
conn_id: non_neg_integer(),
request: HttpDouble.Request.t(),
timestamp: integer(),
rule_id: reference() | nil,
route_id: reference() | nil,
action: term()
}
@typedoc """
Endpoint information for pointing external systems (for example ejabberd)
at a running HttpDouble instance.
"""
@type endpoint :: %{
host: String.t(),
port: non_neg_integer(),
base_url: String.t()
}
@doc """
Starts a new dummy HTTP server.
Options:
* `:port` – TCP port to listen on (defaults to `0`, meaning a random free port)
* `:host` – logical host name to report in `endpoint/1` (default: `"127.0.0.1"`)
* `:ip` – IP tuple to bind to (default: `{127, 0, 0, 1}`)
* `:routes` – list of static route definitions (see README for examples)
* `:mode` – one of:
* `:mock_first` (default) – try mock rules first, fall back to static routes
* `:routes_only` – ignore mock rules, use only static routes
* `:mock_only` – only apply mock rules, unknown requests are 404
* `:name` – optional registered name (via `Registry.HttpDouble.ServerRegistry`)
"""
@spec start_link(Keyword.t()) :: GenServer.on_start()
def start_link(opts \\ []) when is_list(opts) do
HttpDouble.Server.start_link(opts)
end
@doc """
Returns a `child_spec/1` suitable for embedding the server under a supervisor.
"""
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) when is_list(opts) do
HttpDouble.Server.child_spec(opts)
end
@doc """
Adds a stub rule using an options keyword list.
This is the most flexible form and supports all fields of `HttpDouble.MockRule`.
"""
@spec stub(server(), Keyword.t()) :: {:ok, reference()}
def stub(server, opts) when is_list(opts) do
HttpDouble.Server.add_rule(server, :stub, opts)
end
@doc """
Convenience stub form: `stub(server, matcher, response_spec)`.
Examples:
# Always return 200/\"ok\" for GET /health
{:ok, _rule} =
HttpDouble.stub(server, %{method: "GET", path: "/health"}, %{status: 200, body: "ok"})
# Sequential responses for POST /api/messages
{:ok, _rule} =
HttpDouble.stub(server, %{method: :post, path: "/api/messages"}, [
%{status: 201, json: %{result: "accepted"}},
%{status: 503, body: "unavailable"}
])
"""
@spec stub(server(), matcher(), response_spec()) :: {:ok, reference()}
def stub(server, matcher, response_spec) do
HttpDouble.Server.add_rule(server, :stub, matcher: matcher, respond: response_spec)
end
@doc """
Adds an expectation rule.
Semantics are the same as for `stub/3`, but expectations are intended to be
asserted via `calls/1` in your tests. The engine will keep track of how many
times each expectation was used.
"""
@spec expect(server(), matcher(), response_spec()) :: {:ok, reference()}
def expect(server, matcher, response_spec) do
HttpDouble.Server.add_rule(server, :expect, matcher: matcher, respond: response_spec)
end
@doc """
Adds a single static route at runtime.
"""
@spec add_route(server(), map()) :: :ok
def add_route(server, route_spec) when is_map(route_spec) do
HttpDouble.Server.add_routes(server, [route_spec])
end
@doc """
Adds multiple static routes at runtime.
"""
@spec add_routes(server(), [map()]) :: :ok
def add_routes(server, route_specs) when is_list(route_specs) do
HttpDouble.Server.add_routes(server, route_specs)
end
@doc """
Replaces the full static route table at runtime.
"""
@spec set_routes(server(), [map()]) :: :ok
def set_routes(server, route_specs) when is_list(route_specs) do
HttpDouble.Server.set_routes(server, route_specs)
end
@doc """
Updates a single route, identified either by `:id` or by `{method, path}`.
"""
@spec update_route(server(), map()) :: :ok
def update_route(server, route_spec) when is_map(route_spec) do
HttpDouble.Server.update_route(server, route_spec)
end
@doc """
Deletes a route, identified either by `:id` or by `{method, path}`.
"""
@spec delete_route(server(), map()) :: :ok
def delete_route(server, route_spec) when is_map(route_spec) do
HttpDouble.Server.delete_route(server, route_spec)
end
@doc """
Returns the list of all HTTP calls observed by a server.
"""
@spec calls(server()) :: [call_record()]
def calls(server) do
HttpDouble.Server.calls(server)
end
@doc """
Resets the server state:
* clears all routes
* clears all mock rules and expectations
* clears call history
"""
@spec reset!(server()) :: :ok
def reset!(server) do
HttpDouble.Server.reset!(server)
end
@doc """
Returns the TCP port the server is currently listening on.
"""
@spec port(server()) :: non_neg_integer()
def port(server) do
HttpDouble.Server.port(server)
end
@doc """
Returns the logical host the server advertises (for configuration injection).
"""
@spec host(server()) :: String.t()
def host(server) do
HttpDouble.Server.host(server)
end
@doc """
Returns a convenient endpoint map `%{host, port, base_url}` for pointing
external systems (for example ejabberd) at this HttpDouble instance.
"""
@spec endpoint(server()) :: endpoint()
def endpoint(server) do
HttpDouble.Server.endpoint(server)
end
@doc """
Builds a full URL for a given path (string or list of segments) using the
server's current endpoint.
"""
@spec url(server(), String.t() | [String.t()]) :: String.t()
def url(server, path) when is_binary(path) do
%{base_url: base} = endpoint(server)
base <> ensure_leading_slash(path)
end
def url(server, segments) when is_list(segments) do
path = Enum.map_join(segments, "/", &URI.encode/1)
url(server, "/" <> path)
end
defp ensure_leading_slash("/" <> _ = path), do: path
defp ensure_leading_slash(path), do: "/" <> path
@doc """
Stops the server gracefully.
"""
@spec stop(server()) :: :ok
def stop(server) do
GenServer.stop(server)
end
end