Пишем REST API клиента на Go
APIApi clientsклиенты, areочень veryполезны helpfulкогда whenвы you'reвыставляете shipping yourваш REST APIs to the public. And Go makes it easy, for you as a developer, as well as for your users, thanks to its idiomatic design and type system. But what defines a good API client?на публику. И go делает это проще для вас, как для разработчика, так же как и для ваших пользователей, спасибо его структуре и типу систем. Но как определить хороший Api клиент?
InВ thisэтой tutorial,инструкции, we'reмы goingсобираемся toобсудить reviewнесколько someхороших bestпрактик practices of writing a goodнаписания SDK inна Go.
We'llМы beбудем usingисполльзовть Facest.io APIApi asкак an example.пример.
BeforeПрежде weчем beginначнем toписать writeкакой-то anyкод, code,мы weодлжны should study theизучить API toчтобы understandпонять theглавные mainего aspectsвещи, ofтакие it such as:как:
WhatЧтоisтакоеthe"BaseURLurl",ofиtheможноAPIлиandегоcan it be changed later?поменять?DoesПоддерживаетitлиsupportонversioning?версионирование?WhatКакиеareошибкиtheможноpossible errors?встретить?HowКакимclientsобразомshouldаутентифицируютсяauthenticate? Understanding all of this will help you to put a right structure.клиенты?
Let'sОтветы startна withэти theвопросы basics.помогут Createвам aсоздать repository,правильную pickструктуру.
Начнем correctс name,основ. ideallyСоздадим matchingрепозиторй, theвыберем название, в идеаеле одно должно совпадать с именем API serviceсервиса. name. InitializeИнициализирум go modules.модули. AndСоздадим createнашу ourглавную mainструктуру structдля toхранения holdпользовательской user-specificинформации. information.Это Thisструктура, structв willпоследствии, containбудет содержать точки доступа API endpointsкак as functions later.функции.
ThisСтруктуры structдложны shouldбыть beгибкими, flexibleно butтак alsoже limitedограниченными. soчтобы theпользователь userне can'tмогу seeувидеть internalвнутренние fields.поля.
WeСоздаем makeполя fieldsBaseURL
BaseURLи andHTTPClient
HTTPClientэкспортируемыми, exportable,чтобы soпользователь usersмог canиспользовать useих theirв ownсвоём HTTP clientклиенте, ifесли necessary.это нужно.
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,
},
}
}
NowПродолжаем: let's move on and implementреализуем "Get Faces" endpoint,точку whichдоступа, returnsкоторая theвозвращает listсписок ofрезультатов resultsи andподдерживает supportsпостраничный pagination,вывод, whichчто meansговорит ourо functionтом, shouldчто supportпостраничный paginationвывод options- asэто input.опция ввода.
AsКак Iя noticedуказал inпро API, successлюбой responsesответ andдолжен errorвсегда responsesиметь alwaysодну followи theту sameже structure,структуру, soчтобы weмы canмогли defineопределить themи separatelyотделить fromуспешный dataли typesбыл andответит don'tили makeнет, themот exportedданных sinceчто thisпришли, isчтобы notпользователь relevantвидел informationтолько toнужную theему user.информацию.
type errorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
type successResponse struct {
Code int `json:"code"`
Data interface{} `json:"data"`
}
MakeУбедитесь, sureчто youвы don'tне writeпишете allвсе endpointsточки inдоступа theв sameодном .go file,файле, butно groupсгрупируйте themих andиспользуя useотдельные separateфайлы, files.для Forпримера exampleвы youможете mayсгрупировать groupих byпо resourceтипу, type,всё anythingчто thatначинается startsс with /v1/faces
goesидет intoв faces.fasec.go
file.файл.
IЯ usuallyобычно startначинаю byс definingопределения theтипаов, types,вы youможете canэто doсделать itвручную manually or by convertingсконвертировав JSON to go usingв JSON-to-Go tool.инструменте.
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"`
}
TheФункция GetFaces
functionдожна shouldподдерживать supportпостраничный paginationвывод andа weмы canдолжны doделать thisэто byдобавив addingаргументы funcфункции, arguments,но butэти theseаргументы argumentsне areобязательны optional,и andони theyмогту mayбыть beизменены changedв inбудущем. theТак, future.что Soстоит itгруппировать makesих senseв toспециальную group them into a special struct:структуру:
type FacesListOptions struct {
Limit int `json:"limit"`
Page int `json:"page"`
}
OneЕще moreодин argumentаргумент, ourнашей functionфункции shouldдолжен support,поддерживать, andи it'sего theконтекст, context,который whichпозволит willпользователю letвызывать usersAPI. controlПользователи theмогут создавать Context
, передавая его в нашу функцию. Пример использования: отмена API call.вызова Usersесли canон createдлится a Context, pass it to our func. Simple use case: cancel API call if it takes more thanбольше 5 seconds.
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:
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 same 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 >= 400 as errors and parse response into errorResponse. It depends on the API design though, your API may handle errors differently.
Tests
So now we have SDK with single API endpoint covered, which 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 test 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.
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 provide 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.
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.