lib/http/middleware/component.ex

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

  alias WeChat.Http.Middleware.Common
  alias WeChat.{Error, Request, Utils}

  require Logger

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

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

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

  defp prepare_request(env, request) do
    env
    |> populate_component_access_token(request)
    |> Common.encode_request()
  end

  # https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/Official_Accounts/official_account_website_authorization.html

  defp populate_component_access_token(
         env,
         %Request{
           uri: %URI{path: "/sns/oauth2/component/access_token"},
           authorizer_appid: authorizer_appid,
           appid: appid
         } = request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        query = Keyword.merge([appid: authorizer_appid, component_appid: appid], env.query)

        {
          Map.put(env, :query, query),
          request
        }
    end
  end

  # https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/Mini_Programs/WeChat_login.html

  defp populate_component_access_token(
         env,
         %Request{
           uri: %URI{path: "/sns/component/jscode2session"},
           authorizer_appid: authorizer_appid,
           appid: appid
         } = request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        query = Keyword.merge([appid: authorizer_appid, component_appid: appid], env.query)

        {
          Map.put(env, :query, query),
          request
        }
    end
  end

  defp populate_component_access_token(
         env,
         %Request{
           uri: %URI{path: "/cgi-bin/component/api_component_token"},
           appid: appid,
           adapter_storage: {adapter_storage, args}
         } = request
       ) do
    case adapter_storage.fetch_secret_key(appid, args) do
      {:ok, secret} ->
        body =
          env.body
          |> populate_required_into_body([:component_verify_ticket])
          |> Map.put(:component_appid, request.appid)
          |> Map.put(:component_appsecret, secret)

        {
          Map.put(env, :body, body),
          request
        }

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

        raise %Error{
          reason: "invalid_config",
          message: "not found secret_key for appid: #{inspect(appid)}"
        }
    end
  end

  defp populate_component_access_token(
         env,
         %Request{uri: %URI{path: "/cgi-bin/component/api_create_preauthcode"}, appid: appid} =
           request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        body = populate_required_into_body(env.body, [], %{component_appid: appid})

        {
          Map.put(env, :body, body),
          request
        }
    end
  end

  defp populate_component_access_token(
         env,
         %Request{uri: %URI{path: "/cgi-bin/component/api_query_auth"}, appid: appid} = request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        body =
          populate_required_into_body(env.body, [:authorization_code], %{component_appid: appid})

        {
          Map.put(env, :body, body),
          request
        }
    end
  end

  defp populate_component_access_token(
         env,
         %Request{uri: %URI{path: "/cgi-bin/component/api_authorizer_token"}, appid: appid} =
           request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        body =
          populate_required_into_body(env.body, [:authorizer_appid, :authorizer_refresh_token], %{
            component_appid: appid
          })

        {
          Map.put(env, :body, body),
          request
        }
    end
  end

  defp populate_component_access_token(
         env,
         %Request{uri: %URI{path: "/cgi-bin/component/api_get_authorizer_info"}, appid: appid} =
           request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        body =
          populate_required_into_body(env.body, [:authorizer_appid], %{component_appid: appid})

        {
          Map.put(env, :body, body),
          request
        }
    end
  end

  defp populate_component_access_token(
         env,
         %Request{uri: %URI{path: "/cgi-bin/component/api_get_authorizer_option"}, appid: appid} =
           request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        body =
          populate_required_into_body(env.body, [:authorizer_appid, :option_name], %{
            component_appid: appid
          })

        {
          Map.put(env, :body, body),
          request
        }
    end
  end

  defp populate_component_access_token(
         env,
         %Request{uri: %URI{path: "/cgi-bin/component/api_set_authorizer_option"}, appid: appid} =
           request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        body =
          populate_required_into_body(
            env.body,
            [:authorizer_appid, :option_name, :option_value],
            %{
              component_appid: appid
            }
          )

        {
          Map.put(env, :body, body),
          request
        }
    end
  end

  defp populate_component_access_token(
         env,
         %Request{uri: %URI{path: "/cgi-bin/component/api_get_authorizer_list"}, appid: appid} =
           request
       ) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        body = populate_required_into_body(env.body, [:offset, :count], %{component_appid: appid})
        Logger.info("after processing api_get_authorizer_list the body: #{inspect(body)}")

        {
          Map.put(env, :body, body),
          request
        }
    end
  end

  defp populate_component_access_token(env, request) do
    case append_component_access_token(env, request) do
      {:error, error} ->
        {:error, error}

      env ->
        {env, request}
    end
  end

  defp append_component_access_token(env, %Request{access_token: component_access_token})
       when component_access_token != nil do
    Logger.info(fn ->
      "auto append component_access_token using the latest re-freshed component_access_token: #{inspect(component_access_token)}"
    end)

    Map.update!(
      env,
      :query,
      &Keyword.put(&1 || [], :component_access_token, component_access_token)
    )
  end

  defp append_component_access_token(env, %Request{adapter_storage: adapter_storage, appid: appid}) do
    result = WeChat.Component.fetch_component_access_token(appid, adapter_storage)

    case result do
      {:error, error} ->
        {:error, error}

      {:ok, %WeChat.Token{access_token: component_access_token}}
      when is_bitstring(component_access_token) ->
        Map.update!(
          env,
          :query,
          &Keyword.put(&1 || [], :component_access_token, component_access_token)
        )
    end
  end

  defp decode_response({:ok, %{body: body} = response}, env, next, request)
       when body != "" and body != nil do
    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, _} ->
        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(
         %{"authorizer_access_token" => access_token, "expires_in" => expires_in} = response,
         %Request{
           uri: %URI{path: "/cgi-bin/component/api_query_auth"},
           adapter_storage: {adapter_storage, args},
           appid: appid,
           authorizer_appid: authorizer_appid
         }
       )
       when access_token != nil and expires_in != nil do
    authorizer_refresh_token = Map.get(response, "authorizer_refresh_token")

    {:ok, token} =
      adapter_storage.save_access_token(
        appid,
        authorizer_appid,
        access_token,
        authorizer_refresh_token,
        args
      )

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

  defp sync_to_storage_cache(
         %{"authorizer_access_token" => access_token, "expires_in" => expires_in} = response,
         %Request{
           uri: %URI{path: "/cgi-bin/component/api_authorizer_token"},
           adapter_storage: {adapter_storage, args},
           appid: appid,
           authorizer_appid: authorizer_appid
         }
       )
       when access_token != nil and expires_in != nil do
    authorizer_refresh_token = Map.get(response, "authorizer_refresh_token")

    {:ok, token} =
      adapter_storage.save_access_token(
        appid,
        authorizer_appid,
        access_token,
        authorizer_refresh_token,
        args
      )

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

  defp sync_to_storage_cache(
         %{"component_access_token" => component_access_token, "expires_in" => expires_in} =
           response,
         %Request{
           uri: %URI{path: "/cgi-bin/component/api_component_token"},
           adapter_storage: {adapter_storage, args},
           appid: appid
         }
       )
       when component_access_token != nil and expires_in != nil do
    adapter_storage.save_component_access_token(appid, component_access_token, args)
    response
  end

  defp sync_to_storage_cache(response, _request) do
    response
  end

  defp populate_required_into_body(body, fields, prepared \\ %{})

  defp populate_required_into_body(body, [], prepared)
       when is_map(prepared) and is_bitstring(body) do
    prepared
  end

  defp populate_required_into_body(body, [], prepared) when is_map(prepared) and is_map(body) do
    prepared
  end

  defp populate_required_into_body(nil, [], prepared) when is_map(prepared) do
    prepared
  end

  defp populate_required_into_body(body, [], prepared) when is_map(prepared) do
    raise %Error{
      reason: "invalid_request",
      message: "http body: #{inspect(body)} is invalid"
    }
  end

  defp populate_required_into_body(body, [current_field | rest_fields], prepared)
       when is_map(prepared) and is_map(body) and is_bitstring(current_field) do
    current_field_atom = String.to_atom(current_field)

    value =
      Map.get_lazy(body, current_field, fn ->
        Map.get(body, current_field_atom)
      end)

    updated = Map.put(prepared, current_field_atom, value)
    populate_required_into_body(body, rest_fields, updated)
  end

  defp populate_required_into_body(body, [current_field | rest_fields], prepared)
       when is_map(prepared) and is_map(body) and is_atom(current_field) do
    value =
      Map.get_lazy(body, current_field, fn ->
        Map.get(body, to_string(current_field))
      end)

    updated = Map.put(prepared, current_field, value)
    populate_required_into_body(body, rest_fields, updated)
  end

  defp populate_required_into_body(body, [current_field | _rest_fields], prepared)
       when is_map(prepared) and is_bitstring(body) and is_bitstring(current_field) do
    updated = Map.put(prepared, String.to_atom(current_field), body)
    populate_required_into_body(body, [], updated)
  end

  defp populate_required_into_body(body, [current_field | _rest_fields], prepared)
       when is_map(prepared) and is_bitstring(body) and is_atom(current_field) do
    updated = Map.put(prepared, current_field, body)
    populate_required_into_body(body, [], updated)
  end

  defp populate_required_into_body(body, [current_field | _rest_fields], prepared)
       when is_map(prepared) and is_map(body)
       when is_map(prepared) and is_bitstring(body) do
    raise %Error{
      reason: "invalid_request",
      message: "invalid field: #{inspect(current_field)} from request body"
    }
  end

  defp populate_required_into_body(nil, fields, prepared)
       when is_map(prepared) and is_list(fields) and fields != [] do
    prepared
  end

  defp populate_required_into_body(body, fields, prepared)
       when is_map(prepared) and is_list(fields) and fields != [] do
    raise %Error{
      reason: "invalid_request",
      message: "invalid request body: #{inspect(body)}"
    }
  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{appid: appid, adapter_storage: {adapter_storage, args}} = request,
         %{"errcode" => errcode},
         request_query
       )
       when errcode == 40001
       when errcode == 42001 do
    Logger.info(
      "when invoke wechat component apis occurs expired component_access_token for wechat appid: #{appid}, will refresh to fetch a new component_access_token"
    )

    refresh_result =
      adapter_storage.refresh_component_access_token(
        appid,
        request_query[:component_access_token],
        args
      )

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

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

  defp rerun_when_token_expire(
         _env,
         _next,
         %Request{appid: appid},
         %{"errcode" => errcode} = json_resp_body,
         _request_query
       )
       when errcode == 61005
       when errcode == 61006 do
    Logger.error(
      "component_verify_ticket of appid: #{appid} is expired or invalid (errcode: #{errcode}), please re-auth to update verify_ticket"
    )

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

  defp rerun_when_token_expire(
         env,
         _next,
         %Request{appid: appid},
         %{"errcode" => errcode} = json_resp_body,
         _request_query
       )
       when errcode == 61023 do
    Logger.info(
      "refresh_token of appid: #{appid} is invalid, will retry fetch, env: #{inspect(env)}"
    )

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

  defp rerun_when_token_expire(
         _env,
         _next,
         %Request{appid: appid},
         %{"errcode" => errcode} = json_resp_body,
         _request_query
       )
       when errcode == 61004 do
    Logger.error("clientip is not in whitelist of appid: #{appid}")

    {:error,
     %Error{
       reason: "invalid_clientip",
       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