lib/hooksniff.ex

defmodule HookSniff do
  @moduledoc """
  Official Elixir client for the HookSniff webhook delivery service.

  ## Usage

      client = HookSniff.new("hr_live_...")

      # Create endpoint
      {:ok, endpoint} = HookSniff.Endpoints.create(client, %{url: "https://myapp.com/webhook"})

      # Send webhook
      {:ok, delivery} = HookSniff.Webhooks.send(client, %{
        endpoint_id: endpoint["id"],
        event: "order.created",
        data: %{order_id: "12345"}
      })
  """

  @default_base_url "https://hooksniff-api-1046140057667.europe-west1.run.app/v1"
  @default_timeout 30_000
  @user_agent "hooksniff-elixir/0.2.0"

  defstruct [:api_key, :base_url, :timeout]

  @type t :: %__MODULE__{
          api_key: String.t(),
          base_url: String.t(),
          timeout: pos_integer()
        }

  @doc """
  Create a new HookSniff client.
  """
  @spec new(String.t(), keyword()) :: t()
  def new(api_key, opts \\ []) do
    %__MODULE__{
      api_key: api_key,
      base_url: Keyword.get(opts, :base_url, @default_base_url),
      timeout: Keyword.get(opts, :timeout, @default_timeout)
    }
  end

  @doc false
  def request(%__MODULE__{} = client, method, path, body \\ nil) do
    url = client.base_url <> path
    url_charlist = String.to_charlist(url)

    headers = [
      {'authorization', String.to_charlist("Bearer " <> client.api_key)},
      {'content-type', 'application/json'},
      {'user-agent', String.to_charlist(@user_agent)}
    ]

    http_opts = [
      timeout: client.timeout,
      connect_timeout: client.timeout,
      ssl: [verify: :verify_peer]
    ]

    {content_type, req_body} =
      case body do
        nil -> {'application/json', []}
        _ -> {'application/json', Jason.encode!(body)}
      end

    request = {url_charlist, headers, content_type, req_body}

    case :httpc.request(method_charlist(method), request, http_opts, []) do
      {:ok, {{_http_ver, status, _reason}, resp_headers, resp_body}} ->
        handle_response(status, resp_headers, to_string(resp_body))

      {:error, reason} ->
        {:error, %HookSniff.Error{message: "HTTP error: #{inspect(reason)}", code: :network_error}}
    end
  end

  defp method_charlist(:get), do: :get
  defp method_charlist(:post), do: :post
  defp method_charlist(:put), do: :put
  defp method_charlist(:delete), do: :delete

  defp handle_response(status, _headers, body) when status in 200..299 do
    case Jason.decode(body) do
      {:ok, decoded} -> {:ok, decoded}
      {:error, _} -> {:ok, body}
    end
  end

  defp handle_response(400, _headers, body), do: {:error, parse_error(body, :validation_error)}
  defp handle_response(401, _headers, body), do: {:error, parse_error(body, :authentication_error)}
  defp handle_response(404, _headers, body), do: {:error, parse_error(body, :not_found_error)}
  defp handle_response(413, _headers, body), do: {:error, parse_error(body, :payload_too_large_error)}
  defp handle_response(429, _headers, body), do: {:error, parse_error(body, :rate_limit_error)}
  defp handle_response(status, _headers, body), do: {:error, parse_error(body, :unknown_error, status)}

  defp parse_error(body, code, status \\ nil) do
    message =
      case Jason.decode(body) do
        {:ok, %{"error" => %{"message" => msg}}} -> msg
        _ -> "HTTP #{status || code}"
      end

    %HookSniff.Error{message: message, code: code, status: status}
  end
end