Пишем 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 секунд.
Теперь ourшаблон functionнашей skeletonфункции mayвыглядит lookследующим like this:образом:
func (c *Client) GetFaces(ctx context.Context, options *FacesListOptions) (*FacesList, error) {
return nil, nil
}
NowВремя it'sсделать timeсам to make API call itself: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
}
SinceТак allкак все точки доступа API endpointsработают actодинаково, inфункция theпомошника samesendRequest
manner,создана, helperчтобы functionизбежать sendRequestповторения isв createdкоде. toОн avoidзадает codeбольшиство duplication. It will set common headers заголовков(content type, auth header), makeсоздает request,запрос, checkпроверяет forна errors,ошибки, parseпарсит response.ответ.
NoteОтменит, thatчто we'reмы consideringпредполагаем status codesответы < 200 andи >= 400400, asкак errorsошибки, andв parseэтом responseслучае intoпарсится errorResponse.ответ Itиз dependserrorResponse
. onПонятно, theчто APIэто designзависит though,от yourархитектуры APIAPI, mayваша handleApi errorsможет differently.по разному обрабатывать ошибки.
TestsТестирование
SoТеперь nowу weнас haveесть SDK withс singleпокрытием APIодной endpointточки covered,доступа whichAPI, isчто enoughдостаточно forдля thisэтого example,примера, butно isэтого itдостаточно enoughчтобы toпередать beэто ableпользователю? toВозможно shipда, thisно toдавайте users?посмотрим Probablyеще yes,на butнесколько let's focus on few more things.вещей.
TestsТесты areвостребованны almostв requiredданном here,месте, andи thereесть canвозможность beдвух 2 types of them:типов: unit testsтесты, andи integrationинтеграционные tests.тесты. ForДля theвторого secondбудем oneвызвать we'll call realнастоящий API. Let'sНапишем writeпростой a simple test.тест:
// +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")
}
NoteЭтот thatтест thisиспользует testenv
usesпеременную env.где var whereуказывается API Keyключ. isПоступая set.таким Byобразом, doingмы this,убеждаемся, we'reчто makingони sureне thatпубличны. theyПозже areмы notможем public.сконфигурировать Andэту laterпеременную weс canпомощью configureокружения ourиспользуемого buildв system to propagate this env. var using secrets.CI\CD.
Also,Эти theseтесты, testsотьделены areот separatedюнит fromтестов(так unitкак testsони (becauseвыполняются theyгораздо take longer toдольше): execute):
go test -v -tags=integration
Documentation.Документация.
MakeДелайте yourваш SDK self-explanatoryне withтребующий clearописания typesс andпонятными abstractions,типами don'tи exposeабстракциями. tooне muchвыставляйте information.много Usually,информации. it'sОбычно enoughдостаточно toпредоставить providegodoc
godocссылку linkкак asглавную main documentation.документацию.
CompatibilityСвместимость andи Versioning.версионирование.
VersionВерсионирование yourвашего SDK updatesзависит byот publishingвашего newрепозитория. semverНо toвсегда yourубеждайтесь, repository.что Butвы makeне sureсломали you'reничего notв breakingразличных anythingпатчах withили newминорных minor/patchвыпусках. releases.Обычно Usually yourваша SDK libraryбиблиотека shouldдолжна followследовать API updates,обновлениям, soпоэтому ifесли релизитсся API releases v2, thenтогда thereдолжен shouldбыть beи anрелиз SDK v2 release as well.v2.
Conclusion
That's it.
One question though: what are the best API Go clients have you seen so far? Please share them in the comments.
You can find the full source code here.