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