Skip to main content

lib/http/headers.ex

defmodule HTTP.Headers do
  @moduledoc """
  HTTP headers processing and manipulation utilities.

  This module provides a comprehensive API for working with HTTP headers, including
  case-insensitive lookups, header normalization, Content-Type parsing, and a
  default User-Agent generator.

  ## Features

  - **Case-insensitive operations**: All header name comparisons are case-insensitive
  - **Header normalization**: Converts header names to Title-Case format
  - **Multiple headers**: Support for multiple values for the same header name
  - **Content-Type parsing**: Extract media type and parameters
  - **Default User-Agent**: Automatically generated based on runtime environment

  ## Basic Usage

      # Create headers
      headers = HTTP.Headers.new([
        {"Content-Type", "application/json"},
        {"Authorization", "Bearer token"}
      ])

      # Get header value (case-insensitive)
      content_type = HTTP.Headers.get(headers, "content-type")
      # => "application/json"

      # Set/update header
      headers = HTTP.Headers.set(headers, "Accept", "application/json")

      # Set only if not present
      headers = HTTP.Headers.set_default(headers, "User-Agent", "CustomAgent/1.0")

      # Add multiple values for same header
      headers = HTTP.Headers.add(headers, "Accept", "text/html")
      values = HTTP.Headers.get_all(headers, "Accept")
      # => ["application/json", "text/html"]

  ## Content-Type Parsing

      {media_type, params} =
        HTTP.Headers.parse_content_type("application/json; charset=utf-8")
      # => {"application/json", %{"charset" => "utf-8"}}

  ## User-Agent

  The library provides a default User-Agent string that includes:

  - Operating system information
  - System architecture
  - OTP version
  - BEAM version
  - Elixir version
  - Library version

  Example User-Agent:

      "Mozilla/5.0 (macOS; x86_64-apple-darwin24.6.0) OTP/27 BEAM/15.1 Elixir/1.18.3 http_core/0.5.0"

  Access the default User-Agent:

      user_agent = HTTP.Headers.user_agent()
  """

  defstruct headers: []

  @type header :: {String.t(), String.t()}
  @type headers_list :: list(header)
  @type t :: %__MODULE__{headers: headers_list}

  @doc """
  Creates a new HTTP.Headers struct.

  ## Examples
      iex> HTTP.Headers.new([{"Content-Type", "application/json"}])
      %HTTP.Headers{headers: [{"Content-Type", "application/json"}]}
      
      iex> HTTP.Headers.new()
      %HTTP.Headers{headers: []}
  """
  @spec new(headers_list) :: t()
  def new(headers \\ []) when is_list(headers) do
    %__MODULE__{headers: headers}
  end

  @doc """
  Normalizes header names to title case (e.g., "content-type" becomes "Content-Type").

  ## Examples
      iex> HTTP.Headers.normalize_name("content-type")
      "Content-Type"
      
      iex> HTTP.Headers.normalize_name("AUTHORIZATION")
      "Authorization"
  """
  @spec normalize_name(String.t()) :: String.t()
  def normalize_name(name) when is_binary(name) do
    name
    |> String.downcase()
    |> String.split("-")
    |> Enum.map_join("-", &String.capitalize/1)
  end

  @doc """
  Parses a header string into a {name, value} tuple.

  ## Examples
      iex> HTTP.Headers.parse("Content-Type: application/json")
      {"Content-Type", "application/json"}
      
      iex> HTTP.Headers.parse("Authorization: Bearer token123")
      {"Authorization", "Bearer token123"}
  """
  @spec parse(String.t()) :: header
  def parse(header_str) when is_binary(header_str) do
    case String.split(header_str, ":", parts: 2) do
      [name, value] ->
        {normalize_name(String.trim(name)), String.trim(value)}

      [name] ->
        {normalize_name(String.trim(name)), ""}
    end
  end

  @doc """
  Converts a HTTP.Headers struct to a map for easy lookup.

  ## Examples
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}])
      iex> HTTP.Headers.to_map(headers)
      %{"content-type" => "application/json", "authorization" => "Bearer token"}
  """
  @spec to_map(t()) :: map()
  def to_map(%__MODULE__{headers: headers}) do
    Enum.reduce(headers, %{}, fn {name, value}, acc ->
      Map.put(acc, String.downcase(name), value)
    end)
  end

  @doc """
  Converts a map of headers to a HTTP.Headers struct.

  ## Examples
      iex> headers = HTTP.Headers.from_map(%{"content-type" => "application/json", "authorization" => "Bearer token"})
      iex> {"Content-Type", "application/json"} in headers.headers
      true
      iex> {"Authorization", "Bearer token"} in headers.headers
      true
  """
  @spec from_map(map()) :: t()
  def from_map(map) when is_map(map) do
    headers =
      map
      |> Enum.map(fn {name, value} ->
        {normalize_name(to_string(name)), to_string(value)}
      end)

    %__MODULE__{headers: headers}
  end

  @doc """
  Gets a header value by name (case-insensitive).

  ## Examples
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
      iex> HTTP.Headers.get(headers, "content-type")
      "application/json"
      
      iex> headers = HTTP.Headers.new([{"Authorization", "Bearer token"}])
      iex> HTTP.Headers.get(headers, "missing")
      nil
  """
  @spec get(t(), String.t()) :: String.t() | nil
  def get(%__MODULE__{headers: headers}, name) when is_binary(name) do
    normalized_name = String.downcase(name)

    Enum.find_value(headers, fn {header_name, value} ->
      if String.downcase(header_name) == normalized_name, do: value
    end)
  end

  @doc """
  Gets all header values for a given header name (case-insensitive).

  ## Examples
      iex> headers = HTTP.Headers.new([{"Accept", "text/html"}, {"Accept", "application/json"}])
      iex> HTTP.Headers.get_all(headers, "accept")
      ["text/html", "application/json"]
      
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}])
      iex> HTTP.Headers.get_all(headers, "content-type")
      ["application/json"]
      
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
      iex> HTTP.Headers.get_all(headers, "missing")
      []
  """
  @spec get_all(t(), String.t()) :: list(String.t())
  def get_all(%__MODULE__{headers: headers}, name) when is_binary(name) do
    normalized_name = String.downcase(name)

    headers
    |> Enum.filter(fn {header_name, _value} ->
      String.downcase(header_name) == normalized_name
    end)
    |> Enum.map(fn {_header_name, value} -> value end)
  end

  @doc """
  Sets a header value, replacing any existing header with the same name.

  ## Examples
      iex> headers = HTTP.Headers.new([{"Content-Type", "text/plain"}])
      iex> updated = HTTP.Headers.set(headers, "Content-Type", "application/json")
      iex> HTTP.Headers.get(updated, "Content-Type")
      "application/json"
      
      iex> headers = HTTP.Headers.new()
      iex> updated = HTTP.Headers.set(headers, "Authorization", "Bearer token")
      iex> HTTP.Headers.get(updated, "Authorization")
      "Bearer token"
  """
  @spec set(t(), String.t(), String.t()) :: t()
  def set(%__MODULE__{headers: headers} = headers_struct, name, value)
      when is_binary(name) and is_binary(value) do
    normalized_name = normalize_name(name)

    updated_headers =
      headers
      |> Enum.reject(fn {header_name, _} ->
        String.downcase(header_name) == String.downcase(normalized_name)
      end)
      |> Kernel.++([{normalized_name, value}])

    %{headers_struct | headers: updated_headers}
  end

  @doc """
  Adds a new header value without removing existing headers with the same name.

  ## Examples
      iex> headers = HTTP.Headers.new([{"Accept", "text/html"}])
      iex> updated = HTTP.Headers.add(headers, "Accept", "application/json")
      iex> HTTP.Headers.get_all(updated, "Accept")
      ["text/html", "application/json"]
      
      iex> headers = HTTP.Headers.new()
      iex> updated = HTTP.Headers.add(headers, "Authorization", "Bearer token")
      iex> HTTP.Headers.get(updated, "Authorization")
      "Bearer token"
      
      iex> headers = HTTP.Headers.new([{"Content-Type", "text/plain"}])
      iex> updated = HTTP.Headers.add(headers, "Authorization", "Bearer token")
      iex> HTTP.Headers.get(updated, "Authorization")
      "Bearer token"
      iex> HTTP.Headers.get(updated, "Content-Type")
      "text/plain"
  """
  @spec add(t(), String.t(), String.t()) :: t()
  def add(%__MODULE__{headers: headers} = headers_struct, name, value)
      when is_binary(name) and is_binary(value) do
    normalized_name = normalize_name(name)
    %{headers_struct | headers: headers ++ [{normalized_name, value}]}
  end

  @doc """
  Merges two HTTP.Headers structs, with the second taking precedence.

  ## Examples
      iex> headers1 = HTTP.Headers.new([{"Content-Type", "text/plain"}])
      iex> headers2 = HTTP.Headers.new([{"Content-Type", "application/json"}])
      iex> merged = HTTP.Headers.merge(headers1, headers2)
      iex> HTTP.Headers.get(merged, "Content-Type")
      "application/json"
      
      iex> headers1 = HTTP.Headers.new([{"A", "1"}])
      iex> headers2 = HTTP.Headers.new([{"B", "2"}])
      iex> merged = HTTP.Headers.merge(headers1, headers2)
      iex> HTTP.Headers.get(merged, "A")
      "1"
      iex> HTTP.Headers.get(merged, "B")
      "2"
  """
  @spec merge(t(), t()) :: t()
  def merge(%__MODULE__{headers: headers1}, %__MODULE__{headers: headers2}) do
    map1 = to_map(%__MODULE__{headers: headers1})
    map2 = to_map(%__MODULE__{headers: headers2})
    merged = Map.merge(map1, map2)
    from_map(merged)
  end

  @doc """
  Checks if a header exists (case-insensitive).

  ## Examples
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
      iex> HTTP.Headers.has?(headers, "content-type")
      true
      
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
      iex> HTTP.Headers.has?(headers, "missing")
      false
  """
  @spec has?(t(), String.t()) :: boolean()
  def has?(%__MODULE__{headers: headers}, name) when is_binary(name) do
    normalized_name = String.downcase(name)

    Enum.any?(headers, fn {header_name, _} ->
      String.downcase(header_name) == normalized_name
    end)
  end

  @doc """
  Removes a header by name (case-insensitive).

  ## Examples
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}])
      iex> updated = HTTP.Headers.delete(headers, "content-type")
      iex> HTTP.Headers.has?(updated, "content-type")
      false
      iex> HTTP.Headers.has?(updated, "Authorization")
      true
  """
  @spec delete(t(), String.t()) :: t()
  def delete(%__MODULE__{headers: headers} = headers_struct, name) when is_binary(name) do
    normalized_name = String.downcase(name)

    updated_headers =
      Enum.reject(headers, fn {header_name, _} ->
        String.downcase(header_name) == normalized_name
      end)

    %{headers_struct | headers: updated_headers}
  end

  @doc """
  Parses a Content-Type header to extract the media type and parameters.

  ## Examples
      iex> HTTP.Headers.parse_content_type("application/json; charset=utf-8")
      {"application/json", %{"charset" => "utf-8"}}
      
      iex> HTTP.Headers.parse_content_type("text/plain")
      {"text/plain", %{}}
  """
  @spec parse_content_type(String.t()) :: {String.t(), map()}
  def parse_content_type(content_type) when is_binary(content_type) do
    parts = String.split(content_type, ";")
    media_type = parts |> hd() |> String.trim()

    params =
      parts
      |> tl()
      |> Enum.reduce(%{}, fn param, acc ->
        case String.split(param, "=", parts: 2) do
          [key, value] -> Map.put(acc, String.trim(key), String.trim(value))
          _ -> acc
        end
      end)

    {media_type, params}
  end

  @doc """
  Formats headers for display (useful for debugging).

  ## Examples
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}])
      iex> HTTP.Headers.format(headers)
      "Content-Type: application/json\nAuthorization: Bearer token"
  """
  @spec format(t()) :: String.t()
  def format(%__MODULE__{headers: headers}) do
    headers
    |> Enum.map_join("\n", fn {name, value} -> "#{name}: #{value}" end)
  end

  @doc """
  Returns the underlying list of headers.

  ## Examples
      iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
      iex> HTTP.Headers.to_list(headers)
      [{"Content-Type", "application/json"}]
  """
  @spec to_list(t()) :: headers_list
  def to_list(%__MODULE__{headers: headers}) do
    headers
  end

  @doc """
  Sets a header only if it doesn't already exist (case-insensitive).

  ## Examples
      iex> headers = HTTP.Headers.new([{"Content-Type", "text/plain"}])
      iex> updated = HTTP.Headers.set_default(headers, "Content-Type", "application/json")
      iex> HTTP.Headers.get(updated, "Content-Type")
      "text/plain"
      
      iex> headers = HTTP.Headers.new([{"Accept", "text/html"}])
      iex> updated = HTTP.Headers.set_default(headers, "User-Agent", "CustomAgent/1.0")
      iex> HTTP.Headers.get(updated, "User-Agent")
      "CustomAgent/1.0"
      
      iex> headers = HTTP.Headers.new()
      iex> updated = HTTP.Headers.set_default(headers, "Authorization", "Bearer token")
      iex> HTTP.Headers.get(updated, "Authorization")
      "Bearer token"
  """
  @spec set_default(t(), String.t(), String.t()) :: t()
  def set_default(%__MODULE__{headers: headers} = headers_struct, name, value)
      when is_binary(name) and is_binary(value) do
    normalized_name = String.downcase(name)

    # Check if header already exists
    header_exists =
      Enum.any?(headers, fn {header_name, _} ->
        String.downcase(header_name) == normalized_name
      end)

    if header_exists do
      headers_struct
    else
      normalized_header_name = normalize_name(name)
      %{headers_struct | headers: [{normalized_header_name, value} | headers]}
    end
  end

  @doc """
  Returns the default User-Agent string used by the library.

  ## Examples
      iex> user_agent = HTTP.Headers.user_agent()
      iex> user_agent =~ "Mozilla/5.0"
      true
      iex> user_agent =~ "http_core/"
      true
  """
  @spec user_agent(atom()) :: String.t()
  def user_agent(app \\ :http_core) when is_atom(app) do
    os_info = get_os_info()
    arch_info = get_arch_info()
    otp_version = System.otp_release()
    elixir_version = System.version()

    beam_version = :erlang.system_info(:version)
    app_version = app_version(app)

    "Mozilla/5.0 (#{os_info}; #{arch_info}) OTP/#{otp_version} BEAM/#{beam_version} Elixir/#{elixir_version} #{app}/#{app_version}"
  end

  defp app_version(app) do
    app
    |> Application.spec(:vsn)
    |> case do
      nil -> "unknown"
      version -> to_string(version)
    end
  end

  @spec get_os_info() :: String.t()
  defp get_os_info do
    case :os.type() do
      {:unix, :darwin} ->
        "macOS"

      {:unix, :linux} ->
        "Linux"

      {:win32, _} ->
        "Windows"

      {_, os} ->
        Atom.to_string(os)
    end
  end

  @spec get_arch_info() :: String.t()
  defp get_arch_info do
    to_string(:erlang.system_info(:system_architecture))
  end
end