Skip to content

Latest commit

 

History

History
909 lines (693 loc) · 46.9 KB

part11.md

File metadata and controls

909 lines (693 loc) · 46.9 KB

Реализация RESTful HTTP API в Go с помощью Gin

Оригинал

Привет и рад вас снова видеть на мастер-классе по бэкенду.

На данный момент мы многое узнали о работе с базой данных в Go. Теперь пришло время узнать, как реализовать некоторые RESTful HTTP API, которые позволят внешним клиентам взаимодействовать с серверной частью нашего банковского сервиса.

Ниже:

Веб-фреймворки Go и HTTP-маршрутизаторы

Хотя мы можем использовать стандартный пакет net/http для реализации этих API, будет намного проще просто воспользоваться преимуществами некоторых существующих веб-фреймворков.

Вот некоторые из самых популярных веб-фреймворков Golang, отсортированные по количеству звезд на Github:

Они предлагают широкий спектр функций, таких как маршрутизация, привязка параметров, валидация, middleware, а некоторые из них даже имеют встроенную ORM.

Если вы предпочитаете легковесный пакет только с функцией маршрутизации, то вот некоторые из самых популярных HTTP-маршрутизаторов для golang:

В этом мастер-классе я буду использовать самый популярный фреймворк: Gin.

Устанавливаем Gin

Давайте откроем браузер и поищем по ключевым словам golang gin, а затем откроем страницу фреймворка на Github. Прокрутите немного вниз и выберите Installation («Установка»).

Давайте скопируем эту команду go get и запустим ее в терминале, чтобы установить пакет:

❯ go get -u github.com/gin-gonic/gin

После этого в файле go.mod нашего простого банковского приложения мы видим, что gin добавлен как новая зависимость вместе с некоторыми другими используемыми пакетами.

Определяем структуру Server

Теперь я собираюсь создать новую папку с названием api. Затем создайте внутри неё новый файл server.go. Здесь мы реализуем наш сервер HTTP API.

Сначала давайте определим новую структуру Server. Этот сервер будет обслуживать все HTTP-запросы для нашего банковского сервиса. В нем будет 2 поля:

  • Первое — это объект db.Store, который мы реализовали в предыдущих лекциях. Это позволит нам взаимодействовать с базой данных при обработке API-запросов от клиентов.
  • Второе поле — маршрутизатор типа gin.Engine. Этот маршрутизатор поможет нам отправить каждый API запрос правильному обработчику для обработки.
type Server struct {
    store  *db.Store
    router *gin.Engine
}

Теперь давайте добавим функцию NewServer, которая принимает на вход db.Store и возвращает Server. Эта функция создаст новый экземпляр Server и настроит все маршруты HTTP API для нашего сервиса на этом сервере.

Во-первых, мы создаем новый объект Server, используя переданное в качестве входного параметра db.Store. Затем мы создаем новый маршрутизатор, вызывая gin.Default(). Мы добавим маршруты к этому маршрутизатору чуть позже. После этого шага мы присвоим объект router server.router и вернем сервер.

func NewServer(store *db.Store) *Server {
    server := &Server{store: store}
    router := gin.Default()

    // TODO: добавить маршруты к маршрутизатору

    server.router = router
    return server
}

Теперь давайте добавим первый API маршрут для создания нового счёта. Он будет использовать метод POST, поэтому мы вызываем router.POST.

Мы должны передать путь для маршрута, в данном случае это /accounts, а затем одну или несколько функций-обработчиков. Если вы передаете несколько функций, то последняя из них должна быть настоящим обработчиком, а все остальные функции должны быть middlewares.

func NewServer(store *db.Store) *Server {
    server := &Server{store: store}
    router := gin.Default()

    router.POST("/accounts", server.createAccount)

    server.router = router
    return server
}

На данный момент у нас нет никаких middlewares, поэтому я просто передают один обработчик: server.createAccount. Это метод структуры Server, который нам нужно реализовать. Причина, по которой это должен быть метод структуры Server, заключается в том, что нам иметь доступ к объекту db.Store, чтобы сохранить новый счёт в базе данных.

