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