OpenAPI GeneratorでMoralis APIへのElixirによるクライアントを作る
ブロックチェーンへのアクセスを簡便化するHTTP APIを提供するMoralis » The Ultimate Web3 Development Platformというサービスがあります。そのAPIは、OpenAPIで仕様が記述されています。この記事では、そのMoralisのAPIをElixirから使えるようにしてみたいと思います。
今回試したコードはkentaro/elixir-moralisに置いてあります。適宜ご参照ください。
OpenAPIとは
OpenAPI(正確にはThe OpenAPI Specification)とは、HTTPによるAPI仕様を記述するための仕様です(OpenAPI Specification v3.1.0 | Introduction, Definitions, & More)。OpenAPIで記述されたAPI仕様(スキーマ。JSONやYAMLで書ける)をなかだちにすることで、APIドキュメント、クライアントとサーバの実装を自動生成したりできる、便利な仕組みです。
この記事における関心事に沿っていうと、OpenAPIで記述された仕様に基づいてAPIクライアントの実装を自動生成するために利用します。ここでは、ジェネレータとしてopenapi-generator-cliを使ってみましょう。npm
コマンドを用いてopenapi-generator-cli
をインストールします。
$ npm install @openapitools/openapi-generator-cli -g
Moralis APIの仕様
Moralis APIの仕様はMoralis Admin(要ユーザ登録)にまとまっています。また、 https://deep-index.moralis.io/api-docs/v2/swagger.json にOpenAPI 3.0.0で記述されたJSONファイルがあるので、Swagger Editorで読み込むと、いい感じに表示してくれます。
APIクライアントライブラリの作成
プロジェクトの準備
ここでやりたいことは以下の通りです。
- OpenAPIで記述されたスキーマからElixirによるクライアント実装を生成する
- 生成された実装を利用してAPIにアクセスするライブラリを作成する
後述で実行するopenapi-generator-cli
は、mixプロジェクトとしてクライアント実装を生成します。そのため、その実装を用いるライブラリを構成するmixプロジェクトと併存する形でパッケージを構成する必要があります。そこでアンブレラプロジェクトという仕組みが使えます。
mix new
する際に--umbrella
オプションをつけることで、アンブレラプロジェクトとしてプロジェクトを作成できます。
$ mix new moralis --umbrella
* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating apps
* creating config
* creating config/config.exs
Your umbrella project was created successfully.
Inside your project, you will find an apps/ directory
where you can create and host many apps:
cd moralis
cd apps
mix new my_app
Commands like "mix compile" and "mix test" when executed
in the umbrella project root will automatically run
for each application in the apps/ directory.
$ mv moralis/ elixir-moralis
$ cd elixir-moralis
まずは、クライアントライブラリの本体の方から作成していきます。apps/
ディレクトリに作りましょう。
$ cd apps/
$ mix new moralis
次に、OpenAPIで記述されたスキーマから、APIクライアントのElixir実装を生成します。
ドキュメントにある通りに実行するとspecのvalidationエラーが出るので、Documentation for the elixir Generator
を参考にして--skip-validate-spec
でいったん無効化し、以下の通りopenapi-generator-cli
コマンドを実行しましょう。また、モジュール名としてMoralis
を指定しているのもポイントです。
$ openapi-generator-cli generate -i https://deep-index.moralis.io/api-docs/v2/swagger.json -g elixir -o ./gen --skip-validate-spec --invoker-package Moralis
Did set selected version to 5.4.0
[main] WARN o.o.c.config.CodegenConfigurator - There were issues with the specification, but validation has been explicitly disabled.
Errors:
-attribute components.schemas.trade.items is missing
Warnings:
-Unused model: nftContractMetadataCollection
-Unused model: historicalNftTransfer
-Unused model: erc721Metadata
(省略)
################################################################################
# Thanks for using OpenAPI Generator. #
# Please consider donation to help us maintain this project 🙏 #
# https://opencollective.com/openapi_generator/donate #
################################################################################
ディレクトリ構成はこんな感じになっているはずです。
$ tree -L 2
.
├── README.md
├── apps
│ ├── gen
│ ├── moralis
│ └── openapitools.json
├── config
│ └── config.exs
└── mix.exs
4 directories, 4 files
生成されたコードを見てみる
APIの利用用途として、ここではあるアドレスにひもづくNFTの一覧を取得してみたいと思います。それに該当するコードを見てみましょう。
このようなドキュメントが生成されています(apps/gen/lib/moralis/api/account.ex
)。
Gets the NFTs owned by a given address
Gets NFTs owned by the given address * The response will include status [SYNCED/SYNCING] based on the contracts being indexed. * Use the token_address param to get results for a specific contract only * Note results will include all indexed NFTs * Any request which includes the token_address param will start the indexing process for that NFT collection the very first time it is requested
## Parameters
- connection (Moralis.Connection): Connection to server
- address (String.t): The owner of a given token
- opts (KeywordList): [optional] Optional parameters
- :chain (Moralis.Model.ChainList.t): The chain to query
- :format (String.t): The format of the token id
- :offset (integer()): offset
- :limit (integer()): limit
- :token_addresses ([String.t]): The addresses to get balances for (Optional)
- :cursor (String.t): The cursor returned in the last response (for getting the next page)
## Returns
{:ok, Moralis.Model.NftOwnerCollection.t} on success
{:error, Tesla.Env.t} on failure
対応するコードはこの通り(apps/gen/lib/moralis/api/account.ex
)。specもしっかり定義されていますね。
@spec get_nfts(Tesla.Env.client, String.t, keyword()) :: {:ok, Moralis.Model.NftOwnerCollection.t} | {:error, Tesla.Env.t}
def get_nfts(connection, address, opts \\ []) do
optional_params = %{
:"chain" => :query,
:"format" => :query,
:"offset" => :query,
:"limit" => :query,
:"token_addresses" => :query,
:"cursor" => :query
}
%{}
|> method(:get)
|> url("/#{address}/nft")
|> add_optional_params(optional_params, opts)
|> Enum.into([])
|> (&Connection.request(connection, &1)).()
|> evaluate_response([
{ 200, %Moralis.Model.NftOwnerCollection{}}
])
end
ファイルの書き換え
ジェネレータで生成されたファイルを書き換えるのはよくないのですが、mix.exs
がエラーになるので、version
の箇所を0.0.1
等に手動で書き換えてしまいます。ついでに、Mix.Project
の名前がバッティングするのを適当な名前に変更しておきました。
diff --git a/apps/gen/mix.exs b/apps/gen/mix.exs
index 2bcb487..c38f7fc 100644
--- a/apps/gen/mix.exs
+++ b/apps/gen/mix.exs
@@ -1,9 +1,9 @@
-defmodule Moralis.Mixfile do
+defmodule Moralis.Gen.Mixfile do
use Mix.Project
def project do
- [app: :moralis,
- version: "2",
+ [app: :gen,
+ version: "0.0.1",
elixir: "~> 1.6",
build_embedded: Mix.env == :prod,
start_permanent: Mix.env == :prod,
diff --git a/apps/moralis/mix.exs b/apps/moralis/mix.exs
index a75adcf..c2d36d6 100644
--- a/apps/moralis/mix.exs
+++ b/apps/moralis/mix.exs
@@ -1,4 +1,4 @@
-defmodule Moralis.MixProject do
+defmodule Moralis.Core.MixProject do
use Mix.Project
def project do
クライアントモジュールの作成
生成された実装を用いてAPIへアクセスするクライアントのコードを書きます(apps/moralis/lib/moralis.ex
)。生成された実装では、渡されたTesla.Env.client
を使ってAPIにアクセスするようになっているので、環境変数からAPIキーを読み込んでセットしたクライアントを生成するコードを書いています。
defmodule Moralis do
@moduledoc """
Handle Tesla connections for Moralis.
"""
@doc """
Configure an authless client connection
# Returns
Tesla.Env.client
"""
@spec client() :: Tesla.Env.client
def client do
[
{Tesla.Middleware.BaseUrl, "https://deep-index.moralis.io/api/v2"},
{Tesla.Middleware.EncodeJson, engine: Poison},
{Tesla.Middleware.Headers, [{"x-api-key", System.get_env("MORALIS_API_KEY")}]}
]
|> Tesla.client()
end
end
また、apps/moralis
の方のmix.exs
に、上記の依存ライブラリを追加しておきましょう。
diff --git a/apps/moralis/mix.exs b/apps/moralis/mix.exs
index c2d36d6..18dba6b 100644
--- a/apps/moralis/mix.exs
+++ b/apps/moralis/mix.exs
@@ -28,6 +28,8 @@ defmodule Moralis.Core.MixProject do
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
# {:sibling_app_in_umbrella, in_umbrella: true}
+ {:tesla, "~> 1.2"},
+ {:poison, "~> 3.0"}
]
end
end
APIクライアントを実行する
環境変数に、Moralis Admin取得できるAPIキーをセットしておきましょう。
$ export MORALIS_API_KEY="********************"
あとは、iexで起動して、以下のようにして実行するだけです。ここでは、僕のEthereum上のアドレス0xC8cFA9Ab96B9e78961607d485c50135059C84840
がひもづけられた、Ethereumのメインネット上のNFTの一覧を取得しています。
$ iex -S mix
iex(1)> Moralis.client |> Moralis.Api.Account.get_nfts("0xC8cFA9Ab96B9e78961607d485c50135059C84840", chain: :eth, format: :decimal)
21:45:43.419 [warning] Description: 'Authenticity is not established by certificate path validation'
Reason: 'Option {verify, verify_peer} and cacertfile/cacerts is missing'
{:ok,
%Moralis.Model.NftOwnerCollection{
cursor: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3aGVyZSI6eyJvd25lcl9vZiI6IjB4YzhjZmE5YWI5NmI5ZTc4OTYxNjA3ZDQ4NWM1MDEzNTA1OWM4NDg0MCJ9LCJsaW1pdCI6NTAwLCJvZmZzZXQiOjUwMCwib3JkZXIiOltbInRyYW5zZmVyX2luZGV4IiwiREVTQyJdXSwicGFnZSI6MSwia2V5IjoiMTQxNzcwMDguMzcuNTUuMCIsImlhdCI6MTY0NzQzNDc0M30.HfVwM7LtsZ6M1vY75lnHM7cLcrkKOmRlpDYrWyVCGTY",
page: 0,
page_size: 500,
result: [
%Moralis.Model.NftOwner{
amount: "1",
block_number: "14255778",
block_number_minted: "14255778",
contract_type: "ERC721",
metadata: "{\"is_normalized\":true,\"name\":\"antipop.eth\",\"description\":\"antipop.eth, an ENS name.\",\"attributes\":[{\"trait_type\":\"Created Date\",\"display_type\":\"date\",\"value\":null},{\"trait_type\":\"Length\",\"display_type\":\"number\",\"value\":7},{\"trait_type\":\"Registration Date\",\"display_type\":\"date\",\"value\":1645531685000},{\"trait_type\":\"Expiration Date\",\"display_type\":\"date\",\"value\":1803316445000}],\"name_length\":7,\"url\":\"https://app.ens.domains/name/antipop.eth\",\"version\":0,\"background_image\":\"https://metadata.ens.domains/mainnet/avatar/antipop.eth\",\"image_url\":\"https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/0x681d2af5f952f2f3ced409b235e8b9ba1516133c8496c650394a5793562824e1/image\"}",
name: "",
owner_of: "0xc8cfa9ab96b9e78961607d485c50135059c84840",
symbol: "",
synced_at: "2022-02-24T20:10:07.244Z",
token_address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85",
token_id: "47092071322328660070279121323087064708284557536712030821400426890975974532321",
token_uri: "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/47092071322328660070279121323087064708284557536712030821400426890975974532321"
},
%Moralis.Model.NftOwner{
amount: "1",
block_number: "14246649",
block_number_minted: "14246649",
contract_type: "ERC721",
metadata: "{\n \"name\": \"Community Statement on \\\"NFT art\\\"\",\n \"description\": \"This is an NFT indicating that you have signed the Community Statement on \\\"NFT art\\\".\\n\\nWebsite: [https://nft-art-statement.github.io](https://nft-art-statement.github.io)\\n\\nPDF: [https://ipfs.io/ipfs/Qmbuc7FMZ2qsUjSMtTG6FoD6sAigCzS9AyJUtQF2cMX4Qe](https://ipfs.io/ipfs/Qmbuc7FMZ2qsUjSMtTG6FoD6sAigCzS9AyJUtQF2cMX4Qe)\\n\\nOriginal image script: [https://openprocessing.org/sketch/1491110](https://openprocessing.org/sketch/1491110)\",\n \"image\": \"https://ipfs.io/ipfs/QmURcYa9U1juYWTQaeNe2Cj9Xbxt6yXuZfx2G9XqhbGD7k\",\n \"external_url\": \"https://nft-art-statement.github.io\"\n}",
name: "Community Statement on NFT art",
owner_of: "0xc8cfa9ab96b9e78961607d485c50135059c84840",
symbol: "CSNA",
synced_at: "2022-02-21T02:06:51.280Z",
token_address: "0x01a45dfef5fc495f1b4c146319b79808be464dba",
token_id: "1146429188785820425206666927417020747748991322176",
token_uri: "https://ipfs.io/ipfs/QmXtwT89TTySmJYpvU9mNWi46Ro44x3pT5yh9m6yFN7Uy4"
},
%Moralis.Model.NftOwner{
amount: "1",
block_number: "14218466",
block_number_minted: "14218466",
contract_type: "ERC1155",
metadata: "{\"name\":\"yellow tent\",\"description\":null,\"external_link\":null,\"image\":\"https://lh3.googleusercontent.com/AuRzNcrrInhkOIG6oqxALSM2Skn1OiOlp9tZyupx59zP-T50AaiNeydvsFLH2BhvIp0wJOtqWMJeJZ7QD4Xhlpl9HAHsJA6hfnX_2w\",\"animation_url\":null}",
name: "OpenSea Shared Storefront",
owner_of: "0xc8cfa9ab96b9e78961607d485c50135059c84840",
symbol: "OPENSTORE",
synced_at: "2022-02-16T17:28:39.722Z",
token_address: "0x495f947276749ce646f68ac8c248420045cb7b5e",
token_id: "73836485752685965752355914628923343070930493146497138125618145467993293848577",
token_uri: "https://api.opensea.io/api/v1/metadata/0x495f947276749Ce646f68AC8c248420045cb7b5e/0xa33df84efd75309ae7ceb5aea44e1c7c02d98f4b000000000000010000000001"
},
%Moralis.Model.NftOwner{
amount: "1",
block_number: "14177008",
block_number_minted: "14177008",
contract_type: "ERC721",
metadata: "{\"is_normalized\":true,\"name\":\"kentarokuribayashi.eth\",\"description\":\"kentarokuribayashi.eth, an ENS name.\",\"attributes\":[{\"trait_type\":\"Created Date\",\"display_type\":\"date\",\"value\":null},{\"trait_type\":\"Length\",\"display_type\":\"number\",\"value\":18},{\"trait_type\":\"Registration Date\",\"display_type\":\"date\",\"value\":1644478257000},{\"trait_type\":\"Expiration Date\",\"display_type\":\"date\",\"value\":1802263017000}],\"name_length\":18,\"url\":\"https://app.ens.domains/name/kentarokuribayashi.eth\",\"version\":0,\"background_image\":\"https://metadata.ens.domains/mainnet/avatar/kentarokuribayashi.eth\",\"image_url\":\"https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/0xcb266aacf362d37fef19506ce033febea8121aa26f8874a71703c26158a69b01/image\"}",
name: "",
owner_of: "0xc8cfa9ab96b9e78961607d485c50135059c84840",
symbol: "",
synced_at: "2022-02-23T15:28:07.577Z",
token_address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85",
token_id: "91887384698719783598076195110397135275850003826252571321438245958937884400385",
token_uri: "https://metadata.ens.domains/mainnet/0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/91887384698719783598076195110397135275850003826252571321438245958937884400385"
}
],
status: "SYNCED",
total: 4
}}
おわりに
以上見てきた通り、簡単にAPIクライアントを作成し、APIから望む結果を取得することができました。次のステップとしては、OpenAPIのスキーマからのクライアント実装をGitHub Actions等で自動化することですが、上記した通りそのままだとエラーになってしまう箇所があるため、今回はそこまではしませんでした。その辺りが解決したら取り組んでみたいと思います。
Discussion