Current section
Files
Jump to
Current section
Files
lib/s2/client.ex
defmodule S2.Client do
@moduledoc """
HTTP client for S2 control plane operations (basins, streams, tokens, metrics).
Wraps `Req` with S2 authentication and JSON encoding/decoding. Used internally
by the operation modules (`S2.Basins`, `S2.Streams`, etc.) — you typically don't
need to call this directly.
> **URL encoding note:** The generated operation modules interpolate resource names
> (basins, streams, token IDs) directly into URL path strings. Req handles URL
> encoding of these paths, so no additional escaping is needed in the operations layer.
## Example
config = S2.Config.new(base_url: "https://aws.s2.dev", token: "my-token")
client = S2.Client.new(config)
{:ok, basins} = S2.Basins.list_basins(server: client)
"""
require Logger
@type t :: %__MODULE__{config: S2.Config.t(), req: Req.Request.t()}
defstruct [:config, :req]
@doc """
Create a new client from an `S2.Config`.
"""
@spec new(S2.Config.t()) :: t()
def new(%S2.Config{} = config) do
headers = base_headers(config)
req =
Req.new(
base_url: config.base_url <> "/v1",
headers: headers
)
%__MODULE__{config: config, req: req}
end
@doc """
Execute a control plane API request.
Takes an operation map (generated by `S2.Basins`, `S2.Streams`, etc.) with
`:url`, `:method`, `:response`, and `:opts` keys. The `:opts` keyword list
must include `server: client` and optionally `basin: "name"`.
Returns `{:ok, decoded_response}` on success, `{:error, %S2.Error{}}` on
failure, or `:ok` for operations with no response body (e.g. DELETE).
"""
@spec request(map()) :: {:ok, term()} | {:error, term()} | :ok
def request(
%{
url: url,
method: method,
response: response_specs,
opts: opts
} = operation
) do
client = opts[:server] || raise ArgumentError, "server is required in opts"
unless is_struct(client, __MODULE__) do
raise ArgumentError, "server must be an %S2.Client{}, got: #{inspect(client)}"
end
query = Map.get(operation, :query, [])
body = Map.get(operation, :body)
basin = opts[:basin]
headers = if basin, do: [{"s2-basin", basin}], else: []
req_opts = [
method: method,
url: url,
headers: headers
]
req_opts = if Enum.any?(query), do: Keyword.put(req_opts, :params, query), else: req_opts
req_opts =
if body do
Keyword.put(req_opts, :json, encode_body(body))
else
req_opts
end
case Req.request(client.req, req_opts) do
{:ok, %Req.Response{status: status, body: resp_body}} ->
handle_response(status, resp_body, response_specs)
{:error, exception} ->
{:error, %S2.Error{status: nil, message: Exception.message(exception)}}
end
end
@doc false
def handle_response(status, body, response_specs) do
case List.keyfind(response_specs, status, 0) do
{_, :null} ->
:ok
{_, {schema_mod, :t}} when status < 400 ->
{:ok, decode_schema(body, schema_mod)}
{_, {schema_mod, :t}} ->
decoded = decode_schema(body, schema_mod)
{:error, to_error(status, decoded)}
nil ->
{:error, S2.Error.from_response(%{status: status, body: body})}
end
end
@doc false
def to_error(_status, %S2.Error{} = error), do: error
def to_error(status, %{code: code, message: message}) do
%S2.Error{status: status, code: code, message: message}
end
def to_error(status, other) do
%S2.Error{status: status, message: inspect(other)}
end
@doc false
def decode_schema(body, schema_mod) when is_map(body) do
fields = schema_mod.__fields__(:t)
attrs =
for {field_name, type} <- fields, into: %{} do
str_key = Atom.to_string(field_name)
raw = Map.get(body, str_key)
{field_name, decode_field(raw, type)}
end
struct(schema_mod, attrs)
end
def decode_schema(nil, _schema_mod), do: nil
def decode_schema(body, _schema_mod), do: body
@doc false
def decode_field(nil, _type), do: nil
def decode_field(values, [{schema_mod, :t}]) when is_list(values) do
Enum.map(values, &decode_schema(&1, schema_mod))
end
def decode_field(value, {schema_mod, :t}) when is_map(value) and is_atom(schema_mod) do
decode_schema(value, schema_mod)
end
def decode_field(value, {:union, variants}) do
Enum.find_value(variants, fn
:null when is_nil(value) ->
{:decoded, nil}
{schema_mod, :t} when is_atom(schema_mod) and is_map(value) ->
{:decoded, decode_schema(value, schema_mod)}
{:const, const_value} when const_value == value ->
{:decoded, value}
_ ->
nil
end)
|> case do
{:decoded, result} ->
result
nil ->
Logger.debug("No matching union variant for value: #{inspect(value)}")
value
end
end
def decode_field(value, _type), do: value
@doc false
def encode_body(%{__struct__: _} = struct) do
struct
|> Map.from_struct()
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new(fn {k, v} -> {k, encode_body(v)} end)
end
def encode_body(values) when is_list(values), do: Enum.map(values, &encode_body/1)
def encode_body(body), do: body
defp base_headers(%S2.Config{token: nil}), do: []
defp base_headers(%S2.Config{token: token}) do
[{"authorization", "Bearer #{token}"}]
end
end