Реализуем API для создания счёта

Я собираюсь реализовать метод server.createAccount в новом файле account.go внутри папки api. Здесь мы объявляем функцию, у которой есть указатель на сервера. Она называется createAccount, и должна принимать объект gin.Context в качестве входного параметра.

func (server *Server) createAccount(ctx *gin.Context) {
    ...
}

Почему у этой функции такая сигнатура? Давайте посмотрим на функцию router.POST Gin:

Здесь мы видим, что HandlerFunc объявлена как функция с Context в качестве входного параметра. По сути, при использовании Gin всё, что мы делаем внутри обработчика, будет включать этот объект Context. Он предоставляет множество удобных методов для считывания входных параметров и выдачи ответов.

Хорошо, теперь давайте объявим новую структуру для хранения запроса на создание счёта. В нем будет несколько полей, подобных createAccountParams из account.sql.go, который мы использовали в базе данных в предыдущей лекции:

type CreateAccountParams struct {
    Owner    string `json:"owner"`
    Balance  int64  `json:"balance"`
    Currency string `json:"currency"`
}

Итак, я скопирую эти поля и вставлю их в нашу структуру createAccountRequest. Когда создается новый счёт, ее начальный баланс всегда должен быть равен 0, поэтому мы можем удалить поле баланса. Мы разрешаем клиентам указывать только имя владельца и валюту счёта. Мы собираемся получить эти входные параметры из тела HTTP запроса, который является объектом JSON, поэтому я оставлю JSON дескрипторы.

type createAccountRequest struct {
    Owner    string `json:"owner"`
    Currency string `json:"currency"`
}

func (server *Server) createAccount(ctx *gin.Context) {
...
}

Всякий раз, когда мы получаем входные данные от клиентов, всегда полезно проверять их, потому что клиенты могут отправлять неверные данные, которые мы не хотим хранить в нашей базе данных.

К счастью, в Gin встроен пакет validator для автоматической проверки данных под капотом. Например, мы можем использовать дескриптор поля, чтобы сообщить Gin, что оно является обязательным (required). А позже вызывать функцию ShouldBindJSON для анализа входных данных из тела HTTP-запроса, и Gin проверит выходной объект, чтобы убедиться, что он удовлетворяет условиям, указанным в дескрипторе.

Я собираюсь добавить дескриптор required для полей owner и currency. Более того, допустим, наш банк пока поддерживает только 2 типа валюты: USD и EUR. Итак, как мы можем сказать Gin, чтобы он проверил это за нас? Для этого мы можем использовать условие oneof:

type createAccountRequest struct {
    Owner    string `json:"owner" binding:"required"`
    Currency string `json:"currency" binding:"required,oneof=USD EUR"`
}

Мы используем запятую для разделения нескольких условий и пробел для разделения возможных значений одного из условий.

Хорошо, теперь в функции createAccount мы объявляем новую переменную req типа createAccountRequest. Затем мы вызываем функцию ctx.ShouldBindJSON() и передаем этот объект req. Эта функция вернет ошибку.

Если ошибка не равна nil, то это означает, что клиент предоставил неверные данные. Поэтому мы должны отправить клиенту ответ 400 Bad Request. Для этого мы просто вызываем функцию ctx.JSON() для отправки JSON ответа.

Первый аргумент — это код HTTP статуса, который в данном случае должен быть http.StatusBadRequest. Второй аргумент — это объект JSON, который мы хотим отправить клиенту. Здесь мы просто хотим отправить ошибку, поэтому нам понадобится функция для преобразования этой ошибки в объект ключ-значение, чтобы Gin мог преобразовать её в JSON прежде чем возвратить клиенту.

func (server *Server) createAccount(ctx *gin.Context) {
    var req createAccountRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    ...
}

Позже мы будем часто использовать эту функцию errorResponse() в нашем коде, и ее можно использовать и для других обработчиков, а не только для обработчиков работающих со счётами, поэтому я реализую ее в файле server.go.

