LoginSignup
10
8

More than 5 years have passed since last update.

Servantで認証付きAPIのクライアントを作ってcoincheckから自分の取引履歴をExcelで出力する

Posted at

やりたいことは表題の通りです。ポイントは以下の2つ

  • 認証つきAPIのクライアントを作る
  • Excelで出力する

活躍するのはservant-clientxlsxというライブラリです。ServantやLensに慣れていない人は以下のリンクを参考に勉強してみてください :mortar_board:

データ構造を定義する

まずは取得するトランザクションのデータ構造を定義しましょう。今回利用するAPIはcoincheckの注文の取引履歴のAPIです。

app/Types.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}

module Types where

import GHC.Generics
import Data.Aeson

data Funds = Funds
  { btc :: String
  , jpy :: String
  } deriving (Show, Generic, FromJSON, ToJSON)

data Transaction = Transaction
  { id           :: Int
  , order_id     :: Int
  , created_at   :: String
  , funds        :: Funds
  , pair         :: String
  , rate         :: String
  , fee_currency :: Maybe String
  , fee          :: String
  , liquidity    :: String
  , side         :: String
  } deriving (Show, Generic, FromJSON, ToJSON)

data Transactions = Transactions
  { success      :: Bool
  , transactions :: [Transaction]
  } deriving (Show, Generic, FromJSON, ToJSON)

難しいことはしていないので少し眺めてそんなものかと思ってもらえれば大丈夫です。

クライアントを作る

それではAPIにアクセスするクライアントを作っていきましょう。
これからアクセスするAPIはPrivateで認証がかかっておりアクセスするにはAPI Keyを発行する必要があります。

coincheckではAPI Keyを作成する際に細かい権限の制御ができます。今回は注文の取引履歴しか取得しないのでそれだけチェックを入れておきます。

API Keyを作ったらいよいよコードを書いていきましょう
まずは作ったAPI Keyを管理するデータ構造を定義します

import Data.Time
import Data.ByteString.Lazy.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as BL

data APIKey = APIKey
  { key    :: ByteString
  , secret :: ByteString
  }

type Auth = (APIKey, ByteString)

genAuth :: APIKey ->  IO Auth
genAuth token = do
  nonce <-  BL.pack . formatTime defaultTimeLocale "%s" <$> getCurrentTime
  pure (token, nonce)

認証時に使うnonceは時刻を利用しているので副作用を伴うgenAuthを経由して取得するようにしておきます。APIKeyAuthを分けることでnonceの生成を必ず行うようにしています。

次にServant DSLを使ってAPIの定義を書いていきます。

