Пишем обёртку для API Токийской фондовой биржи на Golang

от автора


Целевым REST API будет jquants-api, описанный в предыдущей статье.

Я решил реализовать обёртку на Golang, что оказалось чрезвычайно быстро и удобно. В итоге я выполнил эту задачу за один вечер, а получившуюся Golang-обёртку с базовыми функциями загрузил на GitHub.

В этой статье я вкратце расскажу о процессе написания API и моих шагах по реализации проекта.

▍ Цели

Для начала перечислим задачи, которые нам предстоит выполнить:

  • Создать тест и поддерживающий код, проверяющий, что мы можем сохранять имя пользователя и пароль в файл edn, совместимый с форматом jquants-api-jvm.
  • Написать ещё один тест и поддерживающий код для получения токена обновления.
  • Написать ещё один тест и поддерживающий код для получения токена ID.
  • Написать ещё один тест и поддерживающий код с использованием токена ID для получения суточных значений.
  • Опубликовать нашу обёртку на GitHub.
  • Использовать нашу библиотеку Go в другой программе.

▍ Начнём с написания тестового случая, подготовки и сохранения структуры логина для доступа к API

Мы постоянно говорим о написании кода при помощи TDD, и теперь настало время его применить. Проверим, что у нас есть код для ввода и сохранения имени пользователя и пароля в файл edn, совместимый с форматом jquants-api-jvm.

В файле helper_test.go напишем скелет теста для функции PrepareLogin.

package jquants_api_go  import ( "fmt" "os" "testing" )  func TestPrepareLogin(t *testing.T) { PrepareLogin(os.Getenv("USERNAME"), os.Getenv("PASSWORD")) }

Здесь мы берём USERNAME и PASSWORD из окружения при помощи os.GetEnv.

Запишем функцию подготовки в файл helper.go. Она будет делать следующее:

  • Получать в качестве параметров имя пользователя и пароль.
  • Создавать экземпляр структуры Login.
  • Структурировать его как содержимое файла EDN.

Структура Login будет выглядеть просто:

type Login struct {     UserName string `edn:"mailaddress"`     Password string `edn:"password"` }

А вызов edn.Marshal будет создавать содержимое массива byte[], которое мы сможем записывать в файл, поэтому writeConfigFile просто будет вызывать os.WriteFile с массивом, возвращённым после упорядочивания в формат EDN.

func writeConfigFile(file string, content []byte) {     os.WriteFile(getConfigFile(file), content, 0664) }

Чтобы использовать библиотеку EDN, нам понадобится добавить её в файл go.mod:

require olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3

Перед запуском теста введите свои учётные данные jquants API:

export USERNAME="youremail@you.com" export PASSWORD="yourpassword"

На этом этапе у вас уже должна быть возможность выполнять go test в папке проекта, а затем получать следующий результат:

PASS ok      github.com/hellonico/jquants-api-go    1.012s

Также вы должны увидеть, что содержимое файла login.edn заполнено правильно:

cat ~/.config/jquants/login.edn

{:mailaddress "youremail@you.com" :password "yourpassword"}

▍ Использование Login для отправки HTTP-запроса jQuants API и получения RefreshToken

Второй функцией, которую надо протестировать, будет TestRefreshToken. Она отправляет HTTP-запрос POST с именем пользователя и паролем для получения в качестве ответа на вызов API токена обновления. Мы дополним файл helper_test.go новым тестовым случаем:

func TestRefreshToken(t *testing.T) {     token, _ := GetRefreshToken()     fmt.Printf("%s\n", token) }

Функция GetRefreshToken будет выполнять следующее:

  • Загружать пользователя, сохранённого в файл ранее, и подготавливать его в виде данных JSON.
  • Подготавливать HTTP-запрос с URL и отформатированным в JSON пользователем в качестве содержимого body.
  • Отправлять HTTP-запрос.
  • API вернёт данные, которые будут храниться как структура RefreshToken.
  • После этого мы будем сохранять токен обновления как файл EDN.