Эта функция принимает ошибку в качестве входных данных и возвращает объект gin.H, который на самом деле представляет собой map[string]interface{}. Таким образом, мы можем хранить в нём любые данные типа «ключ-значение», какие захотим.

А пока что давайте просто вернем gin.H только с одним ключом: error, а его значением будет сообщение об ошибке. Позднее мы можем проверить тип ошибки и отформатировать её иным образом, если захотим.

func errorResponse(err error) gin.H {
    return gin.H{"error": err.Error()}
}

Теперь вернемся к обработчику createAccount. Если входные данные верны, то ошибок не будет. В этом случае мы просто вставляем новый счёт в базу данных.

Сначала мы объявляем объект CreateAccountParams, где Owner — это req.Owner, Currency — это req.Currency, а Balance0. Затем мы вызываем server.store.CreateAccount(), передаем входной контекст и аргумент. Эта функция вернет созданную счёт и ошибку.

func (server *Server) createAccount(ctx *gin.Context) {
    var req createAccountRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    arg := db.CreateAccountParams{
        Owner:    req.Owner,
        Currency: req.Currency,
        Balance:  0,
    }

    account, err := server.store.CreateAccount(ctx, arg)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, account)
}

Если err не равна nil, то при попытке вставки в базу данных возникла какая-то внутренняя ошибка. Таким образом, мы вернем клиенту код состояния 500 Internal Server Error. Мы также повторно используем функцию errorResponse(), чтобы отправить ошибку клиенту, а затем немедленно выйти из функции.

Если ошибок не возникает, то счёт был успешно создан. Мы просто отправляем клиенту код состояния 200 OK и созданный объект. Вот и все! Обработчик createAccount готов.

Стартуем HTTP сервер

Далее нам нужно добавить еще немного кода для запуска HTTP-сервера. Я собираюсь добавить новую функцию Start() в нашу структуру Server. Эта функция примет адрес (address) в качестве входных данных и вернет ошибку. Её роль заключается в запуске HTTP сервера на входном адресе (address), чтобы начать прослушивание API запросов.

func (server *Server) Start(address string) error {
    return server.router.Run(address)
}

Gin уже имеет функцию в маршрутизаторе для выполнения этого действия, поэтому все, что нам нужно сделать, это вызвать server.router.Run() и передать адрес сервера.

Обратите внимание, что поле server.router является приватным, поэтому к нему нельзя получить доступ из-за пределов этого пакета api. Это одна из причин, по которой у нас есть общедоступная функция Start(). На данный момент она состоит из одной единственной команды, но позже мы, возможно, захотим добавить в эту функцию некоторую логику для корректного завершения работы сервера.

Хорошо, теперь давайте создадим точку входа для нашего сервера в файле main.go в корне этого репозитория. Имя пакета должно быть main, и в нём должна быть функция main().

Чтобы создать Server, нам нужно подключиться к базе данных и сначала создать Store. Код будет очень похож на тот, который мы написали ранее в файле main_test.go.

Поэтому я скопирую эти константы для dbDriver и dbSource и вставлю их в начало нашего файла main.go. Затем также скопируйте блок кода, который устанавливает соединения с базой данных, и вставьте его внутрь функции main.

Используя это соединение, мы можем создать новое хранилище (store), используя функцию db.NewStore(). Затем мы создаем новый сервер, вызывая api.NewServer() и передавая в качестве параметра store.

const (
    dbDriver      = "postgres"
    dbSource      = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
)

func main() {
    conn, err := sql.Open(dbDriver, dbSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }

    store := db.NewStore(conn)
    server := api.NewServer(store)

    ...
}

Чтобы запустить сервер, нам просто нужно вызвать server.Start() и передать адрес сервера. Пока я просто объявлю его с помощью константы: localhost, порт 8080. В будущем мы отрефакторим код, чтобы загружать все эти параметры из переменных окружения или файла настроек. В случае возникновения какой-либо ошибки при запуске сервера, мы просто пишем фатальный лог, сообщая, что сервер не может быть запущен.

