defmodule Linear.Client do @moduledoc """ HTTP transport for the Linear GraphQL API. Handles authentication, request construction, and response parsing. Not typically used directly -- see `Linear` for the public API. """ require Logger @default_endpoint "https://api.linear.app/graphql" @default_timeout 30_000 @max_error_body_bytes 1_000 @type t :: %__MODULE__{ endpoint: String.t(), headers: [{String.t(), String.t()}], timeout: pos_integer() } @enforce_keys [:endpoint, :headers, :timeout] defstruct [:endpoint, :headers, :timeout] @spec new(keyword()) :: t() def new(opts \\ []) do endpoint = Keyword.get(opts, :endpoint, @default_endpoint) timeout = Keyword.get(opts, :timeout, @default_timeout) auth_header = cond do key = Keyword.get(opts, :api_key) -> {"Authorization", key} token = Keyword.get(opts, :access_token) -> {"Authorization", "Bearer #{token}"} true -> nil end headers = [auth_header, {"Content-Type", "application/json"}] |> Enum.reject(&is_nil/1) %__MODULE__{endpoint: endpoint, headers: headers, timeout: timeout} end @spec query(t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} def query(%__MODULE__{} = client, query_string, opts \\ []) do variables = Keyword.get(opts, :variables, %{}) operation_name = Keyword.get(opts, :operation_name) request_fn = Keyword.get(opts, :request_fn, &default_request/3) payload = %{"query" => query_string, "variables" => variables} |> maybe_put("operationName", operation_name) case request_fn.(client, payload, opts) do {:ok, %{status: 200, body: %{"errors" => errors, "data" => data}}} when is_list(errors) and errors != [] -> {:error, {:graphql_errors, errors, data}} {:ok, %{status: 200, body: %{"errors" => errors}}} when is_list(errors) and errors != [] -> {:error, {:graphql_errors, errors, nil}} {:ok, %{status: 200, body: %{"data" => data}}} -> {:ok, data} {:ok, %{status: status} = response} -> Logger.error("Linear API returned status=#{status}#{error_context(payload, response)}") {:error, {:http_status, status}} {:error, reason} -> Logger.error("Linear API request failed: #{inspect(reason)}") {:error, {:request_failed, reason}} end end defp default_request(%__MODULE__{} = client, payload, _opts) do Req.post(client.endpoint, headers: client.headers, json: payload, connect_options: [timeout: client.timeout] ) end defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value) when is_binary(value) and value != "", do: Map.put(map, key, value) defp maybe_put(map, _key, _value), do: map defp error_context(payload, response) do op = case Map.get(payload, "operationName") do name when is_binary(name) and name != "" -> " operation=#{name}" _ -> "" end body = response |> Map.get(:body) |> summarize_body() "#{op} body=#{body}" end defp summarize_body(body) when is_binary(body) do body |> String.replace(~r/\s+/, " ") |> String.trim() |> String.slice(0, @max_error_body_bytes) |> inspect() end defp summarize_body(body) do body |> inspect(limit: 20, printable_limit: @max_error_body_bytes) |> String.slice(0, @max_error_body_bytes) end end