lib/storage/adapter/default.ex

defmodule WeChat.Storage.Adapter.DefaultClient do
  @moduledoc """
  For `common` application.

  ```
  defmodule MyClient do
    use WeChat,
      adapter_storage: {:default, "http://localhost:4000"},
      appid: "MyAppID"
  end
  ```

  The above equals as below

  ```
  defmodule MyClient do
    use WeChat,
      adapter_storage: {
        WeChat.Storage.Adapter.DefaultClient,
        "http://localhost:4000"
      },
      appid: "MyAppID"
  end
  ```

  When uses the `{:default, "MyHubBaseURL"}`, there requires some HTTP API functions are provided by the hub web app server,
  let's take "MyHubBaseURL" as "http://localhost:4000" for example.

  ### Refresh access token

  ```
  Request
  - method: POST http://localhost:4000/refresh/access_token
  - body: %{"appid" => "MyAppID", "access_token" => "CurrentExpiryAccessToken"}
  - body format: json

  Response
  - success body: %{"access_token" => "...", "expires_in" => integer(), "timestamp" => integer()}
  - error body: any content is acceptable will be return back into `WeChat.Error`
  ```

  ### Fetch access token

  ```
  Request
  - method: GET http://localhost:4000/client/access_token
  - query string: appid="MyAppID"

  Response
  - success body: %{"access_token" => "...", "expires_in" => integer(), "timestamp" => integer()}
  - error body: any error content is acceptable will be return back into `WeChat.Error`
  ```

  ### Fetch jsapi-ticket/card-ticket

  ```
  Request
  - method: GET http://localhost:4000/client/ticket
  - query string: appid="MyAppID"&type=jsapi or appid="MyAppID"&type=wx_card

  Response
  - success body: %{"ticket" => "...", "expires_in" => integer(), timestamp => integer(), "type" => "jsapi" | "wx_card"}
  - error body: any error content is acceptable will be return back into `WeChat.Error`
  ```
  """

  @behaviour WeChat.Storage.Client

  use WeChat.Registry

  alias WeChat.Storage.DefaultHubConnector, as: Connector
  alias WeChat.Error

  @impl true
  @decorate cache()
  def fetch_access_token(appid, hub_base_url) do
    Connector.fetch_access_token(appid, hub_base_url)
  end

  @impl true
  @decorate cache()
  def refresh_access_token(appid, access_token, hub_base_url) do
    Connector.refresh_access_token(appid, access_token, hub_base_url)
  end

  @impl true
  @decorate cache()
  def fetch_ticket(appid, type, hub_base_url) do
    # currently only support `type` as "jsapi" | "wx_card"
    Connector.fetch_ticket(appid, type, hub_base_url)
  end
end

defmodule WeChat.Storage.Adapter.DefaultComponentClient do
  @moduledoc """
  For `component` application.

  ```
  defmodule MyClient do
    use WeChat.Component,
      adapter_storage: {:default, "http://localhost:4000"},
      appid: "MyComponentAppID"
  end
  ```

  The above equals as below

  ```
  defmodule MyClient do
    use WeChat.Component,
      adapter_storage: {
        WeChat.Storage.Adapter.DefaultComponentClient,
        "http://localhost:4000"
      },
      appid: "MyComponentAppID"
  end
  ```

  When uses the `{:default, "MyHubBaseURL"}`, there requires some HTTP API functions are provided by the hub web app server,
  let's take "MyHubBaseURL" as "http://localhost:4000" for example.

  ### Refresh authorizer access_token

  ```
  Request
  - method: POST http://localhost:4000/refresh/access_token
  - body: %{"appid" => "Your3rdComponentAppID", "authorizer_appid" => "YourAuthorizerAppID", "access_token" => "CurrentExpiryAccessToken"}
  - body format: json

  Response
  - success body: %{"access_token" => "...", "expires_in" => integer(), "timestamp" => integer()}
  - error body: any content is acceptable will be return back into `WeChat.Error`
  ```

  ### Fetch access_token

  ```
  Request
  - method: GET http://localhost:4000/client/access_token
  - query string: appid="MyAppID"

  Response
  - success body: %{"access_token" => "...", "expires_in" => integer(), "timestamp" => integer()}
  - error body: any error content is acceptable will be return back into `WeChat.Error`
  ```

  ### Fetch jsapi-ticket/card-ticket

  ```
  Request
  - method: GET http://localhost:4000/client/ticket
  - query string: appid="MyAppID"&type=jsapi or appid="MyAppID"&type=wx_card

  Response
  - success body: %{"ticket" => "...", "expires_in" => integer(), "timestamp" => integer(), "type" => "jsapi" | "wx_card"}
  - error body: any error content is acceptable will be return back into `WeChat.Error`
  ```
  """

  @behaviour WeChat.Storage.ComponentClient

  use WeChat.Registry

  alias WeChat.Storage.DefaultHubConnector, as: Connector
  alias WeChat.Error

  @impl true
  @decorate cache()
  def fetch_access_token(appid, authorizer_appid, hub_base_url) do
    Connector.fetch_access_token(appid, authorizer_appid, hub_base_url)
  end

  @impl true
  @decorate cache()
  def fetch_component_access_token(appid, hub_base_url) do
    Connector.fetch_component_access_token(appid, hub_base_url)
  end

  @impl true
  @decorate cache()
  def refresh_access_token(
        appid,
        authorizer_appid,
        access_token,
        hub_base_url
      ) do
    Connector.refresh_access_token(appid, authorizer_appid, access_token, hub_base_url)
  end

  @impl true
  @decorate cache()
  def refresh_component_access_token(appid, component_access_token, hub_base_url) do
    Connector.refresh_component_access_token(appid, component_access_token, hub_base_url)
  end

  @impl true
  @decorate cache()
  def fetch_ticket(appid, authorizer_appid, type, hub_base_url) do
    # Currently, `type` supports "jsapi" | "wx_card"
    Connector.fetch_ticket(appid, authorizer_appid, type, hub_base_url)
  end