{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ScopedTypeVariables #-}

import Data.Digest.Pure.SHA
import Data.Proxy
import Servant.API
import qualified Servant.Common.Req as Req

data Private

type CoincheckAPI = "api" :> "exchange" :> "orders" :> "transactions" :> Private :> Get '[JSON] Transactions

instance (HasClient sublayout) => HasClient (Private :> sublayout) where
  type Client (Private :> sublayout) = Auth -> Client sublayout

  clientWithRoute _ req baseurl (apiKey, nonce) =
    let url = "https://coincheck.jp" ++ Req.reqPath req
        body = maybe "" fst $ Req.reqBody req
        signature = showDigest $ hmacSha256 (secret apiKey) (mconcat [nonce, BL.pack url, body])
        req' = Req.addHeader "ACCESS-KEY" (BL.unpack $ key apiKey)
               . Req.addHeader "ACCESS-NONCE" (BL.unpack nonce)
               . Req.addHeader "ACCESS-SIGNATURE" signature
               $ req
    in clientWithRoute (Proxy :: Proxy sublayout) req' baseurl

Privateという新しいServant DSLを定義しています。今のところHasClientのインスタンスのみ作っており、これがある場合はAuthを使って認証情報を付加したリクエストを生成してくれるようになります。今回は一つのエンドポイントしか使いませんが複数ある時でもPrivateというDSLを加えるだけで認証に対応したクライアントを作ってくれるのでとても便利です。

クライアントを作っておきましょう。

import Control.Monad.Trans.Either
import Data.Proxy
import Servant.Client

coincheckAPI :: Proxy CoincheckAPI
coincheckAPI = Proxy

getExchangeOrdersTransactions :: Auth -> EitherT ServantError IO Transactions
getExchangeOrdersTransactions = client coincheckAPI $ BaseUrl Https "coincheck.jp" 443

Excelで出力する

Excel...
でもHaskellならExcelのファイルを作るのも簡単です!
というか必要があってxlsxを使ったらマジ便利だったので今回の記事を書いた次第です

使い方はWorksheetとかのデータ構造を作ってLensで値を突っ込んで終わり!

import Codec.Xlsx
import Control.Lens
import System.Time

createExcel :: [Transaction] -> IO ()
createExcel ts = do
  ct <- getClockTime
  BL.writeFile "取引履歴.xlsx" $ fromXlsx ct xlsx
    where
      setHeader sheet = sheet & cellValueAt (1,1) ?~ CellText "Order ID"
                              & cellValueAt (1,2) ?~ CellText "Side"
                              & cellValueAt (1,3) ?~ CellText "Rate"
                              & cellValueAt (1,4) ?~ CellText "Liquidity"
                              & cellValueAt (1,5) ?~ CellText "BTC"
                              & cellValueAt (1,6) ?~ CellText "Date"
      setTs (t, n) sheet = sheet & cellValueAt (n,1) ?~ CellDouble (fromIntegral . order_id $ t)
                                 & cellValueAt (n,2) ?~ CellText (Text.pack . side $ t)
                                 & cellValueAt (n,3) ?~ CellText (Text.pack . rate $ t)
                                 & cellValueAt (n,4) ?~ CellText (Text.pack . liquidity $ t)
                                 & cellValueAt (n,5) ?~ CellText (Text.pack . btc . funds $ t)
                                 & cellValueAt (n,6) ?~ CellText (Text.pack . created_at $ t)
      xlsx = def & atSheet "Transactions" ?~ (foldr setTs (setHeader def) $ zip ts [2..])

今までの関数を組み合わせて表題のプログラムを完成させましょう。アクセスキーとシークレットは伏せてありますw

main :: IO ()
main = do
  let apiKey = APIKey { key = "access-key", secret = "access-secret" }
  auth <- genAuth apiKey
  ts <- transactions . either (error . show) id <$> (runEitherT $ getExchangeOrdersTransactions auth)
  createExcel ts

成果物をプレビューしようとしたら家のPCにExcelが入ってなかったのでMacのプレビューで確認しました。2件しかありませんがちゃんと取引履歴が出力されていますね!

コメント下さい :bow:

出来あがったコードですがいくつか不満なところがあります。

まず自分で作ったServant DSL Privateの使い方なのですが今は

type CoincheckAPI = "api" :> "exchange" :> "orders" :> "transactions" :> Private :> Get '[JSON] Transactions

このように使っていますが本当は

type CoincheckAPI = Private :> "api" :> "exchange" :> "orders" :> "transactions" :> Get '[JSON] Transactions

このように先頭で宣言したいです。この方が

type CoincheckAPI = Private :>
                          ("api" :> "exchange" :> "orders" :> "transactions" :> Get '[JSON] Transactions
                      :<|> "api" :> "exchange" :> "leverage" :> "positions" :> Get '[JSON] Positions
                      :<|> "api" :> "accounts" :> "balance" :> Get '[JSON] Balance)

のようにPrivateなAPIだけまとめてしまって見やすく宣言することが出来るはずです。しかし認証情報を作成するにはリクエストのBodyの情報が必要なのですがPrivateを先頭に書いてしまうとそれに対応するHasClientのメソッドclientWithRouteが実行されるタイミングではまだリクエストのBodyが存在しないということが起こりえます。なので今回はGetの直前に書きました。何とかして先頭で宣言する方法はないでしょうか…

次にnonceの生成タイミングなのですが、今回はgenAuthという関数でごまかしましたが本当はクライアントにはAPIKeyだけ渡してしまえば十分なはずです。しかし処理を記述するclientWithRouteの型はProxy layout -> Req -> BaseUrl -> Client layoutとなっていて直接はIOが現れていないのでgetCurrentTimeが使えずnonceも生成できません。ですが結果的にClient layoutIOの上にモナド交換子を積んだような型になるので実行は可能なはずです。何とかしてclientWithRouteの実装でnonceを生成する方法はないでしょうか…

最後はtimeの使い方の話なのですが、nonceを生成している部分の

formatTime defaultTimeLocale "%s" <$> getCurrentTime

ですが、実はこれ秒までしかとれていません。なので実際は1秒以内に複数リクエストを送ると失敗してしまいます :sweat: 修正する方法はとても簡単でミリ秒まで取得するようにすればいいのですが、timeのフォーマッタではミリ秒を直接手に入れることが出来ず秒より細かい値を取得しようと思ったらいきなりピコ秒になってしまします。ピコ秒にすると今度はcoincheckが設けている値の上限に達してしまうので使えません。結果は文字列なので反転してdropして反転すれば欲しい値は手に入るのですがあまりいい方法ではないと思うのでこの辺を柔軟に取得する方法があったら教えてほしいです :bow:


最後にcabalファイルと全コードを載せておきます

coincheck-trades-excel.cabal
name:                coincheck-trades-excel
version:             0.1.0.0
synopsis:            Initial project template from stack
license:             BSD3
build-type:          Simple
cabal-version:       >=1.10

executable coincheck-trades-excel-exe
  hs-source-dirs:      app
  main-is:             Main.hs
  ghc-options:         -threaded -rtsopts -with-rtsopts=-N
  build-depends:       base
                     , bytestring
                     , text
                     , time
                     , old-time
                     , transformers
                     , either
                     , aeson
                     , SHA
                     , servant
                     , servant-client
                     , lens
                     , xlsx
  default-language:    Haskell2010
app/Types.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}

module Types where

import GHC.Generics
import Data.Aeson

data Funds = Funds
  { btc :: String
  , jpy :: String
  } deriving (Show, Generic, FromJSON, ToJSON)

data Transaction = Transaction
  { id           :: Int
  , order_id     :: Int
  , created_at   :: String
  , funds        :: Funds
  , pair         :: String
  , rate         :: String
  , fee_currency :: Maybe String
  , fee          :: String
  , liquidity    :: String
  , side         :: String
  } deriving (Show, Generic, FromJSON, ToJSON)

data Transactions = Transactions
  { success      :: Bool
  , transactions :: [Transaction]
  } deriving (Show, Generic, FromJSON, ToJSON)
app/Main.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Main where

import Codec.Xlsx
import Control.Lens
import Control.Monad.Trans.Either
import Data.ByteString.Lazy.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Data.Text as Text
import Data.Time
import Data.Digest.Pure.SHA
import Data.Proxy
import Servant.API
import Servant.Client
import qualified Servant.Common.Req as Req
import System.Time
import Types hiding (id)

data AuthToken = AuthToken
  { key    :: ByteString
  , secret :: ByteString
  }

type Auth = (AuthToken, ByteString)

genAuth :: AuthToken -> IO Auth
genAuth token = do
  nonce <- BL.pack . formatTime defaultTimeLocale "%s" <$> getCurrentTime
  pure (token, nonce)

data Private

type CoincheckAPI = "api" :> "exchange" :> "orders" :> "transactions" :> Private :> Get '[JSON] Transactions

instance (HasClient sublayout) => HasClient (Private :> sublayout) where
  type Client (Private :> sublayout) = Auth -> Client sublayout

  clientWithRoute _ req baseurl (apiKey, nonce) =
    let url = "https://coincheck.jp" ++ Req.reqPath req
        body = maybe "" fst $ Req.reqBody req
        signature = showDigest $ hmacSha256 (secret apiKey) (mconcat [nonce, BL.pack url, body])
        req' = Req.addHeader "ACCESS-KEY" (BL.unpack $ key apiKey)
               . Req.addHeader "ACCESS-NONCE" (BL.unpack nonce)
               . Req.addHeader "ACCESS-SIGNATURE" signature
               $ req
    in clientWithRoute (Proxy :: Proxy sublayout) req' baseurl

coincheckAPI :: Proxy CoincheckAPI
coincheckAPI = Proxy

getExchangeOrdersTransactions :: Auth -> EitherT ServantError IO Transactions
getExchangeOrdersTransactions = client coincheckAPI $ BaseUrl Https "coincheck.jp" 443

createExcel :: [Transaction] -> IO ()
createExcel ts = do
  ct <- getClockTime
  BL.writeFile "取引履歴.xlsx" $ fromXlsx ct xlsx
    where
      setHeader sheet = sheet & cellValueAt (1,1) ?~ CellText "Order ID"
                              & cellValueAt (1,2) ?~ CellText "Side"
                              & cellValueAt (1,3) ?~ CellText "Rate"
                              & cellValueAt (1,4) ?~ CellText "Liquidity"
                              & cellValueAt (1,5) ?~ CellText "BTC"
                              & cellValueAt (1,6) ?~ CellText "Date"
      setTs (t, n) sheet = sheet & cellValueAt (n,1) ?~ CellDouble (fromIntegral . order_id $ t)
                                 & cellValueAt (n,2) ?~ CellText (Text.pack . side $ t)
                                 & cellValueAt (n,3) ?~ CellText (Text.pack . rate $ t)
                                 & cellValueAt (n,4) ?~ CellText (Text.pack . liquidity $ t)
                                 & cellValueAt (n,5) ?~ CellText (Text.pack . btc . funds $ t)
                                 & cellValueAt (n,6) ?~ CellText (Text.pack . created_at $ t)
      xlsx = def & atSheet "Transactions" ?~ (foldr setTs (setHeader def) $ zip ts [2..])

main :: IO ()
main = do
  let apiKey = APIKey { key = "access-key", secret = "access-secret" }
  auth <- genAuth apiKey
  ts <- transactions . either (error . show) id <$> (runEitherT $ getExchangeOrdersTransactions auth)
  createExcel ts
10
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8