Пишем REST API клиента на Go
Api клиенты, очень полезны когда вы выставляете ваш REST API на публику. И go делает это проще для вас, как для разработчика, так же как и для ваших пользователей, спасибо его структуре и типу систем. Но как определить хороший Api клиент?
В этой инструкции, мы собираемся обсудить несколько хороших практик написания SDK на Go.
Мы будем исполльзовть Facest.io Api как пример.
Прежде чем начнем писать какой-то код, мы одлжны изучить API чтобы понять главные его вещи, такие как:
- Что такое "Base url", и можно ли его поменять?
- Поддерживает ли он версионирование?
- Какие ошибки можно встретить?
- Каким образом аутентифицируются клиенты?
Ответы на эти вопросы помогут вам создать правильную структуру.
Начнем с основ. Создадим репозиторй, выберем название, в идеаеле одно должно совпадать с именем API сервиса. Инициализирум go модули. Создадим нашу главную структуру для хранения пользовательской информации. Это структура, в последствии, будет содержать точки доступа API как функции.
Структуры дложны быть гибкими, но так же ограниченными. чтобы пользователь не могу увидеть внутренние поля.
Создаем поля BaseURL
и HTTPClient
экспортируемыми, чтобы пользователь мог использовать их в своём HTTP клиенте, если это нужно.
package facest
import (
"net/http"
"time"
)
const (
BaseURLV1 = "https://api.facest.io/v1"
)
type Client struct {
BaseURL string
apiKey string
HTTPClient *http.Client
}
func NewClient(apiKey string) *Client {
return &Client{
BaseURL: BaseURLV1,
apiKey: apiKey,
HTTPClient: &http.Client{
Timeout: time.Minute,
},
}
}
Продолжаем: реализуем "Get Faces" точку доступа, которая возвращает список результатов и поддерживает постраничный вывод, что говорит о том, что постраничный вывод - это опция ввода.
Как я указал про API, любой ответ должен всегда иметь одну и ту же структуру, чтобы мы могли определить и отделить успешный ли был ответит или нет, от данных что пришли, чтобы пользователь видел только нужную ему информацию.
type errorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
type successResponse struct {
Code int `json:"code"`
Data interface{} `json:"data"`
}
Убедитесь, что вы не пишете все точки доступа в одном .go файле, но сгрупируйте их используя отдельные файлы, для примера вы можете сгрупировать их по типу, всё что начинается с /v1/faces
идет в fasec.go
файл.
Я обычно начинаю с определения типаов, вы можете это сделать вручную сконвертировав JSON в JSON-to-Go инструменте.
package facest
import "time"
type FacesList struct {
Count int `json:"count"`
PagesCount int `json:"pages_count"`
Faces []Face `json:"faces"`
}
type Face struct {
FaceToken string `json:"face_token"`
FaceID string `json:"face_id"`
FaceImages []FaceImage `json:"face_images"`
CreatedAt time.Time `json:"created_at"`
}
type FaceImage struct {
ImageToken string `json:"image_token"`
ImageURL string `json:"image_url"`
CreatedAt time.Time `json:"created_at"`
}
Функция GetFaces
дожна поддерживать постраничный вывод а мы должны делать это добавив аргументы функции, но эти аргументы не обязательны и они могту быть изменены в будущем. Так, что стоит группировать их в специальную структуру:
type FacesListOptions struct {
Limit int `json:"limit"`
Page int `json:"page"`
}
Еще один аргумент, нашей функции должен поддерживать, и его контекст, который позволит пользователю вызывать API. Пользователи могут создавать Context
, передавая его в нашу функцию. Пример использования: отмена API вызова если он длится больше 5 секунд.
Теперь шаблон нашей функции выглядит следующим образом:
func (c *Client) GetFaces(ctx context.Context, options *FacesListOptions) (*FacesList, error) {
return nil, nil
}
Время сделать сам API:
func (c *Client) GetFaces(ctx context.Context, options *FacesListOptions) (*FacesList, error) {
limit := 100
page := 1
if options != nil {
limit = options.Limit
page = options.Page
}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/faces?limit=%d&page=%d", c.BaseURL, limit, page), nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
res := FacesList{}
if err := c.sendRequest(req, &res); err != nil {
return nil, err
}
return &res, nil
}
func (c *Client) sendRequest(req *http.Request, v interface{}) error {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Accept", "application/json; charset=utf-8")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
res, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
var errRes errorResponse
if err = json.NewDecoder(res.Body).Decode(&errRes); err == nil {
return errors.New(errRes.Message)
}
return fmt.Errorf("unknown error, status code: %d", res.StatusCode)
}
fullResponse := successResponse{
Data: v,
}
if err = json.NewDecoder(res.Body).Decode(&fullResponse); err != nil {
return err
}
return nil
}
Так как все точки доступа API работают одинаково, функция помошника sendRequest
создана, чтобы избежать повторения в коде. Он задает большиство заголовков(content type, auth header), создает запрос, проверяет на ошибки, парсит ответ.
Отменит, что мы предполагаем ответы < 200 и >= 400, как ошибки, в этом случае парсится ответ из errorResponse
. Понятно, что это зависит от архитектуры API, ваша Api может по разному обрабатывать ошибки.
Тестирование
Теперь у нас есть SDK с покрытием одной точки доступа API, что достаточно для этого примера, но этого достаточно чтобы передать это пользователю? Возможно да, но давайте посмотрим еще на несколько вещей.
Тесты востребованны в данном месте, и есть возможность двух типов: unit тесты, и интеграционные тесты. Для второго будем вызвать настоящий API. Напишем простой тест:
// +build integration
package facest
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetFaces(t *testing.T) {
c := NewClient(os.Getenv("FACEST_INTEGRATION_API_KEY"))
ctx := context.Background()
res, err := c.GetFaces(nil)
assert.Nil(t, err, "expecting nil error")
assert.NotNil(t, res, "expecting non-nil result")
assert.Equal(t, 1, res.Count, "expecting 1 face found")
assert.Equal(t, 1, res.PagesCount, "expecting 1 PAGE found")
assert.Equal(t, "integration_face_id", res.Faces[0].FaceID, "expecting correct face_id")
assert.NotEmpty(t, res.Faces[0].FaceToken, "expecting non-empty face_token")
assert.Greater(t, len(res.Faces[0].FaceImages), 0, "expecting non-empty face_images")
}
Этот тест использует env
переменную где указывается API ключ. Поступая таким образом, мы убеждаемся, что они не публичны. Позже мы можем сконфигурировать эту переменную с помощью окружения используемого в CI\CD.
Эти тесты, отьделены от юнит тестов(так как они выполняются гораздо дольше): execute):
go test -v -tags=integration
Документация.
Делайте ваш SDK не требующий описания с понятными типами и абстракциями. не выставляйте много информации. Обычно достаточно предоставить godoc
ссылку как главную документацию.
Свместимость и версионирование.
Версионирование вашего SDK зависит от вашего репозитория. Но всегда убеждайтесь, что вы не сломали ничего в различных патчах или минорных выпусках. Обычно ваша SDK библиотека должна следовать API обновлениям, поэтому если релизитсся API v2, тогда должен быть и релиз SDK v2.