
Целевым 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("es) 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-канал с полезностями и уютный чат

