lib/github/operation.ex

defmodule GitHub.Operation do
  @moduledoc """
  Defines a struct that tracks client requests

  > #### Note {:.info}
  >
  > This module is unlikely to be used directly by applications. Instead, functions in this module
  > are useful for plugins. See `GitHub.Plugin` for more information.

  ## Fields

    * `private` (map): This field is useful for plugins that need to store information. Plugins
      should be careful to namespace their data to avoid overlap. By default, this map will include
      an `__auth__` key with the auth credentials used for the request, `__info__` containing the
      information that originated the request, and `__opts__` containing all of the options passed
      in to the original operation function.

    * `request_body` (term): For requests that support request bodies, this key will hold the data
      to be included in an outgoing request. Depending on the plugins involved, this key may have
      Elixir terms (like a map) or strings (such as a JSON-encoded string).

    * `request_headers` (list of headers): HTTP headers to be included in the outgoing request.
      These are specified as tuples with the name of the header and its value.

    * `request_method` (atom): HTTP verb of the outgoing request.

    * `request_params` (keyword): URL-based query parameters for the outgoing request.

    * `request_server` (string): URL scheme and hostname of the API server for the request.

    * `request_types` (list of types): OpenAPI type specifications for the request body. These are
      specified as tuples with the Content-Type and the type specification.

    * `request_url` (string): URL path of the outgoing request.

    * `response_body` (term): For responses that include response bodies, this key will hold the
      data from the response. Depending on the plugins involved, this key may have raw response data
      (such as a JSON-encoded string) or Elixir terms (like a map).

    * `response_code` (integer): Response status code.

    * `response_headers` (list of headers): HTTP headers from the response. These are specified as
      tuples with the name of the header and its value.

    * `response_types` (list of types): OpenAPI type specifications for the response body. These are
      specified as tuples with the status code and the type specification.

  """

  alias GitHub.Auth
  alias GitHub.Config

  @type auth :: nil | (token :: String.t()) | {username :: String.t(), password :: String.t()}
  @type header :: {String.t(), String.t()}
  @type headers :: [header]
  @type method :: :get | :put | :post | :delete | :options | :head | :patch | :trace
  @type request_type :: {String.t(), type}
  @type request_types :: [request_type]
  @type response_type :: {integer, type | nil}
  @type response_types :: [response_type]

  @typedoc "Type annotation produced by [OpenAPI](https://github.com/aj-foster/open-api-generator)"
  @type type ::
          :binary
          | :boolean
          | :integer
          | :map
          | :number
          | :string
          | :unknown
          | {:array, type}
          | {:nullable, type}
          | {:union, [type]}
          | {module, atom}

  @typedoc "Operation struct for tracking client requests from start to finish"
  @type t :: %__MODULE__{
          private: map,
          request_body: term,
          request_headers: headers,
          request_method: method,
          request_params: keyword | [{String.t(), String.t()}] | nil,
          request_server: String.t(),
          request_types: request_types | nil,
          request_url: String.t(),
          response_body: term,
          response_code: integer | nil,
          response_headers: headers | nil,
          response_types: response_types
        }

  defstruct [
    :private,
    :request_body,
    :request_headers,
    :request_method,
    :request_params,
    :request_server,
    :request_types,
    :request_url,
    :response_body,
    :response_code,
    :response_headers,
    :response_types
  ]

  #
  # Plugin Helpers
  #

  @doc """
  Get the client's calling function and original arguments

  This level of introspection is meant for testing purposes, although other plugins can take
  advantage of it as necessary.
  """
  @spec get_caller(t) :: {module, atom, [any]}
  def get_caller(operation) do
    case operation do
      %__MODULE__{private: %{__info__: %{args: args, call: {module, function}}}} ->
        {module, function, Keyword.values(args)}

      %__MODULE__{private: %{__info__: %{call: {module, function}}}} ->
        {module, function, []}
    end
  end

  @doc """
  Get the options passed to the client request

  ## Examples

      iex> operation = %Operation{private: %{__opts__: [server: "https://api.github.com"]}}
      iex> Operation.get_options(operation)
      [server: "https://api.github.com"]

  """
  @spec get_options(t) :: keyword
  def get_options(%__MODULE__{private: %{__opts__: opts}}), do: opts

  @doc """
  Get the value of a response header

  If response headers have not been filled in — or the response did not have the given header —
  then `nil` will be returned.

  ## Examples

      iex> operation = %Operation{response_headers: [{"Content-Type", "application/json"}]}
      iex> Operation.get_response_header(operation, "Content-Type")
      "application/json"

      iex> operation = %Operation{response_headers: []}
      iex> Operation.get_response_header(operation, "ETag")
      nil

  """
  @spec get_response_header(t, String.t()) :: String.t() | nil
  def get_response_header(operation, header) do
    %__MODULE__{response_headers: headers} = operation
    header = String.downcase(header)

    case Enum.find(headers, fn {name, _value} -> String.downcase(name) == header end) do
      {_header, value} -> value
      _ -> nil
    end
  end

  @doc """
  Put information in the operation's private data store

  Existing data with the same key will be overridden.

  ## Example

      iex> operation = %Operation{private: %{}}
      iex> operation = Operation.put_private(operation, :my_plugin_data, "abc123")
      %Operation{private: %{my_plugin_data: "abc123"}} = operation

  """
  @spec put_private(t, atom, term) :: t
  def put_private(operation, key, value) do
    %__MODULE__{private: private} = operation
    %__MODULE__{operation | private: Map.put(private, key, value)}
  end

  @doc """
  Add a request header to an outgoing operation

  This function makes no effort to deduplicate headers.

  ## Example

      iex> operation = %Operation{request_headers: []}
      iex> operation = Operation.put_request_header(operation, "Content-Type", "application/json")
      %Operation{request_headers: [{"Content-Type", "application/json"}]} = operation

  """
  @spec put_request_header(t, String.t(), String.t()) :: t
  def put_request_header(operation, header, value) do
    %__MODULE__{request_headers: headers} = operation
    %__MODULE__{operation | request_headers: [{header, value} | headers]}
  end

  #
  # Internal
  #

  @doc false
  @spec new(map) :: t
  def new(
        %{
          url: url,
          method: method,
          response: response_types,
          opts: opts
        } = request_info
      ) do
    %__MODULE__{
      private: %{__info__: request_info, __opts__: opts},
      request_body: request_info[:body],
      request_headers: [],
      request_method: method,
      request_params: request_info[:query],
      request_server: Config.server(opts),
      request_types: request_info[:request],
      request_url: url,
      response_types: response_types
    }
    |> put_auth_header(opts[:auth])
    |> put_content_type_header()
    |> put_version_header(Config.version(opts))
    |> put_user_agent()
  end

  @spec put_auth_header(t, auth) :: t
  defp put_auth_header(operation, nil) do
    if auth = Config.default_auth() do
      put_auth_header(operation, auth)
    else
      put_private(operation, :__auth__, nil)
    end
  end

  defp put_auth_header(operation, token) when is_binary(token) do
    operation
    |> put_request_header("Authorization", "Bearer #{token}")
    |> put_private(:__auth__, token)
  end

  defp put_auth_header(operation, {username, password}) do
    basic_auth = Base.encode64("#{username}:#{password}")

    operation
    |> put_request_header("Authorization", "Basic #{basic_auth}")
    |> put_private(:__auth__, basic_auth)
  end

  defp put_auth_header(operation, value) do
    auth = Auth.to_auth(value)
    put_auth_header(operation, auth)
  end

  @spec put_content_type_header(t) :: t
  defp put_content_type_header(%__MODULE__{request_types: nil} = operation), do: operation

  defp put_content_type_header(%__MODULE__{request_types: [type]} = operation) do
    {content_type, _body_type} = type
    put_request_header(operation, "Content-Type", content_type)
  end

  @spec put_version_header(t, String.t()) :: t
  defp put_version_header(operation, version) do
    put_request_header(operation, "X-GitHub-Api-Version", version)
  end

  @spec put_user_agent(t) :: t
  defp put_user_agent(operation) do
    user_agent =
      IO.iodata_to_binary([
        Config.app_name() || "Unknown App",
        " via oapi_github ",
        Application.spec(:oapi_github, :vsn),
        "; Elixir ",
        System.version(),
        " / OTP ",
        System.otp_release()
      ])

    put_request_header(operation, "User-Agent", user_agent)
  end
end