Если вы видите что-то необычное, просто сообщите мне. Skip to main content

Пишем 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 секунд.

Now

Теперь 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.