И последний, но очень важное момент, о котором мы не должны забыть, это добавление пустого импорта для драйвера lib/pq. Без этого наш код не сможет взаимодействовать с базой данных.

package main

import (
    "database/sql"
    "log"

    _ "github.com/lib/pq"
    "github.com/techschool/simplebank/api"
    db "github.com/techschool/simplebank/db/sqlc"
)

const (
    dbDriver      = "postgres"
    dbSource      = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
    serverAddress = "0.0.0.0:8080"
)

func main() {
    conn, err := sql.Open(dbDriver, dbSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }

    store := db.NewStore(conn)
    server := api.NewServer(store)

    err = server.Start(serverAddress)
    if err != nil {
        log.Fatal("cannot start server:", err)
    }
}

Хорошо, теперь файл main для нашего сервера готов. Давайте добавим новую команду make в Makefile, чтобы запустить его.

Я назову её make server. И она должна выполнить команду go run main.go. Давайте добавим server в список PHONY.

server:
    go run main.go
    
.PHONY: postgres createdb dropdb migrateup migratedown sqlc test server

Затем откроем терминал и выполним:

make server

make_server

Вуаля, сервер запущен. Он прослушивает и обслуживает HTTP запросы на порту 8080.

Тестируем API для создания счета с помощью Postman

Теперь я собираюсь использовать Postman для тестирования API создания счёта.

Давайте добавим новый запрос, выберем метод POST, заполним URL, который в данном случае равен http://localhost:8080/accounts.

Параметры должны быть отправлены через тело JSON, поэтому давайте выберем вкладку Body, Raw и формат JSON. Нам нужно добавить 2 поля для отправки: имя владельца, здесь я буду использовать свое имя, и валюту, скажем, USD.

{
    "owner": "Quang Pham",
    "currency": "USD"
}

Хорошо, затем нажмите Send.

Отлично, запрос успешно выполнен. Мы получили код состояния 200 OK и созданный объект счёта. У него ID = 1, баланс = 0, с правильным именем владельца и валютой.

Теперь давайте попробуем отправить неверные данные, чтобы посмотреть, что произойдет. Я установлю значение для двух полей равное пустой строке и нажму Send.

{
    "owner": "",
    "currency": ""
}

В этот раз мы получили 400 Bad Request и ошибку о том, что поля обязательны. Это сообщение об ошибке выглядит довольно сложно для чтения, потому что оно объединяет дву ошибки проверки двух полей. Это то, что мы могли бы улучшить в будущем.

Затем я попытаюсь использовать неверный код валюты, например xyz.

{
    "owner": "Quang Pham",
    "currency": "xyz"
}

На этот раз мы также получаем код состояния 400 Bad Request, но сообщение об ошибке отличается. В нем говорится, что валидация дескриптора oneof завершилась с ошибкой, из-за того, что в коде мы допускаем только два возможных значения для валюты: USD и EUR.

Действительно здорово, как Gin справился с валидацией ввода с помощью всего лишь нескольких строк кода. Он также выводит логи запросов в красивой форме, которая очень легко читается человеком.

Реализуем API для получения счёта

Хорошо, теперь мы добавим API для получения конкретного счёта по идентификатору. Оно будет очень похоже на API создания счёта, поэтому я продублирую эту команду маршрутизации:

func NewServer(store *db.Store) *Server {
    ...

    router.POST("/accounts", server.createAccount)
    router.GET("/accounts/:id", server.getAccount)

    ...
}

Здесь вместо POST мы будем использовать метод GET. И этот путь должен включать идентификатор счёта, который мы хотим получить /accounts/:id. Обратите внимание, что у нас есть двоеточие перед идентификатором. Так мы сообщаем Gin, что id — это параметр URI.

Затем мы должны реализовать новый обработчик getAccount в структуре Server. Для этого перейдем к файлу account.go. Как и раньше, мы объявляем новую структуру с названием getAccountRequest для хранения входных параметров. Она будет состоять из поля идентификатора типа int64.