end

defmodule WeChat.Storage.DefaultHubConnector do
  @moduledoc false

  require Logger

  alias WeChat.{Error, Url}

  def refresh_access_token(appid, access_token, hub_base_url) do
    Logger.info(
      "send refresh_token request to WeChat hub for appid: #{inspect(appid)}, access_token: #{inspect(access_token)}"
    )

    token =
      hub_base_url
      |> client()
      |> Tesla.post(Url.to_refresh_access_token(), %{appid: appid, access_token: access_token})
      |> response_to_access_token()

    Logger.info(
      "received refreshed token from WeChat hub: #{inspect(token)} for appid: #{inspect(appid)}"
    )

    token
  end

  def refresh_access_token(appid, authorizer_appid, access_token, hub_base_url) do
    Logger.info(
      "send refresh_token request to WeChat hub for appid: #{inspect(appid)} with authorizer_appid: #{inspect(authorizer_appid)}, access_token: #{inspect(access_token)}"
    )

    token =
      hub_base_url
      |> client()
      |> Tesla.post(Url.to_refresh_access_token(), %{
        appid: appid,
        authorizer_appid: authorizer_appid,
        access_token: access_token
      })
      |> response_to_access_token()

    Logger.info(
      "received access token from WeChat hub: #{inspect(token)} for appid: #{inspect(appid)} with authorizer_appid: #{inspect(authorizer_appid)}"
    )

    token
  end

  def fetch_access_token(appid, hub_base_url) do
    hub_base_url
    |> client()
    |> Tesla.get(Url.to_fetch_access_token(), query: [appid: appid])
    |> response_to_access_token()
  end

  def fetch_access_token(appid, authorizer_appid, hub_base_url) do
    hub_base_url
    |> client()
    |> Tesla.get(Url.to_fetch_access_token(),
      query: [appid: appid, authorizer_appid: authorizer_appid]
    )
    |> response_to_access_token()
  end

  def fetch_component_access_token(appid, hub_base_url) do
    hub_base_url
    |> client()
    |> Tesla.get(Url.to_fetch_component_access_token(), query: [appid: appid])
    |> response_to_access_token()
  end

  def refresh_component_access_token(appid, access_token, hub_base_url) do
    hub_base_url
    |> client()
    |> Tesla.post(
      Url.to_refresh_component_access_token(),
      %{appid: appid, access_token: access_token}
    )
    |> response_to_access_token()
  end

  def fetch_ticket(appid, type, hub_base_url) do
    hub_base_url
    |> client()
    |> Tesla.get(Url.to_fetch_ticket(), query: [appid: appid, type: type])
    |> response_to_ticket()
  end

  def fetch_ticket(appid, authorizer_appid, type, hub_base_url) do
    hub_base_url
    |> client()
    |> Tesla.get(Url.to_fetch_ticket(),
      query: [appid: appid, authorizer_appid: authorizer_appid, type: type]
    )
    |> response_to_ticket()
  end

  defp client(hub_base_url) do
    Tesla.client(
      [
        {Tesla.Middleware.BaseUrl, hub_base_url},
        {Tesla.Middleware.Retry, delay: 500, max_retries: 10},
        Tesla.Middleware.JSON
      ],
      WeChat.Application.http_adapter()
    )
  end

  defp response_to_access_token(
         {:ok,
          %{
            status: 200,
            body: %{
              "access_token" => access_token,
              "timestamp" => timestamp,
              "expires_in" => expires_in
            }
          }}
       )
       when access_token != nil and access_token != "" and
              is_integer(timestamp) and is_integer(expires_in) do
    {
      :ok,
      %WeChat.Token{
        access_token: access_token,
        timestamp: timestamp,
        expires_in: expires_in
      }
    }
  end

  defp response_to_access_token({:ok, %{status: 200, body: %{"access_token" => _access_token}}}) do
    {
      :error,
      %Error{
        reason: "invalid_access_token",
        errcode: -1,
        message: "check response failed, invalid value of access_token"
      }
    }
  end

  defp response_to_access_token(
         {:ok,
          %{
            body: %{
              "errcode" => errcode,
              "http_status" => http_status,
              "message" => message,
              "reason" => reason
            }
          }}
       ) do
    {
      :error,
      %Error{
        errcode: errcode,
        http_status: http_status,
        message: message,
        reason: reason
      }
    }
  end

  defp response_to_access_token({:error, error}) do
    {
      :error,
      %Error{
        reason: "fail_fetch_access_token",
        errcode: -1,
        message: error
      }
    }
  end

  defp response_to_ticket(
         {:ok,
          %{
            status: 200,
            body:
              %{"ticket" => ticket, "timestamp" => timestamp, "expires_in" => expires_in} = body
          }}
       )
       when ticket != nil and ticket != "" and
              is_integer(timestamp) and is_integer(expires_in) do
    {
      :ok,
      %WeChat.Ticket{
        value: ticket,
        type: Map.get(body, "type"),
        timestamp: timestamp,
        expires_in: expires_in
      }
    }
  end

  defp response_to_ticket({:ok, %{status: status, body: body}}) do
    {:error, %Error{reason: "fail_fetch_ticket", errcode: -1, http_status: status, message: body}}
  end

  defp response_to_ticket({:error, error}) do
    {:error, %Error{reason: "fail_fetch_ticket", errcode: -1, message: error}}
  end
end