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