Теперь поскольку ID является параметром URI, мы не можем получить его из тела запроса, как раньше. Вместо этого мы используем дескриптор uri, чтобы сообщить Gin имя параметра URI:

type getAccountRequest struct {
    ID int64 `uri:"id" binding:"required,min=1"`
}

Мы добавляем условие, что этот идентификатор является обязательным полем. Кроме того, мы не хотим, чтобы клиент отправлял неверный идентификатор, например отрицательное число. Чтобы сообщить об этом Gin, мы можем использовать условие min. В этом случае давайте установим min = 1, потому что это наименьшее возможное значение идентификатора счёта.

Хорошо, теперь в обработчике server.getAccount делаем то же самое, что и раньше. Сначала мы объявляем новую переменную req типа getAccountRequest. Только здесь вместо ShouldBindJSON мы должны вызвать ShouldBindUri.

Если произошла ошибка, мы просто возвращаем код состояния 400 Bad Request. В противном случае мы вызываем server.store.GetAccount(), чтобы получить счёт с ID, равным req.ID. Эта функция вернет account и ошибку.

func (server *Server) getAccount(ctx *gin.Context) {
    var req getAccountRequest
    if err := ctx.ShouldBindUri(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    account, err := server.store.GetAccount(ctx, req.ID)
    if err != nil {
        if err == sql.ErrNoRows {
            ctx.JSON(http.StatusNotFound, errorResponse(err))
            return
        }

        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, account)
}

Если ошибка не равна nil, то произошло одно из двух:

  • первое — какая-то внутренняя ошибка при запросе данных из базы данных. В этом случае мы просто возвращаем клиенту код состояния 500 Internal Server Error.
  • второе - счёта с этим конкретным входным ID не существует. В этом случае мы должны получить ошибку sql.ErrNoRows. Поэтому мы просто проверяем её здесь, и если это действительно так, мы просто отправляем клиенту код состояния 404 Not Found и выходим из функции.

Если ошибки не возникло, мы просто возвращаем клиенту код состояния 200 OK и счёт. Вот и все! Наш getAccount API готов.

Тестируем API для получения счета с помощью Postman

Давайте перезапустим сервер и откроем Postman, чтобы протестировать его.

Добавим новый запрос с методом GET и URL-адресом http://localhost:8080/accounts/1. Мы добавляем /1 в конце, потому что мы хотим получить счёт с ID = 1. Теперь нажмите отправить:

Запрос выполнен успешно, вместе с найденным счётом мы получили код состояния 200 OK. Это именно тот счёт, который мы создали ранее.

Теперь давайте попробуем получить счёт, который не существует. Я изменю ID на 100: http://localhost:8080/accounts/100 и снова нажму «Send».

На этот раз мы получили код состояния 404 Not Found и ошибку: sql no rows in result set. Именно то, что мы ожидали.

Пошлём ещё один запрос на этот раз с отрицательным ID: http://localhost:8080/accounts/-1.

Теперь мы получили код состояния 400 Bad Request с сообщением об ошибке валидации.

Отлично, таким образом, наш API getAccount работает правильно.

Реализуем API для получения списка счетов

Следующий шаг, реализация API для получения списка счетов с пагинацией.

Количество счетов, хранящихся в нашей базе данных, со временем может вырасти до очень большого числа. Поэтому мы не должны запрашивать и возвращать их все в одном вызове API. Идея разбивки на страницы состоит в том, чтобы разделить записи на несколько страниц небольшого размера, чтобы клиент мог получить только одну страницу при каждом запросе к API.

Этот API немного отличается, потому что мы не будем получать входные параметры из тела запроса или URI, а вместо этого мы получим их из строки запроса. Вот пример запроса:

У нас есть параметр page_id, представляющий собой порядковый номер страницы, которую мы хотим получить, начиная со страницы 1. И параметр page_size, представляющий собой максимальное количество записей, которые могут быть возвращены на одной странице.

Как видите, page_id и page_size добавляются к URL-адресу запроса после вопросительного знака: http://localhost:8080/accounts?page_id=1&page_size=5. Вот почему они называются параметрами запроса, а не параметром URI, как ID счёта в запросе на получение счёта.

Хорошо, теперь вернемся к нашему коду. Я собираюсь добавить новый маршрут, используя методом GET. Но на этот раз путь должен быть равен только /accounts, так как мы собираемся получить параметры из запроса. Назовём обработчик listAccount.

func NewServer(store *db.Store) *Server {
    server := &Server{store: store}
    router := gin.Default()

    router.POST("/accounts", server.createAccount)
    router.GET("/accounts/:id", server.getAccount)
    router.GET("/accounts", server.listAccounts)

    server.router = router
    return server
}

Отлично, давайте откроем файл account.go, чтобы реализовать эту функцию server.listAccounts. Он очень похож на обработчик server.getAccount, поэтому я его продублирую. Затем измените название структуры на listAccountRequest.

Эта структура должна содержать 2 параметра: PageID и PageSize. Теперь обратите внимание, что мы получаем эти параметры не из uri, а из строки запроса, поэтому мы не можем использовать дескриптор uri. Вместо этого мы должны использовать дескриптор form.

type listAccountRequest struct {
    PageID   int32 `form:"page_id" binding:"required,min=1"`
    PageSize int32 `form:"page_size" binding:"required,min=5,max=10"`
}

Оба параметра являются обязательными, а минимальный PageID должен быть равен 1. Предположим, что мы не хотим, чтобы PageSize был слишком большим или слишком маленьким, поэтому я установил для него минимальное значение равное пяти записям, а максимальное значение — десяти записям.

Хорошо, теперь функция-обработчик server.listAccounts должна быть реализована следующим образом:

func (server *Server) listAccounts(ctx *gin.Context) {
    var req listAccountRequest
    if err := ctx.ShouldBindQuery(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    arg := db.ListAccountsParams{
        Limit:  req.PageSize,
        Offset: (req.PageID - 1) * req.PageSize,
    }

    accounts, err := server.store.ListAccounts(ctx, arg)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, accounts)
}

Тип переменной req должен быть равен listAccountRequest. Затем мы используем функцию: ShouldBindQuery, чтобы сообщить Gin, что нужно получить данные из строки запроса.

Если возникает ошибка, мы просто возвращаем статус 400 Bad Request. В противном случае мы вызываем server.store.ListAccounts() для запроса страницы с записями из базы данных. Эта функция запрашивает ListAccountsParams в качестве входных данных, где мы должны предоставить значения для двух полей: Limit и Offset.

Limit — это просто req.PageSize. Тогда как Offset — это количество записей, которые база данных должна пропустить, мы можем вычислить его из req.PageID и req.PageSize, используя следующую формулу: (req.PageID — 1) * req.PageSize.

Функция ListAccounts возвращает список счетов и ошибку. Если возникает ошибка, то нам просто нужно вернуть клиенту 500 Internal Server Error. В противном случае мы отправляем код состояния 200 OK со списком извлеченных из базы записей.

Вот и все, ListAccount API готов.

Тестируем API для получения списка счетов с помощью Postman

Давайте перезапустим сервер, а затем откроем Postman, чтобы протестировать этот запрос.

Он успешно выполнен, но у нас в списке пока только один счёт. Это связано с тем, что на данный момент в нашей базе данных почти не содержит записей. Мы совсем недавно добавили в неё первую запись. Давайте запустим тесты базы данных, которые мы написали в предыдущих лекциях, чтобы заполнить её случайными данными.

❯ make test

Отлично, теперь в нашей базе данных должно быть достаточно записей. Давайте повторно отправим этот запрос к API.

Вуаля, теперь в возвращаемом списке ровно 5 записей, как и ожидалось. Счёта с ID 5 здесь нет, потому что я думаю, что он был удален в тесте. Вместо него у нас есть счёт с ID 6.

Попробуем получить вторую страницу.

Здорово, теперь мы получили следующие 5 записей с ID от 7 до 11. Так что всё работает как надо.

Я отправлю запрос ещё раз, чтобы получить страницу, которой не существует, например, с page_id равным 100.

Итак, мы получили в теле ответа null. Хотя с технической точки зрения это правильно, я думаю, что было бы лучше, если бы сервер возвращал в этом случае пустой список. Итак, давайте сделаем это!

Возвращаем пустой список вместо null

Вот что sqlc генерирует за нас в файле account.sql.go:

func (q *Queries) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error) {
    rows, err := q.db.QueryContext(ctx, listAccounts, arg.Limit, arg.Offset)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var items []Account
    for rows.Next() {
        var i Account
        if err := rows.Scan(
            &i.ID,
            &i.Owner,
            &i.Balance,
            &i.Currency,
            &i.CreatedAt,
        ); err != nil {
            return nil, err
        }
        items = append(items, i)
    }
    if err := rows.Close(); err != nil {
        return nil, err
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return items, nil
}

Мы видим, что переменная items представляющая собой список Account объявлена без инициализации: var items []Account. Вот почему он останется равным null, если не будет добавлено ни одной записи.

К счастью для нас, в последней версии sqlc, версии 1.5.0, существует новый параметр, который указывает sqlc создавать пустой слайс вместо null.

Параметр называется emit_empty_slices, и его значение по умолчанию равно false. Если мы установим это значение в true, то результат, возвращаемый запросом, будет пустым срезом.

Хорошо, теперь давайте добавим этот новый параметр в наш файл sqlc.yaml:

version: "1"
packages:
  - name: "db"
    path: "./db/sqlc"
    queries: "./db/query/"
    schema: "./db/migration/"
    engine: "postgresql"
    emit_json_tags: true
    emit_prepared_queries: false
    emit_interface: false
    emit_exact_table_names: false
    emit_empty_slices: true

Сохраните его и откройте терминал, чтобы обновить sqlc до последней версии. Если вы работаете на Mac и используете Homebrew, просто запустите:

❯ brew upgrade sqlc

Вы можете узнать текущую версию, запустив:

❯ sqlc version
v1.5.0

У меня уже установлена последняя версия: 1.5.0, поэтому сейчас я перегенерирую код:

❯ make sqlc

И вернёмся в Visual Studio Code. Теперь в файле account.sql.go мы видим, что переменная items инициализируется как пустой срез:

func (q *Queries) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error) {
    ...

    items := []Account{}

    ...
}

