lib/wechat.ex

defmodule WeChat do
  @moduledoc """
  The link to WeChat Official Account Platform API document in [Chinese](https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html){:target="_blank"} | [English](https://developers.weixin.qq.com/doc/offiaccount/en/Getting_Started/Overview.html){:target="_blank"}.

  Currently, there are two ways to use the WeChat's APIs:

    * As `common` application, directly integrates WeChat's APIs after turn on your WeChat Official Account into the developer mode ([see details](https://developers.weixin.qq.com/doc/offiaccount/en/Basic_Information/Access_Overview.html){:target="_blank"});
    * As `component` application, authorizes your WeChat Official Account to the WeChat Official Account third-party platform application, leverages a set of common solutions from the third-party platform ([see details](https://developers.weixin.qq.com/doc/oplatform/en/Third-party_Platforms/Third_party_platform_appid.html){:target="_blank"}).

  Refer the official document's recommend to manage access token ([see details](https://developers.weixin.qq.com/doc/offiaccount/en/Basic_Information/Get_access_token.html){:target="_blank"}), we need to
  temporarily storage access token in a centralization way, we prepare four behaviours to manage the minimum responsibilities for each use case.

  Use this library in the 3rd-party web app which can read the temporary storage data (e.g. access token/jsapi-ticket/card-ticket) from the centralization nodes(hereinafter "hub"):

    * The `WeChat.Storage.Client` storage adapter behaviour is required for the `common` application;
    * The `WeChat.Storage.ComponentClient` storage adapter behaviour is required for the `component` application.

  Use this library in the hub web app:

    * The `WeChat.Storage.Hub` storage adapter behaviour is required for the `common` application;
    * The `WeChat.Storage.ComponentHub` storage adapter behaviour is required for the `component` application.

  As usual, the hub web app is one-off setup to use this library, most of time we use `elixir_wechat` is in the 3rd-party web app as a client, so here provide a default storage adapter to conveniently
  initialize it as a client use case:

    * The `WeChat.Storage.Adapter.DefaultClient` implements `WeChat.Storage.Client` behaviour, and is used for the `common` application by default:

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

      #
      # the above equals the following
      #

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

    * The `WeChat.Storage.Adapter.DefaultComponentClient` implements `WeChat.Storage.ComponentClient` behaviour, and is used for the `component` application by default:

      ```elixir
      defmodule MyComponentClient do
        use WeChat.Component,
          adapter_storage: {:default, "http://localhost:4000"}
      end

      #
      # the above equals the following
      #

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

  ## Usage

  ### As `common` application

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

  MyClient.request(:post, url: "WeChatURL1", body: %{}, query: [])
  MyClient.request(:get, url: "WeChatURL2", query: [])
  ```

  Or use `WeChat.request/2` directly

  ```elixir
  WeChat.request(:post, url: "WeChatURL1",
    appid: "MyAppID", adapter_storage: {:default, "http://localhost:4000"},
    body: %{}, query: [])

  WeChat.request(:get, url: "WeChatURL2",
    appid: "MyAppID", adapter_storage: {:default, "http://localhost:4000"},
    query: [])
  ```

  ### As `component` application

  ```elixir
  defmodule MyComponentClient do
    use WeChat.Component,
      adapter_storage: {:default, "http://localhost:4000"},
      appid: "MyAppID",
      authorizer_appid: "MyAuthorizerAppID"
  end

  MyComponentClient.request(:post, url: "WeChatURL1", body: %{}, query: [])
  MyComponentClient.request(:post, url: "WeChatURL2", query: [])
  ```

  Or use `WeChat.request/2` directly

  ```elixir
  WeChat.request(:post, url: "WeChatURL1",
    appid: "MyAppID", authorizer_appid: "MyAuthorizerAppID",
    adapter_storage: {:default, "http://localhost:4000"}, body: %{}, query: [])

  WeChat.request(:get, url: "WeChatURL2",
    appid: "MyAppID", authorizer_appid: "MyAuthorizerAppID",
    adapter_storage: {:default, "http://localhost:4000"}, query: [])
  ```
  """

  alias WeChat.{Http, Utils}

  @type method :: :head | :get | :delete | :trace | :options | :post | :put | :patch
  @type error :: atom() | WeChat.Error.t()

  defmacro __using__(opts \\ []) do
    default_opts =
      opts
      |> Macro.prewalk(&Macro.expand(&1, __CALLER__))
      |> Keyword.take([:adapter_storage, :appid, :authorizer_appid, :scenario])

    quote do
      def default_opts, do: unquote(default_opts)

      @doc """
      See WeChat.request/2 for more information.
      """
      @spec request(method :: WeChat.method(), options :: Keyword.t()) ::
              {:ok, term()} | {:error, WeChat.error()}
      def request(method, options) do
        options = WeChat.Utils.merge_keyword(options, unquote(default_opts))
        WeChat.common_request(method, options)
      end

      @doc """
      The expire time (in seconds) to `access_token` and `ticket` temporary storage,
      by default it is 7200 seconds
      """
      defdelegate expires_in(), to: WeChat

      defoverridable request: 2, expires_in: 0
    end
  end

  defmodule Error do
    @moduledoc """
    A WeChat error expression.
    """
    @type t :: %__MODULE__{
            errcode: String.t(),
            reason: String.t(),
            message: String.t(),
            http_status: integer()
          }

    @derive {Jason.Encoder, only: [:errcode, :message, :reason, :http_status]}
    defexception errcode: nil, message: nil, reason: nil, http_status: nil

    def message(%__MODULE__{
          errcode: errcode,
          message: message,
          reason: reason,
          http_status: http_status
        }) do
      "errcode: #{inspect(errcode)}, message: #{inspect(message)}, reason: #{inspect(reason)}, http_status: #{
        inspect(http_status)
      }"
    end
  end

  defmodule Request do
    @moduledoc false

    @type body :: {:form, map()} | map()

    @type t :: %__MODULE__{
            method: atom(),
            uri: URI.t(),
            appid: String.t(),
            authorizer_appid: String.t(),
            adapter_storage: module(),
            body: body(),
            query: keyword(),
            opts: keyword(),
            access_token: String.t(),
            scenario: :hub | nil
          }

    defstruct [
      :method,
      :uri,
      :appid,
      :authorizer_appid,
      :adapter_storage,
      :body,
      :query,
      :opts,
      :access_token,
      :scenario
    ]
  end

  defmodule Token do
    @moduledoc false
    @type t :: %__MODULE__{
            access_token: String.t(),
            refresh_token: String.t(),
            timestamp: integer(),
            expires_in: integer()
          }

    @derive Jason.Encoder
    defstruct [:access_token, :refresh_token, :timestamp, :expires_in]
  end

  defmodule Ticket do
    @moduledoc false
    @type t :: %__MODULE__{
            value: String.t(),
            type: String.t(),
            timestamp: integer(),
            expires_in: integer()
          }

    @derive Jason.Encoder
    defstruct [:value, :type, :timestamp, :expires_in]
  end

  defmodule UploadMedia do
    @moduledoc """
    Use for upload media file related.
    """
    @type t :: %__MODULE__{
            file_path: String.t(),
            type:
              {:image, String.t()}
              | {:voice, String.t()}
              | {:video, String.t()}
              | {:thumb, String.t()}
          }
    @enforce_keys [:file_path, :type]
    defstruct [:file_path, :type]
  end

  defmodule UploadMediaContent do
    @moduledoc """
    Use for upload media file content related.
    """
    @type t :: %__MODULE__{
            file_content: binary(),
            file_name: String.t(),
            type:
              {:image, String.t()}
              | {:voice, String.t()}
              | {:video, String.t()}
              | {:thumb, String.t()}
          }
    @enforce_keys [:file_content, :file_name, :type]
    defstruct [:file_content, :file_name, :type]
  end

  defmodule JSSDKSignature do
    @moduledoc """
    A WeChat JSSDK signature expression.
    """
    @type t :: %__MODULE__{
            value: String.t(),
            timestamp: integer(),
            noncestr: String.t()
          }
    defstruct [:value, :timestamp, :noncestr]
  end

  defmodule CardSignature do
    @moduledoc """
    A WeChat Card signature expression.
    """
    @type t :: %__MODULE__{
            value: String.t(),
            timestamp: integer(),
            noncestr: String.t()
          }
    defstruct [:value, :timestamp, :noncestr]
  end

  @doc """
  To configure and load WeChat JSSDK in the target page's url properly, use `jsapi_ticket` and `url` to generate a signature for this scenario.
  """
  @spec sign_jssdk(jsapi_ticket :: String.t(), url :: String.t()) :: JSSDKSignature.t()
  defdelegate sign_jssdk(jsapi_ticket, url), to: Utils

  @doc """
  See `WeChat.sign_card/1`.
  """
  @spec sign_card(wxcard_ticket :: String.t(), card_id :: String.t()) :: CardSignature.t()
  defdelegate sign_card(wxcard_ticket, card_id), to: Utils

  @doc """
  See `WeChat.sign_card/1`.
  """
  @spec sign_card(wxcard_ticket :: String.t(), card_id :: String.t(), openid :: String.t()) ::
          CardSignature.t()
  defdelegate sign_card(wxcard_ticket, card_id, openid), to: Utils

  @doc """
  To initialize WeChat Card functions in JSSDK, use `wxcard_ticket` and `card_id` to generate a signature for this scenario,
  [see official document](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#65){:target="_blank"}.
  """
  @spec sign_card(list :: [String.t()]) :: CardSignature.t()
  defdelegate sign_card(list), to: Utils

  @doc """
  Call WeChat's HTTP API functions in a explicit way.

  We can defined a global module to assemble `appid`, `authorizer_appid`(only used for "component" application), and `adapter_storage`.

  For example:

  ```
  defmodule MyClient do
    use WeChat,
      appid: "...",
      adapter_storage: "..."
  end
  ```

  ```
  defmodule MyComponentClient do
    use WeChat.Component,
      appid: "...",
      authorizer_appid: "...",
      adapter_storage: "..."
  end
  ```

  And then we can use `MyClient` or `MyComponentClient` to call `request/2`, as usual, there dose NOT need to pass the above parameters when invoke, but if needed you
  input them to override.

  We can directly use `WeChat.request/2` as well, in this way, the `appid`, `authorizer_appid`(only used for "component" application), and `adapter_storage` are required
  for each invoke.

  The `method` parameter can be used as one of `t:method/0`.

  ## Options

  - `:appid`, required, if you use a global module to assemble it, this value is optional. If you are using a `common` application, `appid` means the application id of your
  WeChat Official Account; if you are a `component` application, `appid` means the application id of your WeChat Official Account third-party platform application.
  - `:authorizer_appid`, optional, if you are using a `component` application, this value is required, the application id of your WeChat Official Account third-party platform
  application.
  - `:adapter_storage`, required, the predefined storage way to used for `access_token`, `jsapi_ticket`, and `card_ticket`, here provide a `{:default, "MyHubURL"}` as option.
  - `:url`, required, the URL to call WeChat's HTTP API function, for example, "/cgi-bin/material/get_materialcount", also you can input a completed URL like
  this "https://api.weixin.qq.com/cgi-bin/material/get_materialcount".
  - `:host`, optional, the host of URI to call WeChat's HTTP API function, if you input a completed URL with host, this value is optional, by default it is "api.weixin.qq.com".
  - `:scheme`, optional, the scheme of URI to call WeChat's HTTP API function, if you input a completed URL with scheme, this value is optional, by default it is "https".
  - `:port`, optional, the port of URI to call WeChat's HTTP API function, if you input a completed URL with port, this value is optional, by default it is "443"(as integer).
  - `:body`, optional, a map, decided by your used WeChat's HTTP API functions, following WeChat official document to setup the body of the request.
  - `:query`, optional, a keyword, decided by your used WeChat's HTTP API functions, following WeChat official document to setup the query string of the request, this library will
  automatically appended a proper `access_token` into the query of each request, so we do NOT need to input `access_token` parameter.
  - `:opts`, optional, custom, per-request middleware or adapter options (exported from `Tesla`)
  """
  @spec request(method :: method(), options :: Keyword.t()) ::
          {:ok, term()} | {:error, WeChat.Error.t()}
  def request(method, options) do
    method
    |> prepare_request(options)
    |> check_adapter_storage(:all)
    |> setup_httpclient()
    |> send_request()
  end

  @doc false
  def common_request(method, options) do
    method
    |> prepare_request(options)
    |> check_adapter_storage(:common)
    |> setup_httpclient()
    |> send_request()
  end

  @doc false
  def component_request(method, options) do
    method
    |> prepare_request(options)
    |> check_adapter_storage(:component)
    |> setup_httpclient()
    |> send_request()
  end

  @doc """
  The expire time (in seconds) to `access_token` and `ticket` temporary storage,
  by default it is 7200 seconds.

  For hub scenario, both `common` and `component` application can override this function in the defined
  basic module if needed, and then can use this function as a global setting to use in `access_token` and `ticket`
  life cycle management, for example:

  ```
  defmodule MyHubComponentClient do
    use WeChat.Component,
      scenario: :hub,
      adapter_storage: MyComponentLocalStorage

    def expires_in(), do: 7000
  end
  ```

  ```
  defmodule MyHubCommonClient do
    use WeChat,
      scenario: :hub,
      adapter_storage: MyCommonLocalStorage

    def expires_in(), do: 7000
  end
  ```

  For client scenario, no need to use this function, the local registry for `access_token` and `ticket` will
  use hub's response(contain time related) when fetch/refresh `access_token` and `ticket` to manage them as a
  client side temporary cache.
  """
  @spec expires_in() :: integer()
  def expires_in(), do: 7200

  @doc """
  A function helper to fetch `common` application's access token.

  When apply it to hub, if no available access token from hub's storage, there will use
  the set account's `secret_key` to refresh a new one.
  """
  def fetch_access_token(appid, adapter_storage) when is_atom(adapter_storage) do
    fetch_access_token(appid, {adapter_storage, nil})
  end

  def fetch_access_token(appid, {adapter_storage, args}) do
    token = adapter_storage.fetch_access_token(appid, args)

    case token do
      {:ok, %WeChat.Token{access_token: access_token}} when access_token != nil ->
        token

      _ ->
        refetch_access_token(appid, adapter_storage, args)
    end
  end

  defp prepare_request(method, options) do
    uri = Utils.parse_uri(options[:url], Keyword.take(options, [:host, :scheme, :port]))

    query = options[:query] || []

    appid = options[:appid] || query[:component_appid] || query[:appid]

    %Request{
      method: check_method_opt(method),
      uri: uri,
      appid: appid,
      authorizer_appid: options[:authorizer_appid],
      body: options[:body],
      query: query,
      opts: options[:opts],
      adapter_storage: options[:adapter_storage],
      scenario: options[:scenario]
    }
  end

  defp setup_httpclient(%Request{uri: %URI{path: path}}) when path == "" or path == nil do
    raise %WeChat.Error{reason: "invalid_request", message: "url is required"}
  end

  defp setup_httpclient(%Request{uri: %URI{path: "/cgi-bin/component" <> _}} = request) do
    {Http.component_client(request), request}
  end

  defp setup_httpclient(%Request{uri: %URI{path: "cgi-bin/component" <> _}} = request) do
    {Http.component_client(request), request}
  end

  defp setup_httpclient(%Request{uri: %URI{path: "/sns/oauth2/component/" <> _}} = request) do
    {Http.component_client(request), request}
  end

  defp setup_httpclient(%Request{uri: %URI{path: "sns/oauth2/component/" <> _}} = request) do
    {Http.component_client(request), request}
  end

  defp setup_httpclient(%Request{uri: %URI{path: "/sns/component/" <> _}} = request) do
    {Http.component_client(request), request}
  end

  defp setup_httpclient(%Request{uri: %URI{path: "sns/component/" <> _}} = request) do
    {Http.component_client(request), request}
  end

  defp setup_httpclient(request) do
    {Http.client(request), request}
  end

  defp send_request({client, request}) do
    options = [
      method: request.method,
      url: URI.to_string(request.uri),
      query: request.query,
      body: request.body,
      opts: request.opts
    ]

    Http.request(client, options)
  end

  defp ensure_implements(module, available_adapter_storage_behaviours)
       when is_list(available_adapter_storage_behaviours) do
    matched =
      module.__info__(:attributes)
      |> Keyword.get(:behaviour, [])
      |> Enum.count(fn behaviour ->
        Enum.member?(available_adapter_storage_behaviours, behaviour)
      end)

    if matched != 1 do
      raise %WeChat.Error{
        reason: "invalid_config",
        message:
          "please ensure module: #{inspect(module)} implemented one of #{
            inspect(available_adapter_storage_behaviours)
          } adapter storage behaviour"
      }
    end
  end

  defp check_adapter_storage(request, :all) do
    adapter_storage = do_check_adapter_storage(request.adapter_storage, :all)
    Map.put(request, :adapter_storage, adapter_storage)
  end

  defp check_adapter_storage(request, :common) do
    adapter_storage = do_check_adapter_storage(request.adapter_storage, :common)
    Map.put(request, :adapter_storage, adapter_storage)
  end

  defp check_adapter_storage(request, :component) do
    adapter_storage = do_check_adapter_storage(request.adapter_storage, :component)
    Map.put(request, :adapter_storage, adapter_storage)
  end

  defp do_check_adapter_storage({adapter_storage, args}, :all) when is_atom(adapter_storage) do
    ensure_implements(
      adapter_storage,
      [
        WeChat.Storage.Client,
        WeChat.Storage.Hub,
        WeChat.Storage.ComponentClient,
        WeChat.Storage.ComponentHub
      ]
    )

    {adapter_storage, args}
  end

  defp do_check_adapter_storage(adapter_storage, :all) when is_atom(adapter_storage) do
    ensure_implements(
      adapter_storage,
      [
        WeChat.Storage.Client,
        WeChat.Storage.Hub,
        WeChat.Storage.ComponentClient,
        WeChat.Storage.ComponentHub
      ]
    )

    {adapter_storage, []}
  end

  defp do_check_adapter_storage(invalid, :all) do
    raise %WeChat.Error{
      reason: "invalid_config",
      message:
        "using unexpected #{inspect(invalid)} adapter storage, please use it as one of [`WeChat.Storage.Client`, `WeChat.Storage.Hub`, `WeChat.Storage.ComponentClient`, `WeChat.Storage.ComponentHub`]"
    }
  end

  defp do_check_adapter_storage({:default, hub_base_url}, :common)
       when is_bitstring(hub_base_url) do
    {WeChat.Storage.Adapter.DefaultClient, hub_base_url}
  end

  defp do_check_adapter_storage(adapter_storage, :common) when is_atom(adapter_storage) do
    ensure_implements(
      adapter_storage,
      [
        WeChat.Storage.Client,
        WeChat.Storage.Hub
      ]
    )

    {adapter_storage, []}
  end

  defp do_check_adapter_storage({adapter_storage, args}, :common)
       when is_atom(adapter_storage) do
    ensure_implements(
      adapter_storage,
      [
        WeChat.Storage.Client,
        WeChat.Storage.Hub
      ]
    )

    {adapter_storage, args}
  end

  defp do_check_adapter_storage(invalid, :common) do
    raise %WeChat.Error{
      reason: "invalid_config",
      message:
        "using unexpected #{inspect(invalid)} adapter storage, please use it as `WeChat.Storage.Client` or `WeChat.Storage.Hub`"
    }
  end

  defp do_check_adapter_storage({:default, hub_base_url}, :component)
       when is_bitstring(hub_base_url) do
    {WeChat.Storage.Adapter.DefaultComponentClient, hub_base_url}
  end

  defp do_check_adapter_storage(adapter_storage, :component) when is_atom(adapter_storage) do
    ensure_implements(
      adapter_storage,
      [
        WeChat.Storage.ComponentClient,
        WeChat.Storage.ComponentHub
      ]
    )

    {adapter_storage, []}
  end

  defp do_check_adapter_storage({adapter_storage, args}, :component)
       when is_atom(adapter_storage) do
    ensure_implements(
      adapter_storage,
      [
        WeChat.Storage.ComponentClient,
        WeChat.Storage.ComponentHub
      ]
    )

    {adapter_storage, args}
  end

  defp do_check_adapter_storage(invalid, :component) do
    raise %WeChat.Error{
      reason: "invalid_config",
      message:
        "using unexpected #{inspect(invalid)} adapter storage, please use it as `WeChat.Storage.ComponentClient` or `WeChat.Storage.ComponentHub`"
    }
  end

  defp check_method_opt(method)
       when method == :head
       when method == :get
       when method == :delete
       when method == :trace
       when method == :options
       when method == :post
       when method == :put
       when method == :patch do
    method
  end

  defp check_method_opt(method) do
    raise %WeChat.Error{
      reason: "invalid_request",
      message: "input invalid http method: #{inspect(method)}"
    }
  end

  defp refetch_access_token(appid, adapter_storage, args) do
    request_result =
      WeChat.request(
        :get,
        appid: appid,
        adapter_storage: {adapter_storage, args},
        url: "/cgi-bin/token"
      )

    case request_result do
      {:ok, %{body: %{"access_token" => access_token} = body}} ->
        {
          :ok,
          %WeChat.Token{
            access_token: access_token,
            timestamp: Map.get(body, "timestamp"),
            expires_in: Map.get(body, "expires_in")
          }
        }

      {:ok, response} ->
        raise Utils.as_error(response)

      {:error, error} ->
        raise Utils.as_error(error)
    end
  end
end