lib/http/middleware/common.ex

defmodule WeChat.Http.Middleware.Common do
  @moduledoc false
  @behaviour Tesla.Middleware

  alias Tesla.Multipart
  alias WeChat.{UploadMedia, UploadMediaContent, Error, Request, Utils}

  require Logger

  def call(env, next, request) do
    execute(env, next, request)
  end

  defp execute(env, next, request) do
    case try_use_from_cache(env, request) do
      {:cont, env, request} ->
        case prepare_request(env, request) do
          {:error, error} ->
            {:error, error}

          prepared_env ->
            prepared_env
            |> Tesla.run(next)
            |> decode_response(env, next, request)
        end

      {:cached, result} ->
        {:ok, result}
    end
  end

  defp try_use_from_cache(
         env,
         %Request{
           authorizer_appid: nil,
           uri: %URI{path: "/cgi-bin/ticket/getticket"},
           adapter_storage: {adapter_storage, args}
         } = request
       ) do
    type = Keyword.get(request.query, :type)

    appid = request.appid

    case adapter_storage.fetch_ticket(appid, type, args) do
      {:ok, ticket} ->
        {
          :cached,
          format_cached_ticket_response(ticket)
        }

      {:error, _} ->
        {:cont, env, request}
    end
  end

  defp try_use_from_cache(
         env,
         %Request{
           authorizer_appid: authorizer_appid,
           uri: %URI{path: "/cgi-bin/ticket/getticket"},
           adapter_storage: {adapter_storage, args}
         } = request
       ) do
    type = Keyword.get(request.query, :type)

    appid = request.appid

    case adapter_storage.fetch_ticket(appid, authorizer_appid, type, args) do
      {:ok, ticket} ->
        {
          :cached,
          format_cached_ticket_response(ticket)
        }

      {:error, _} ->
        {:cont, env, request}
    end
  end

  defp try_use_from_cache(env, request) do
    {:cont, env, request}
  end

  defp format_cached_ticket_response(ticket) do
    %{
      status: 200,
      body: %{
        "errcode" => 0,
        "errmsg" => "ok",
        "ticket" => ticket.value,
        "type" => ticket.type,
        "timestamp" => ticket.timestamp,
        "expires_in" => ticket.expires_in
      },
      headers: []
    }
  end

  defp prepare_request(env, request) do
    env
    |> populate_access_token(request)
    |> encode_request()
  end

  defp populate_access_token(
         env,
         %Request{
           uri: %URI{path: "/cgi-bin/token"},
           appid: appid,
           adapter_storage: {adapter_storage, args}
         } = request
       ) do
    case adapter_storage.fetch_secret_key(appid, args) do
      {:ok, secret} ->
        prepared_query = [
          grant_type: "client_credential",
          appid: request.appid,
          secret: secret
        ]

        {Map.update!(env, :query, &Keyword.merge(&1, prepared_query)), request}

      nil ->
        Logger.error("not found secret_key for appid: #{inspect(appid)}")

        prepared_query = [appid: request.appid]

        {Map.update!(env, :query, &Keyword.merge(&1, prepared_query)), request}
    end
  end

  defp populate_access_token(env, %Request{uri: %URI{path: path}} = request)
       when path in ["/sns/userinfo", "/sns/auth"] do
    # Use oauth authorized access_token to fetch user information,

    # in this case, we don't need to populate the general access_token.

    {env, request}
  end

  defp populate_access_token(
         env,
         %Request{
           access_token: nil,
           authorizer_appid: nil,
           appid: appid,
           adapter_storage: {adapter_storage, args}
         } = request
       ) do
    case adapter_storage.fetch_access_token(appid, args) do
      {:ok, %WeChat.Token{access_token: access_token}} ->
        {Map.update!(env, :query, &Keyword.put(&1 || [], :access_token, access_token)), request}

      {:error, error} ->
        {:error, error}
    end
  end

  defp populate_access_token(
         env,
         %Request{
           access_token: nil,
           authorizer_appid: authorizer_appid,
           appid: appid,
           adapter_storage: {adapter_storage, args}
         } = request
       )
       when authorizer_appid != nil do
    case WeChat.Component.fetch_access_token(appid, authorizer_appid, {adapter_storage, args}) do
      {:ok, %WeChat.Token{access_token: access_token}} when access_token != nil ->
        {Map.update!(env, :query, &Keyword.put(&1 || [], :access_token, access_token)), request}

      {:error, error} ->
        {:error, error}
    end
  end

  defp populate_access_token(env, %Request{access_token: access_token} = request) do
    # Use the latest re-freshed access_token from `request`

    request = Map.put(request, :access_token, nil)
    {Map.update!(env, :query, &Keyword.put(&1 || [], :access_token, access_token)), request}
  end

  def encode_request({:error, error}) do
    {:error, error}
  end

  def encode_request({env, %Request{body: {:form, body}}}) do
    mp =
      Enum.reduce(body, Multipart.new(), fn {key, value}, acc ->
        case value do
          %UploadMedia{} ->
            acc
            |> Multipart.add_file(value.file_path, name: "#{key}", detect_content_type: true)
            |> Multipart.add_field("type", value.type)

          %UploadMediaContent{} ->
            acc
            |> Multipart.add_file_content(value.file_content, value.file_name, name: "#{key}")
            |> Multipart.add_field("type", value.type)

          _ ->
            Multipart.add_field(acc, "#{key}", "#{value}")
        end
      end)

    body_binary = mp |> Multipart.body() |> Enum.join()
    headers = Multipart.headers(mp)

    env
    |> Map.put(:body, body_binary)
    |> Tesla.put_headers(headers)
  end

  def encode_request({env, _request}) do
    with {:ok, env} <- Tesla.Middleware.JSON.encode(env, env.opts || []) do
      env
    end
  end

  defp decode_response({:ok, %{body: body} = response}, env, next, request)
       when body != "" and body != nil do
    Logger.info("common decode_response: #{inspect(body)}")

    case rerun_when_token_expire(env, next, request, response) do
      {:no_retry, json_resp_body} ->
        json_resp_body = sync_to_storage_cache(json_resp_body, request)

        {
          :ok,
          %{
            status: response.status,
            headers: response.headers,
            body: json_resp_body
          }
        }

      {retry_result, _json_resp_body} ->
        retry_result
    end
  end

  defp decode_response({:ok, %{body: body} = response}, _env, _next, _request)
       when body == "" or body == nil do
    {:error,
     %Error{reason: "unknown", message: "response body is empty", http_status: response.status}}
  end

  defp decode_response({:error, reason}, _env, _next, _request) do
    Logger.error("occurs error when decode response with reason: #{inspect(reason)}")
    {:error, %Error{reason: "#{reason}"}}
  end

  defp sync_to_storage_cache(
         %{"access_token" => access_token} = response,
         %Request{
           uri: %URI{path: "/cgi-bin/token"},
           adapter_storage: {adapter_storage, args},
           appid: appid
         }
       ) do
    {:ok, token} = adapter_storage.save_access_token(appid, access_token, args)
    # Keep the latest `WeChat.Token` in the response from hub to client, and then client can

    # use this `timestamp` and `expires_in` for local registry management in client side.

    response |> Map.put("timestamp", token.timestamp) |> Map.put("expires_in", token.expires_in)
  end

  defp sync_to_storage_cache(
         response,
         %Request{
           uri: %URI{path: "/cgi-bin/ticket/getticket"},
           scenario: scenario
         }
       ) when scenario != :hub do
    # Ignore ticket storage in non-hub scenario,

    # actually, ticket is fetched from the hub when

    # call "/cgi-bin/ticket/getticket" request in a client.

    response
  end

  defp sync_to_storage_cache(
         %{"ticket" => ticket} = response,
         %Request{
           uri: %URI{path: "/cgi-bin/ticket/getticket"},
           query: query,
           adapter_storage: {adapter_storage, args},
           appid: appid,
           authorizer_appid: authorizer_appid
         }
       ) when authorizer_appid != nil do
    type = Keyword.get(query, :type)
    {:ok, ticket} = adapter_storage.save_ticket(appid, authorizer_appid, ticket, type, args)
    # Keep the latest `WeChat.Ticket` in the response from hub to client, and then client can

    # use this `timestamp` and `expires_in` for local registry management in client side.

    response |> Map.put("timestamp", ticket.timestamp) |> Map.put("expires_in", ticket.expires_in)
  end

  defp sync_to_storage_cache(
         %{"ticket" => ticket} = response,
         %Request{
           uri: %URI{path: "/cgi-bin/ticket/getticket"},
           query: query,
           adapter_storage: {adapter_storage, args},
           appid: appid,
           authorizer_appid: nil
         }
       ) do
    type = Keyword.get(query, :type)
    {:ok, ticket} = adapter_storage.save_ticket(appid, ticket, type, args)
    # Keep the latest `WeChat.Ticket` in the response from hub to client, and then client can

    # use this `timestamp` and `expires_in` for local registry management in client side.

    response |> Map.put("timestamp", ticket.timestamp) |> Map.put("expires_in", ticket.expires_in)
  end

  defp sync_to_storage_cache(response, _request) do
    response
  end

  defp rerun_when_token_expire(env, next, request, %{body: body} = response) do
    json_resp_body = Utils.json_decode(body)
    result = rerun_when_token_expire(env, next, request, json_resp_body, response.query)
    {result, json_resp_body}
  end

  defp rerun_when_token_expire(
         _env,
         _next,
         %Request{uri: %{path: path}},
         %{"errcode" => errcode},
         _request_query
       )
       when path in ["/sns/userinfo", "/sns/auth"] and errcode in [40001, 42001, 40014] do
    # ignore retry when the access_token put from external

    :no_retry
  end

  defp rerun_when_token_expire(
         env,
         next,
         %Request{
           appid: appid,
           authorizer_appid: authorizer_appid,
           adapter_storage: {adapter_storage, args}
         } = request,
         %{"errcode" => errcode},
         request_query
       )
       when errcode == 40001
       when errcode == 42001
       when errcode == 40014 do
    #

    # errcode from WeChat 40001/42001: expired access_token

    # errcode from WeChat 40014: invalid access_token

    #

    expired_access_token = Keyword.get(request_query, :access_token)

    refresh_result =
      if authorizer_appid != nil do
        adapter_storage.refresh_access_token(
          appid,
          authorizer_appid,
          expired_access_token,
          args
        )
      else
        adapter_storage.refresh_access_token(appid, expired_access_token, args)
      end

    case refresh_result do
      {:ok, %WeChat.Token{access_token: new_access_token}} ->
        request = Map.put(request, :access_token, new_access_token)
        execute(env, next, request)

      {:error, error} ->
        {:error, error}
    end
  end

  defp rerun_when_token_expire(
         _env,
         _next,
         request,
         %{"errcode" => errcode} = json_resp_body,
         _request_query
       )
       when errcode == 61024 do
    Logger.error(
      "invalid usecase to get access_token of authorizer appid(#{request.authorizer_appid}) by WeChat Component Application(#{
        request.appid
      })"
    )

    {:error,
     %Error{
       reason: "invalid_usecase_get_access_token",
       errcode: errcode,
       message: Map.get(json_resp_body, "errmsg")
     }}
  end

  defp rerun_when_token_expire(_env, _next, _request, _json_resp_body, _request_query) do
    :no_retry
  end
end