lib/immich/api/client.ex

defmodule Immich.API.Client do
  @moduledoc """
  Generic HTTP client for Immich API requests.
  """

  defmodule Behaviour do
    @moduledoc """
    Defines the expected interface for HTTP clients used by the Immich API modules.
    """

    @type headers :: [{String.t(), String.t()}]

    @type api_error ::
            {:authentication, term()}
            | {:authorization, term()}
            | {:transport, term()}
            | {:unexpected_response, non_neg_integer(), term()}

    @callback get(String.t(), headers()) ::
                {:ok, map() | term()} | {:error, api_error()}

    @callback post(String.t(), map(), headers()) ::
                {:ok, map() | term()} | {:error, api_error()}

    @callback ndjson_stream(String.t(), map(), headers()) ::
                {:ok, Enumerable.t(map())} | {:error, api_error()}
  end

  @behaviour Behaviour

  @type t :: module()

  @type request_fun :: (keyword() -> {:ok, Req.Response.t()} | {:error, term()})
  @type headers :: [{String.t(), String.t()}]

  @type api_error ::
          {:authentication, term()}
          | {:authorization, term()}
          | {:transport, term()}
          | {:unexpected_response, non_neg_integer(), term()}

  @doc """
  Sends a JSON `POST` request and returns the decoded JSON body for 2xx responses.

  Non-2xx responses are mapped to typed API error tuples.
  """
  @impl Behaviour
  @spec post(String.t(), map(), headers()) :: {:ok, map() | term()} | {:error, api_error()}
  def post(url, body, headers), do: post(url, body, headers, &Req.request/1)

  @spec post(String.t(), map(), headers(), request_fun()) ::
          {:ok, map() | term()} | {:error, api_error()}
  def post(url, body, headers, request_fun) do
    request_fun.(method: :post, url: url, headers: headers, json: body)
    |> handle_response()
  end

  @doc """
  Sends a JSON `GET` request and returns the decoded JSON body for 2xx responses.

  Non-2xx responses are mapped to typed API error tuples.
  """
  @impl Behaviour
  @spec get(String.t(), headers()) :: {:ok, map() | term()} | {:error, api_error()}
  def get(url, headers), do: get(url, headers, &Req.request/1)

  @spec get(String.t(), headers(), request_fun()) ::
          {:ok, map() | term()} | {:error, api_error()}
  def get(url, headers, request_fun) do
    request_fun.(method: :get, url: url, headers: headers)
    |> handle_response()
  end

  @doc """
  Sends a streaming JSON `POST` request and decodes the response as NDJSON.

  Returns a lazy stream of decoded map events for 2xx responses.
  """
  @impl Behaviour
  @spec ndjson_stream(String.t(), map(), headers()) ::
          {:ok, Enumerable.t(map())} | {:error, api_error()}
  def ndjson_stream(url, body, headers), do: ndjson_stream(url, body, headers, &Req.request/1)

  @spec ndjson_stream(String.t(), map(), headers(), request_fun()) ::
          {:ok, Enumerable.t(map())} | {:error, api_error()}
  def ndjson_stream(url, body, headers, request_fun) do
    case request_fun.(method: :post, url: url, headers: headers, json: body, into: :self) do
      {:ok, %Req.Response{status: status, body: response_body}} when status in 200..299 ->
        {:ok, decode_ndjson_stream(response_body)}

      {:ok, %Req.Response{status: 401, body: response_body}} ->
        {:error, {:authentication, decode_json_body(response_body)}}

      {:ok, %Req.Response{status: 403, body: response_body}} ->
        {:error, {:authorization, decode_json_body(response_body)}}

      {:ok, %Req.Response{status: status, body: response_body}} ->
        {:error, {:unexpected_response, status, decode_json_body(response_body)}}

      {:error, reason} ->
        {:error, {:transport, reason}}
    end
  end

  defp handle_response({:ok, %Req.Response{status: status, body: response_body}})
       when status in 200..299,
       do: {:ok, decode_json_body(response_body)}

  defp handle_response({:ok, %Req.Response{status: 401, body: response_body}}),
    do: {:error, {:authentication, decode_json_body(response_body)}}

  defp handle_response({:ok, %Req.Response{status: 403, body: response_body}}),
    do: {:error, {:authorization, decode_json_body(response_body)}}

  defp handle_response({:ok, %Req.Response{status: status, body: response_body}}),
    do: {:error, {:unexpected_response, status, decode_json_body(response_body)}}

  defp handle_response({:error, reason}), do: {:error, {:transport, reason}}

  defp decode_json_body(body) when is_binary(body) do
    case Jason.decode(body) do
      {:ok, decoded} -> decoded
      {:error, _reason} -> body
    end
  end

  defp decode_json_body(body), do: body

  defp decode_ndjson_stream(body_stream) do
    Stream.transform(body_stream, fn -> "" end, &decode_chunk/2, &decode_remainder/1, fn _ ->
      :ok
    end)
  end

  defp decode_chunk(chunk, buffer) when is_binary(chunk) do
    data = buffer <> chunk
    parts = String.split(data, "\n", trim: false)
    {remainder, lines} = List.pop_at(parts, -1, "")
    decoded = Enum.flat_map(lines, &decode_line/1)
    {decoded, remainder}
  end

  defp decode_remainder(""), do: {[], ""}

  defp decode_remainder(remainder) do
    {decode_line(remainder), ""}
  end

  defp decode_line(line) do
    normalized = String.trim(line)

    case normalized do
      "" ->
        []

      payload ->
        case Jason.decode(payload) do
          {:ok, decoded} when is_map(decoded) -> [decoded]
          _ -> []
        end
    end
  end
end