🗂

OpenAPI GeneratorでMoralis APIへのElixirによるクライアントを作る

2022/03/16に公開

ブロックチェーンへのアクセスを簡便化する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