lib/cozy_oss/api_request.ex

defmodule CozyOSS.ApiRequest do
  @moduledoc """
  Converts `%ApiSpec{}` to a `%ApiRequest{}`.
  """

  defstruct [
    :scheme,
    :host,
    :port,
    :method,
    :path,
    :query,
    :headers,
    :body,
    private: %{},
    meta: %{}
  ]

  @typedoc """
  Request scheme.
  """
  @type scheme() :: :https

  @typedoc """
  Request method.
  """
  @type method() :: String.t()

  @typedoc """
  Request path.
  """
  @type path() :: String.t()

  @typedoc """
  Optional request query.
  """
  @type query() :: %{
          optional(query_name :: String.t()) => query_value :: boolean() | number() | String.t()
        }

  @typedoc """
  Request headers.
  """
  @type headers() :: %{optional(header_name :: String.t()) => header_value :: String.t()}

  @typedoc """
  Optional request body.
  """
  @type body() :: iodata() | nil

  @type private() :: %{optional(atom()) => term()}

  @type meta() :: %{optional(atom()) => term()}

  @type t :: %__MODULE__{
          scheme: scheme(),
          host: String.t() | nil,
          port: :inet.port_number(),
          method: method(),
          path: String.t(),
          query: query(),
          headers: headers(),
          body: body(),
          private: private(),
          meta: meta()
        }

  import CozyOSS.Crypto, only: [md5_hash: 1, hmac_sha1: 2]
  alias CozyOSS.Config
  alias CozyOSS.ApiSpec

  @default_expiration_in_seconds_for_signing_on_url 900

  @doc """
  Bulid a struct `%CozyOSS.ApiRequest{}` from `%CozyOSS.Config{}` and `%CozyOSS.ApiSpec{}`.

  This function has built-in signing support, and it's controlled by option `:sign_by`:

  + `sign_by: :header` - add signatures to request headers.
  + `sign_by: :url` - add signatures to URL.

  When using `sign_by: :url`, an extra option `:expiration_in_seconds` is supported. The default value
  is `#{inspect(@default_expiration_in_seconds_for_signing_on_url)}`.

  """
  @spec build!(Config.t(), ApiSpec.t(), keyword()) :: t()
  def build!(%Config{} = config, %ApiSpec{} = api_spec, opts) do
    build_request(config, api_spec)
    |> set_essential_headers()
    |> then(&set_signature(config, &1, opts))
  end

  defp build_request(config, api_spec) do
    base_url = "https://#{config.host}"

    %{
      scheme: scheme,
      host: host,
      port: port
    } = parse_base_url(base_url)

    %{
      bucket: bucket,
      object: object,
      sub_resources: sub_resources,
      method: method,
      path: path,
      query: query,
      headers: headers,
      body: body
    } = api_spec

    %__MODULE__{
      scheme: scheme,
      host: host,
      port: port,
      method: method,
      path: path,
      query: query,
      headers: headers,
      body: body,
      private: %{
        bucket: bucket,
        object: object,
        sub_resources: sub_resources
      }
    }
  end

  defp parse_base_url(url) when is_binary(url) do
    url
    |> URI.parse()
    |> Map.take([:scheme, :host, :port])
  end

  defp set_essential_headers(%__MODULE__{} = req) do
    req
    |> set_header_lazy("content-md5", fn -> md5_hash(req.body) end)
    |> set_header_lazy("content-type", fn -> detect_content_type(req) end)
    |> set_header_lazy("date", fn -> gmt_now() end)
  end

  defp detect_content_type(%__MODULE__{} = req) do
    case Path.extname(req.path) do
      "." <> name -> MIME.type(name)
      _ -> "application/octet-stream"
    end
  end

  defp set_signature(%Config{} = config, %__MODULE__{} = req, opts) do
    sign_by = Keyword.get(opts, :sign_by, :header)
    req = set_meta(req, :sign_by, sign_by)

    case sign_by do
      :header -> set_signature_on_header(config, req)
      :url -> set_signature_on_url(config, req, opts)
      _ -> raise ArgumentError, "unknown :sign_by value - #{inspect(sign_by)}"
    end
  end

  defp set_signature_on_header(%Config{} = config, %__MODULE__{} = req) do
    signature =
      req
      |> build_string_to_sign_for_header_signature()
      |> hmac_sha1(config.access_key_secret)

    set_header(req, "authorization", "OSS #{config.access_key_id}:#{signature}")
  end

  @doc false
  def build_string_to_sign_for_header_signature(%__MODULE__{} = req) do
    %{
      headers: headers,
      private: %{bucket: bucket, object: object, sub_resources: sub_resources}
    } = req

    part1 = [
      req.method,
      fetch_header!(req, "content-md5"),
      fetch_header!(req, "content-type"),
      fetch_header!(req, "date")
    ]

    part2 =
      Enum.reject(
        [
          canonicalize_oss_headers(headers),
          canonicalize_resource(bucket, object, sub_resources)
        ],
        &(&1 == "")
      )

    Enum.join(part1 ++ part2, "\n")
  end

  defp set_signature_on_url(%Config{} = config, %__MODULE__{} = req, opts) do
    expiration_in_seconds =
      Keyword.get(opts, :expiration_in_seconds, @default_expiration_in_seconds_for_signing_on_url)

    expires = get_expires(expiration_in_seconds)

    signature =
      req
      |> build_string_to_sign_for_url_signature(expires)
      |> hmac_sha1(config.access_key_secret)

    req
    |> set_query("OSSAccessKeyId", config.access_key_id)
    |> set_query("Expires", expires)
    |> set_query("Signature", signature)
  end

  @doc false
  def build_string_to_sign_for_url_signature(%__MODULE{} = req, expires) do
    %{
      headers: headers,
      private: %{bucket: bucket, object: object, sub_resources: sub_resources}
    } = req

    part1 = [
      req.method,
      fetch_header!(req, "content-md5"),
      fetch_header!(req, "content-type"),
      expires
    ]

    part2 =
      Enum.reject(
        [
          canonicalize_oss_headers(headers),
          canonicalize_resource(bucket, object, sub_resources)
        ],
        &(&1 == "")
      )

    Enum.join(part1 ++ part2, "\n")
  end

  defp get_expires(expire_seconds) do
    DateTime.utc_now()
    |> DateTime.to_unix()
    |> Kernel.+(expire_seconds)
  end

  # creation of CanonicalizedOSSHeaders
  # https://www.alibabacloud.com/help/en/object-storage-service/latest/access-control-include-signatures-in-the-authorization-header#section-w2k-sw2-xdb
  @doc false
  def canonicalize_oss_headers(headers) when is_map(headers) do
    headers
    |> Enum.filter(fn {k, _v} -> String.match?(k, ~r/^x-oss-/i) end)
    |> Enum.sort_by(fn {k, _v} -> k end, :asc)
    |> Enum.map(fn
      {k, nil} -> to_string(k)
      {k, v} -> "#{k}:#{v}"
    end)
    |> Enum.join("\n")
  end

  # creation of CanonicalizedResource
  # https://www.alibabacloud.com/help/en/object-storage-service/latest/access-control-include-signatures-in-the-authorization-header#section-rvv-dx2-xdb
  @doc false
  def canonicalize_resource(bucket, object, sub_resources) do
    encoded_resource =
      cond do
        bucket && object -> Path.join(["/", bucket, object])
        bucket -> "/#{bucket}/"
        true -> "/"
      end

    encoded_sub_resource = encode_sub_resources(sub_resources)

    [
      encoded_resource,
      encoded_sub_resource
    ]
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("?")
  end

  defp encode_sub_resources(sub_resources) do
    sub_resources
    |> Enum.sort_by(fn {k, _v} -> k end, :asc)
    |> Enum.map(fn
      {k, nil} -> to_string(k)
      {k, v} -> "#{k}=#{v}"
    end)
    |> Enum.join("&")
  end

  defp set_query(%__MODULE__{} = req, name, value)
       when is_binary(name) do
    new_query = Map.put(req.query, name, value)
    %{req | query: new_query}
  end

  defp set_header(%__MODULE__{} = req, name, value)
       when is_binary(name) and is_binary(value) do
    name = String.downcase(name)
    new_headers = Map.put(req.headers, name, value)
    %{req | headers: new_headers}
  end

  defp set_header_lazy(%__MODULE__{} = req, name, fun)
       when is_binary(name) and is_function(fun, 0) do
    name = String.downcase(name)
    new_headers = Map.put_new_lazy(req.headers, name, fun)
    %{req | headers: new_headers}
  end

  defp fetch_header!(%__MODULE__{} = req, name) do
    Map.fetch!(req.headers, name)
  end

  defp set_meta(%__MODULE__{} = req, name, value) do
    new_meta = Map.put(req.meta, name, value)
    %{req | meta: new_meta}
  end

  defp gmt_now() do
    now = DateTime.utc_now()
    Calendar.strftime(now, "%a, %d %b %Y %H:%M:%S GMT")
  end

  @doc false
  def to_url(%__MODULE__{} = req) do
    query = encode_query(req.query)

    %URI{
      scheme: req.scheme,
      host: req.host,
      port: req.port,
      path: req.path,
      query: query
    }
    |> URI.to_string()
  end

  defp encode_query(query) when query == %{}, do: nil
  defp encode_query(query) when is_map(query), do: URI.encode_query(query)

  @doc """
  Converts a request to a signed URL.
  """
  @spec to_url!(t()) :: binary()
  def to_url!(%__MODULE__{meta: %{sign_by: :url}} = req), do: to_url(req)

  def to_url!(%__MODULE__{}) do
    raise ArgumentError,
          "to_url!/1 only supports requests built by build!(config, api_spec, sign_by: :url)"
  end
end