lib/log_snag.ex

defmodule LogSnag do
  @moduledoc """
  LogSnag is a simple client for the LogSnag API.

  This is an unofficial library, and isn't supported by LogSnag. It will try to
  follow the functionality of [the official Node.js
  client](https://github.com/LogSnag/logsnag.js) for the most part, with some
  slight differences in naming conventions.

  Full documentation of the API and fields that are used can be found on [the
  official LogSnag docs page](https://docs.logsnag.com/).
  """

  alias LogSnag.Error
  alias LogSnag.Event
  alias LogSnag.Insight

  @base_url "https://api.logsnag.com/v1"

  @typedoc """
  A string containing a single emoji character.
  """
  @type emoji :: String.t()

  @doc """
  Publish an event to LogSnag.

  An event can be anything you want to track within your application, service,
  or system. It will be published under your project and organized within your
  specified channels.

  For further documentation see LogSnag's official docs:
  https://docs.logsnag.com/endpoints/log

  ## Examples

      iex> publish_event(%{
      ...>   channel: "waitlist",
      ...>   event: "User Joined",
      ...>   description: "Email: john@example.com",
      ...>   icon: "🎉",
      ...>   tags: %{
      ...>     name: "john doe",
      ...>     email: "john@example.com",
      ...>   },
      ...>   notify: true
      ...> })
      {:ok,
        %Event{
          channel: "waitlist",
          event: "User Joined",
          description: "Email: john@example.com",
          icon: "🎉",
          notify: true,
          tags: %{
            name: "john doe",
            email: "john@example.com"
          }
        }}

  """
  @spec publish_event(params) :: {:ok, Event.t()} | {:error, Error.t()}
        when params: %{
               channel: String.t(),
               event: String.t(),
               description: String.t() | nil,
               icon: emoji | nil,
               notify: boolean | nil,
               tags: map | nil
             }
  def publish_event(params) do
    project = Application.fetch_env!(:log_snag, :project)
    params = Map.put(params, :project, project)

    with {:ok, body} <- make_request(:post, "/log", params) do
      {:ok, Event.from_json(body)}
    end
  end

  @doc """
  Publish an insight to LogSnag.

  Insights are real-time widgets that you can add to each of your projects.
  They are use-case agnostic and can be used to display any information that
  you want in real-time.

  For further documentation see LogSnag's official docs:
  https://docs.logsnag.com/endpoints/insight

  ## Examples

      iex> publish_insight(%{
      ...>   title: "User Count",
      ...>   value: 100,
      ...>   icon: "👨"
      ...> })
      {:ok,
        %Insight{
          title: "User Count",
          value: 100,
          icon: "👨"
        }}

  """
  @spec publish_insight(params) :: {:ok, Insight.t()} | {:error, Error.t()}
        when params: %{
               title: String.t(),
               value: String.t() | integer,
               icon: emoji | nil
             }
  def publish_insight(params) do
    project = Application.fetch_env!(:log_snag, :project)
    params = Map.put(params, :project, project)

    with {:ok, body} <- make_request(:post, "/insight", params) do
      {:ok, Insight.from_json(body)}
    end
  end

  # Private

  @spec build_url(String.t()) :: String.t()
  defp build_url("/" <> _ = path) do
    @base_url <> path
  end

  @spec make_request(atom, String.t(), map) :: {:ok, map} | {:error, Error.t()}
  defp make_request(method, path, params) do
    api_key = Application.fetch_env!(:log_snag, :api_key)

    headers = [
      {"Authorization", "Bearer #{api_key}"},
      {"Content-Type", "application/json"},
      {"Accept", "application/json"}
    ]

    body = Jason.encode!(params)
    url = build_url(path)
    request = Finch.build(method, url, headers, body)
    result = Finch.request(request, LogSnag.Finch)

    with {:ok, response} <- result,
         {:ok, body} <- Jason.decode(response.body),
         status when status >= 200 and status <= 299 <- response.status do
      {:ok, body}
    else
      {:error, %Mint.TransportError{} = error} ->
        {:error, Error.new(type: :system, reason: :network_error, context: error)}

      401 ->
        {:error, Error.new(type: :system, reason: :invalid_auth_header)}

      400 ->
        {:ok, response} = result
        body = Jason.decode!(response.body)
        message = body["validation"]["body"]["message"]
        {:error, Error.new(type: :system, reason: :validation_error, context: message)}

      _ ->
        {:error, Error.new(type: :system, reason: :unknown, context: result)}
    end
  end
end