Current section
Files
Jump to
Current section
Files
lib/http_checker.ex
defmodule Fettle.HttpCheckerBase do
@moduledoc """
Base module for defining `Fettle.Checker` modules based on an HTTP call.
See `Fettle.HttpChecker` for details.
"""
@callback compare_response(response :: HTTPPoison.Response.t(), config :: map) :: Result.t()
@callback compare_resp_body(
content_type :: String.t(),
body :: String.t(),
expected :: any,
config :: map
) :: Result.t()
@optional_callbacks compare_response: 2, compare_resp_body: 4
@doc "Get a header from a keyword-like list (but with string keys), returning header value or `nil` if not found."
@spec get_header(headers :: [{String.t(), String.t()}], key :: String.t()) :: String.t() | nil
def get_header(headers, key) when is_list(headers) do
case :lists.keyfind(key, 1, headers) do
{_, value} -> value
false -> nil
end
end
@typedoc "ways of specifing a status code"
@type status_code_spec :: non_neg_integer | Range.t()
@doc "test a status code against a range, value or list of range or value"
@spec expected_status_code?(
status_code :: non_neg_integer,
expected :: status_code_spec | [status_code_spec]
) :: boolean
def expected_status_code?(status_code, expected_spec) when is_integer(status_code) do
case expected_spec do
range = %Range{} ->
status_code in range
code when is_integer(code) ->
status_code == code
[spec | specs] ->
expected_status_code?(status_code, spec) || expected_status_code?(status_code, specs)
[] ->
false
end
end
defmacro __using__(_) do
quote location: :keep do
@behaviour Fettle.Checker
@behaviour Fettle.HttpCheckerBase
alias Fettle.Checker.Result
@httpoison_options ssl: [{:versions, [:"tlsv1.2"]}], recv_timeout: 2000
@hackney_options [pool: Fettle.Checker, timeout: 10_000]
@doc "compare the HTTP response and compute a `Fettle.Checker.Result`"
@spec compare_response(resp :: HTTPoison.Response.t(), config :: map) :: Result.t()
def compare_response(response, config)
def compare_response(resp = %HTTPoison.Response{status_code: status_code}, config) do
expected_status = config.status_code
case Fettle.HttpCheckerBase.expected_status_code?(status_code, expected_status) do
true ->
run_compare_response_body(config.resp_body, resp, config)
false ->
Result.error("Unexpected status code #{status_code}.")
end
end
@doc "choses the method used to compare the response body and executes it"
@spec run_compare_response_body(
resp_body_opt :: any,
resp :: HTTPoison.Response.t(),
config :: map
) :: Result.t()
def run_compare_response_body(resp_body_opt, resp, config)
def run_compare_response_body(nil, _resp, _config), do: Result.ok()
def run_compare_response_body(false, _resp, _config), do: Result.ok()
def run_compare_response_body(
fun,
%HTTPoison.Response{headers: headers, body: body},
config
)
when is_function(fun, 3) do
content_type = Fettle.HttpCheckerBase.get_header(headers, "content-type")
fun.(content_type, body, config)
end
def run_compare_response_body(
{mod, fun},
%HTTPoison.Response{headers: headers, body: body},
config
)
when is_atom(mod) and is_atom(fun) do
content_type = Fettle.HttpCheckerBase.get_header(headers, "content-type")
apply(mod, fun, [content_type, body, config])
end
def run_compare_response_body(
expected_body,
%HTTPoison.Response{headers: headers, body: body},
config
) do
content_type = Fettle.HttpCheckerBase.get_header(headers, "content-type")
compare_resp_body(content_type, body, expected_body, config)
end
@doc """
Compare the response body with to an expected content-type, body or pattern and compute a `Fettle.Checker.Response`.
Supported types for `expected_body` are: `String` or `Regex`, extensions of this module may implement others.
"""
@spec compare_resp_body(
content_type :: String.t(),
body :: String.t(),
expected_body :: any,
config :: map
) :: Result.t()
def compare_resp_body(content_type, body, expected_body, config)
def compare_resp_body(_, body, expected_body, _config)
when is_binary(body) and is_binary(expected_body) do
case String.equivalent?(body, expected_body) do
true -> Result.ok()
false -> Result.error("Unexpected response body.")
end
end
def compare_resp_body(_, body, regex = %Regex{}, _config) when is_binary(body) do
case Regex.match?(regex, body) do
true -> Result.ok()
false -> Result.error("Unexpected response body.")
end
end
@doc "adds a `user-agent` header using `fettle.system_code` config, if not already present"
def default_headers(headers) do
case Fettle.HttpCheckerBase.get_header(headers, "user-agent") do
nil ->
system_code = Application.get_env(:fettle, :system_code, "fettle")
[{"user-agent", system_code} | headers]
_ ->
headers
end
end
@doc "merges default `HTTPoison` options with `httpoison` options specified in config."
def default_httpoison_opts(httpoison_opts) do
hackney_opts = default_hackney_opts(httpoison_opts[:hackney] || [])
httpoison_opts = Keyword.merge(@httpoison_options, httpoison_opts)
Keyword.put(httpoison_opts, :hackney, hackney_opts)
end
@doc "merges default `HTTPoison` hackney options with `httpoison[:hackney]` options specified in config."
def default_hackney_opts(hackney_opts) do
Keyword.merge(@hackney_options, hackney_opts)
end
@doc "check options and transform into a map, applying defaults as necessary"
@impl Fettle.Checker
def init(opts) do
opts[:url] || raise ArgumentError, "#{__MODULE__} Need check :url"
config =
Enum.into(opts, %{
headers: [],
method: "GET",
req_body: "",
status_code: 200,
httpoison: [],
resp_body: nil
})
headers = default_headers(config.headers)
%{config | httpoison: default_httpoison_opts(config.httpoison), headers: headers}
end
@doc "Call an HTTP(S) end-point and assert a response code/response body and return a `Fettle.Checker.Response`"
@impl Fettle.Checker
def check(
config = %{
method: method,
url: url,
req_body: req_body,
headers: headers,
httpoison: httpoison_opts
}
) do
result = HTTPoison.request(method, url, req_body, headers, httpoison_opts)
case result do
{:ok, resp = %HTTPoison.Response{}} ->
compare_response(resp, config)
{:error, %HTTPoison.Error{reason: reason}} ->
Result.error(inspect(reason))
end
end
defoverridable Module.definitions_in(__MODULE__)
end
end
end
defmodule Fettle.HttpChecker do
@moduledoc ~S"""
A `Fettle.Checker` that is healthy depending on the result of an HTTP request.
Implements the `Fettle.Checker` behaviour.
Configure in Fettle health-check config as e.g.:
```elixir
{
%{
name: "my-service-check",
panic_guide_url: "...",
...
checker: Fettle.HttpChecker,
args: [url: "http://my-service.com/endpoint", method: "POST", req_body: body(), status_code: 200..202, resp_body: ~r/.*xy??y.*/]
},
}
```
## Options
| Key | Type | Description | Required/default |
| --- | ---- | ----------- | ---------------- |
| `url` | `String.t` | URL to call | required |
| `headers` | `{String.t, String.t}` | Headers to pass to request | `[]` |
| `method` | `String.t` | HTTP method | `"GET"` |
| `req_body` | `String.t` | Body to send with POST, PUT | `""` |
| `httpoison` | `Keyword.t` | Additional options for `HTTPoison.request/5` | `[]` |
| `status_code` | `non_neg_integer \| Range.t \| [non_neg_integer \| Range.t]` | Status code to match | `200` |
| `resp_body` | any | Expected response body | `false` (don't care) |
## Specifying the expected response body
The supported values for `resp_body` are:
* `String` - exact value of body (comparison via `String.equivalent?/2`).
* `Regex` - a regex to use to match the body.
* `function/3` - called passing content-type header, body and options map; returning `Fettle.Checker.Result`.
* `{module, function}` - called passing content-type header, body and options map; returning `Fettle.Checker.Result`.
Simple customization can be performed by using the function or module `resp_body` options.
## Customizing via Fettle.HttpCheckerBase
Note that the `check/1`, `compare_response/2` and `compare_resp_body/4` functions are all overridable
so that this module can be used as a base for more custom implementations; indeed `Fettle.HttpChecker` itself
is just a default implementation of `Fettle.HttpCheckerBase`.
e.g.
```
defmodule MyResponseChecker do
use Fettle.HttpCheckerBase
def compare_response(resp = %HTTPoison.Response{}, config) do
# your implementation
end
end
```
`compare_response/2` is called from `check/1`, and checks the status code matches, before calling `compare_resp_body/4` if
the `resp_body` option is given, so you can override at either the request or body level.
Note that if you are overriding only `compare_resp_body/4`, you *must* provide a value for the `resp_body` option,
else it will be skipped by the default implementation of `compare_response/2`. You can do this robustly by also
overriding `init/1` to pass a truthy value for the `resp_body` opt, and calling `super`:
```
defmodule JsonBodyChecker do
use Fettle.HttpCheckerBase
def init(opts), do: super([{:resp_body, true} | opts])
def compare_resp_body("application/json", body, true, config) do
# your implementation
end
end
```
The result of the `init/1` function is a map with all keys from the `opts` argument; this is passed through to the lower-level functions
as `config`, so you can add your own parameters for these functions.
"""
use Fettle.HttpCheckerBase
end