Здорово! Давайте перезапустим сервер и протестируем его в Postman. Теперь, когда я отправляю этот запрос, мы получаем пустой список, как и ожидалось.

То есть всё работает как надо!

Теперь я собираюсь протестировать некоторые недопустимые параметры. Например, давайте изменим page_size на 20, что больше, чем максимальное значения равного 10.

На этот раз мы получили код состояния 400 Bad Request и ошибку, говорящую о том, что правило для page_size, определенное дескриптором max, не было выполнено.

Протестируем ещё одну ситуацию, когда page_id = 0.

Мы всё равно получаем код состояния 400 Bad Request, но ошибка связана с тем, что не соблюдено правило для page_id, определенное дескриптором required. Здесь происходит следующее: в пакете validator любое нулевое значение считается отсутствующим. В данной ситуации это приемлемо, поскольку мы все равно не планируем обращаться к странице с page_id = 0.

Однако, если в вашем API параметр может иметь нулевое значение, то вам нужно учесть эту особенность. Я рекомендую вам прочитать документацию пакета validator, чтобы узнать о нем больше.

Итак, сегодня мы узнали, как легко реализовать RESTful HTTP API в Go с помощью Gin. Используя примеры из этой лекции, вы можете попробовать реализовать еще несколько маршрутов для обновления или удаления записей самостоятельно. Будем считать это домашним заданием для вас.

Большое спасибо за время, потраченное на чтение. Желаю вам получать удовольствие от написания кода и до скорой встречи на следующей лекции!