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