Поддерживающая функция GetUser загружает содержимое файла, которое было записано на предыдущем этапе. У нас уже есть структура Login, и теперь мы просто используем edn.Unmarshall() с содержимым файла.

func GetUser() Login {     s, _ := os.ReadFile(getConfigFile("login.edn"))     var user Login     edn.Unmarshal(s, &user)     return user }

Стоит заметить, что нам нужно считывать/записывать структуру Login в файл в формате EDN, а также при отправке HTTP-запроса нам требуется преобразовывать структуру в JSON.

То есть метаданные структуры Login нужно немного изменить:

type Login struct {     UserName string `edn:"mailaddress" json:"mailaddress"`     Password string `edn:"password" json:"password"` }

Также нам нужно, чтобы новая структура считывала возвращённый API токен, то есть мы хотим хранить его в виде EDN, как это происходит со структурой Login:

type RefreshToken struct {     RefreshToken string `edn:"refreshToken" json:"refreshToken"` }

И теперь у нас есть все компоненты, чтобы написать функцию GetRefreshToken:

func GetRefreshToken() (RefreshToken, error) {     // загрузка пользователя, ранее сохранённого в файл, и подготовка его в виде данных json     var user = GetUser()     data, err := json.Marshal(user)      // подготовка http-запроса с url и пользователем, отформатированным в json, в качестве контента body     url := fmt.Sprintf("%s/token/auth_user", BASE_URL)     req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))          // отправка запроса     client := http.Client{}     res, err := client.Do(req)      // API вернёт данные, которые будут храниться в структуре RefreshToken     var rt RefreshToken     json.NewDecoder(res.Body).Decode(&rt)      // также сохраним этот токен обновления в виде файла EDN     encoded, err := edn.Marshal(&rt)     writeConfigFile(REFRESH_TOKEN_FILE, encoded)      return rt, err }

Результат выполнения go test будет чуть более многословным, поскольку мы печатаем в стандартный вывод refreshToken, но тесты должны завершаться успешно!

{eyJjdHkiOiJKV1QiLC...}  PASS ok      github.com/hellonico/jquants-api-go    3.231s

▍ Получение токена ID

Из Refresh Token можно получить IdToken, то есть токен, используемый для отправки запросов к jquants API. Процесс выполнения будет почти таким же, как у GetRefreshToken, и для его поддержки нам достаточно добавить новую структуру IdToken с необходимыми метаданными для преобразования в/из edn/json.

type IdToken struct {     IdToken string `edn:"idToken" json:"idToken"` }

Остальная часть кода на этот раз будет выглядеть так:

func GetIdToken() (IdToken, error) {     var token = ReadRefreshToken()      url := fmt.Sprintf("%s/token/auth_refresh?refreshtoken=%s", BASE_URL, token.RefreshToken)      req, err := http.NewRequest(http.MethodPost, url, nil)     client := http.Client{}     res, err := client.Do(req)      var rt IdToken     json.NewDecoder(res.Body).Decode(&rt)      encoded, err := edn.Marshal(&rt)     writeConfigFile(ID_TOKEN_FILE, encoded)      return rt, err }

▍ Получение суточных котировок

Мы добрались до ядра кода обёртки, где будем использовать IdToken и запрашивать суточные котировки из HTTP API при помощи GET-запроса HTTP.

Поток выполнения кода для получения суточных котировок будет выглядеть так:

  • Как и ранее, считываем токен ID из файла EDN.
  • Подготавливаем целевой URL с кодом параметров и параметрами дат.
  • Отправляем HTTP-запрос, используя в качестве HTTP-заголовка idToken.
  • Парсим результат как структуру суточных котировок, которая будет являться срезом структур Quote.

Тестовый случай просто проверяет возврат ненулевого (nul) значения и печатает текущие котировки.

func TestDaily(t *testing.T) {     var quotes = Daily("86970", "", "20220929", "20221003")          if quotes.DailyQuotes == nil {         t.Failed()     }      for _, quote := range quotes.DailyQuotes {         fmt.Printf("%s,%f\n", quote.Date, quote.Close)     } }

Поддерживающий код для func Daily показан ниже:

func Daily(code string, date string, from string, to string) DailyQuotes {     // чтение токена id     idtoken := ReadIdToken()      // подготовка url с параметрами     baseUrl := fmt.Sprintf("%s/prices/daily_quotes?code=%s", BASE_URL, code)     var url string     if from != "" && to != "" {         url = fmt.Sprintf("%s&from=%s&to=%s", baseUrl, from, to)     } else {         url = fmt.Sprintf("%s&date=%s", baseUrl, date)     }     // отправка HTTP-запроса с использованием idToken     res := sendRequest(url, idtoken.IdToken)      // парсинг результатов в виде суточных котировок     var quotes DailyQuotes     err_ := json.NewDecoder(res.Body).Decode(&quotes)     Check(err_)     return quotes }

Теперь нам нужно заполнить пробелы:

  • Функции sendRequest требуется чуть больше подробностей.
  • Парсинг DailyQuotes на самом деле не так прост.

Давайте сначала разберёмся с sendRequest. Она задаёт заголовок при помощи http.Header. Обратите внимание, что здесь можно добавить любое количество заголовков. Затем она отправляет HTTP-запрос GET и возвращает ответ без изменений.

func sendRequest(url string, idToken string) *http.Response {      req, _ := http.NewRequest(http.MethodGet, url, nil)     req.Header = http.Header{         "Authorization": {"Bearer " + idToken},     }     client := http.Client{}      res, _ := client.Do(req)     return res }

Теперь перейдём к парсингу суточных котировок. Если вы пользуетесь редактором GoLand, то заметите, что при копировании и вставке содержимого JSON в файл на Go редактор спросит, нужно ли напрямую преобразовать JSON в код на Go!

Довольно неплохо.

type Quote struct {     Code             string   `json:"Code"`     Close            float64  `json:"Close"`     Date             JSONTime `json:"Date"`     AdjustmentHigh   float64  `json:"AdjustmentHigh"`     Volume           float64  `json:"Volume"`     TurnoverValue    float64  `json:"TurnoverValue"`     AdjustmentClose  float64  `json:"AdjustmentClose"`     AdjustmentLow    float64  `json:"AdjustmentLow"`     Low              float64  `json:"Low"`     High             float64  `json:"High"`     Open             float64  `json:"Open"`     AdjustmentOpen   float64  `json:"AdjustmentOpen"`     AdjustmentFactor float64  `json:"AdjustmentFactor"`     AdjustmentVolume float64  `json:"AdjustmentVolume"` }  type DailyQuotes struct {     DailyQuotes []Quote `json:"daily_quotes"` }

Хотя стандартные параметры очень хороши, нам нужно настроить их, чтобы правильно преобразовать Dates обратно. Всё, что идёт далее, взято из поста о том, как преобразовывать даты JSON.

Тип JSONTime хранит свою внутреннюю дату как 64-битное integer, и мы добавляем JSONTime функции для преобразования/обратного преобразования JSONTime. Как видно, значение времени, получаемое из содержимого JSON, может быть или строкой, или integer.

type JSONTime int64  // String преобразует метку времени unix в string func (t JSONTime) String() string {     tm := t.Time()     return fmt.Sprintf("\"%s\"", tm.Format("2006-01-02")) }  // Time возвращает это значение в виде time.Time. func (t JSONTime) Time() time.Time {     return time.Unix(int64(t), 0) }  // UnmarshalJSON производит обратное преобразование значений string и int JSON func (t *JSONTime) UnmarshalJSON(buf []byte) error {     s := bytes.Trim(buf, `"`)     aa, _ := time.Parse("20060102", string(s))     *t = JSONTime(aa.Unix())     return nil }

Изначально написанный тестовый случай теперь должен успешно завершаться с go test.

"2022-09-29",1952.000000 "2022-09-30",1952.500000 "2022-10-03",1946.000000 PASS ok      github.com/hellonico/jquants-api-go    1.883s

Наш вспомогательный файл готов, теперь можно добавлять к нему CI.

▍ Конфигурация CircleCI

Конфигурация посимвольно схожа с официальной документацией CircleCI по тестированию с Golang.

Мы просто обновим образ Docker до 1.17.

version: 2.1 jobs:   build:     working_directory: ~/repo     docker:       - image: cimg/go:1.17.9     steps:       - checkout       - restore_cache:           keys:             - go-mod-v4-{{ checksum "go.sum" }}       - run:           name: Install Dependencies           command: go get ./...       - save_cache:           key: go-mod-v4-{{ checksum "go.sum" }}           paths:             - "/go/pkg/mod"       - run: go test -v

Теперь мы готовы настроить проект на CircleCI:

Требуемые параметры USERNAME и PASSWORD в helper_test.go can можно установить непосредственно из настроек Environment Variables проекта CircleCI:

Любой коммит в основную ветвь будет запускать сборку CircleCI (разумеется, её можно запускать и вручную), и если всё в порядке, вы должны увидеть успешно выполненные этапы:

Наша обёртка хорошо протестирована. Теперь приступим к её публикации.

▍ Публикация библиотеки на GitHub

При условии, что наш файл go.mod имеет следующее содержимое:

module github.com/hellonico/jquants-api-go  go 1.17  require olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3

Удобнее всего будет публиковать код при помощи git tag. Давайте создадим git tag и запушим его в GitHub следующим образом:

git tag v0.6.0 git push --tags

Теперь можно обеспечить зависимость отдельного проекта от нашей библиотеки, использовав её в go.mod.

require github.com/hellonico/jquants-api-go v0.6.0

▍ Использование библиотеки из внешней программы

Наша простая программа будет парсить параметры при помощи модуля flag, а затем вызывать различные функции, как это происходило в тестовых случаях для нашей обёртки.

package main  import (     "flag"     "fmt"     jquants "github.com/hellonico/jquants-api-go" )  func main() {      code := flag.String("code", "86970", "Company Code")     date := flag.String("date", "20220930", "Date of the quote")     from := flag.String("from", "", "Start Date for date range")     to := flag.String("to", "", "End Date for date range")     refreshToken := flag.Bool("refresh", false, "refresh RefreshToken")     refreshId := flag.Bool("id", false, "refresh IdToken")      flag.Parse()      if *refreshToken {         jquants.GetRefreshToken()     }     if *refreshId {         jquants.GetIdToken()     }      var quotes = jquants.Daily(*code, *date, *from, *to)      fmt.Printf("[%d] Daily Quotes for %s \n", len(quotes.DailyQuotes), *code)     for _, quote := range quotes.DailyQuotes {         fmt.Printf("%s,%f\n", quote.Date, quote.Close)     }  }

Мы можем создать CLI при помощи go build.

go build

И выполнить его с нужными параметрами:

  • Обновление токена ID.
  • Обновление токена обновления.
  • Получение суточных значений для записи с кодом 86970 с 20221005 по 20221010.

./jquants-example --id --refresh --from=20221005 --to=20221010 --code=86970  Code: 86970 and Date: 20220930 [From: 20221005 To: 20221010] [3] Daily Quotes for 86970  "2022-10-05",2016.500000 "2022-10-06",2029.000000 "2022-10-07",1992.500000

Отличная работа. Мы оставим пользователю задачу написания statements и listedInfo, являющихся частью JQuants API, но пока не реализованных в этой оболочке.

Telegram-канал с полезностями и уютный чат


ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/698740/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *