Учимся разрабатывать REST API на Go на примере сокращателя ссылок

vjebgnmqximd2g6hmph8yit4-ee.png

В этой статье мы напишем полноценный REST API сервис — URL Shortener — и задеплоим его на виртуальный сервер с помощью GitHub Actions.

Говоря «полноценный», я имею в виду, что это будет не игрушечный проект, а готовый к использованию:

  • мы выберем для него актуальный http-роутер,
  • позаботимся о логах,
  • напишем тесты: unit-тесты, тесты хэндлеров и функциональные,
  • настроим автоматический деплой через GitHub Actions и др.


Но важно понимать, что «готовый к продакшену» != «энтерпрайз».

Кратко обо мне: меня зовут Николай, я много лет занимаюсь разработкой на Go, очень люблю этот язык. Также веду свой YouTube-канал, на котором есть видеоверсия текущего гайда, с более подробными объяснениями.

Используйте навигацию, если нет времени читать текст целиком:

→ Выбор библиотек
→ Конфигурация приложения
→ Настраиваем logger
→ Пишем Storage
→ Handlers — обработчики запросов
→ Авторизация
→ Функциональные тесты
→ Деплой проекта
→ Заключение

Выбор библиотек


Для проекта нам понадобятся несколько основных библиотек:

  • go-chi/chi — для обработки HTTP-запросов,
  • slog — для логирования,
  • stretchr/testify — для покрытия проекта тестами,
  • ilyakaznacheev/cleanenv — для конфигурирования,
  • SQLite — для хранения данных, СУБД.


Далее расскажу подробнее, почему я выбрал именно их.

HTTP-роутер


Работа с HTTP-запросами — основной компонент нашего сервиса, поэтому это очень важный выбор.

Можно было просто взять пакет net/http из стандартной библиотеки, но я решил использовать более продвинутый вариант, который упростит работу и добавит удобную маршрутизацию, поддержку middleware и другие приятные вещи.

В то же время, я бы не хотел брать что-то слишком сложное. В идеале нужно решение, совместимое с net/http и легко заменяемое.

Я провел опрос в своем Telegram-канале, учел его результаты и комментарии подписчиков и остановился на go-chi/chi. Он как раз полностью совместим с net/http, минималистичный и производительный — на мой взгляд, наиболее Go-idiomatic.

Логирование


Здесь можно вообще не думать, просто взять привычный uber/zap и двигаться дальше. Но мне не нравится привязка проекта к конкретному логгеру. Можно, конечно, написать собственный интерфейс, чтобы потом легко заменять логгеры. Однако это сложнее, чем может показаться: с большой вероятностью получится интерфейс, заточенный под изначально выбранный логгер. Да и в целом, пока не набьешь кучу шишек, сложно заранее понять, какой именно набор методов в интерфейсе нам подойдет.

К счастью, умные люди уже подумали за нас и написали go-logr/logr, в целом, очень мне понравился. Советую почитать описание: авторы провели серьезную работу по переосмыслению логирования в Go.

Другой хороший вариант — slog. Это пакет для логирования, который позволяет отвязаться от конкретного логгера и легко заменять его при необходимости. Более подробно останавливаться на slog не будем — это тема для полноценной статьи. Оставлю только ссылку на пост с хорошей подборкой материалов о нем. В данном проекте — используем именно slog.

Другое


Для тестирования запросов можно взять привычный testify и httpexpect, тут без сюрпризов.

Для работы с конфигами — cleanenv. Это минималистичный пакет, в котором есть все необходимое: чтение из всех популярных форматов конфиг-файлов, поддержка переменных окружения, удобные struct-теги и другое.

Для хранения данных берем SQLite, потому что для работы с ней не нужно ничего устанавливать, и при этом мы имеем практически полноценную БД. Для пет-проекта это отличный вариант.

icfgqs47gczlbjspzmry5ypqazq.png

Конфигурация приложения


Приступим к коду и подготовим все необходимое для конфигурации сервиса. Создадим в корне папку config — здесь будем хранить файлы с конфигурацией. Я буду использовать yaml, но вы можете выбрать любой другой удобный вам формат. Главное, чтобы его поддерживал cleanenv.

Итак, в папке config создаем файл local.yaml:

# config/local.yaml

env: "local" # Окружение - local, dev или prod
storage_path: "./storage/storage.db" # файл, в котором будет храниться наша БД
http_server: # конфигурация нашего http-сервера
  address: "localhost:8082"
  timeout: 4s
  idle_timeout: 30s


Не забудьте освободить выбранный порт и создать папку, в которой будет размещен db-файл. Сам файл создавать не нужно, он появится автоматически.


Теперь создадим файл internal/config/config.go. Здесь и далее я подразумеваю пути от корня проекта: если такого пути еще нет, его надо создать. Например, так:

mkdir -p internal/config && touch internal/config/config.go


В config.go заведем структуры, в которые будем анмаршалить конфигурационный файл:

// internal/config/config.go

type Config struct {
    Env         string `yaml:"env" env-default:"development"`
    StoragePath string `yaml:"storage_path" env-required:"true"`
    HTTPServer
}

type HTTPServer struct {
    Address     string        `yaml:"address" env-default:"0.0.0.0:8080"`
    Timeout     time.Duration `yaml:"timeout" env-default:"5s"`
    IdleTimeout time.Duration `yaml:"idle_timeout" env-default:"60s"`
}


Здесь я использую cледующие struct-теги:

  • yaml — имя соответствующего параметра в Yaml-файле,
  • env-default — дефолтное значение,
  • env-required — делает параметры обязательными. Если такой параметр не указан, мы будем получать ошибку.


Теперь установим cleanenv и напишем функцию, которая будем возвращать заполненную структуру.

go get -u github.com/ilyakaznacheev/cleanenv

// internal/config/config.go

func MustLoad() *Config {
    // Получаем путь до конфиг-файла из env-переменной CONFIG_PATH
    configPath := os.Getenv("CONFIG_PATH")
    if configPath == "" {
        log.Fatal("CONFIG_PATH environment variable is not set")
    }

    // Проверяем существование конфиг-файла
    if _, err := os.Stat(configPath); err != nil {
        log.Fatalf("error opening config file: %s", err)
    }

    var cfg Config

    // Читаем конфиг-файл и заполняем нашу структуру
    err := cleanenv.ReadConfig(configPath, &cfg)
    if err != nil {
        log.Fatalf("error reading config file: %s", err)
    }

    return &cfg
}


Приставка Must в имени функции обычно говорит, что функция вместо возврата ошибки аварийно завершает работу приложения — например, будет паниковать. Таким подходом злоупотреблять не стоит, но иногда это бывает удобно. Например, если ваше приложение при запуске упадет с паникой из-за кривого или отсутствующего конфиг-файла, это нормально. А вот в бизнес-логике такого лучше не допускать…

Также обращаю внимание, что путь до конфиг-файла я получаю из переменной окружения CONFIG_PATH, дефолтный путь не предусмотрен. Чтобы передать значение такой переменной, можно запустить приложение следующей командой:

CONFIG_PATH=./config/local.yaml ./your-app


Есть и более удобные способы, но они зависят от вашего окружения, используемой IDE и т.п. Советую изучить этот вопрос самостоятельно.


Теперь создадим в корне проекта папку cmd — здесь будем хранить все команды для нашего проекта (например, запуск самого сервиса). В будущем здесь могут быть вспомогательные утилиты, моки и другое.

Далее создаем в cmd папку url-shortener, а внутри нее — файл main.go. Здесь будем конфигурировать и запускать наш сервис — в том числе и MustLoad ():

// cmd/url-shortener/main.go

package main

import (
    "url-shortener/internal/config"
)

func main() {
    cfg := config.MustLoad()
}


Настраиваем logger


Объект конфига у нас есть, теперь соберем логгер. Как я писал выше, использовать будем slog. Это очень гибкий пакет, и конкретная реализация может быть разной. Вы можете написать собственный хендлер (обработчик логов, который определяет, что происходит с записями), обернуть в него привычный логгер (например, zap или logrus) либо использовать дефолтные варианты, которые предоставляются вместе с пакетом. Я выберу последний вариант.

Устанавливаем slog:

go get golang.org/x/exp/slog


Если вы читаете статью уже после выхода Go 1.21, можете просто импортировать slog из std lib:

import "log/slog"


Из коробки в slog есть два вида хендлеров. Для локальной разработки нам подойдет TextHandler, а для деплоя лучше использовать JSONHandler, чтобы агрегатор логов (Kibana, Grafana, Loki и другие) мог его распарсить.

Кроме того, важно учесть уровень логирования — это минимальный уровень сообщений, которые будут выводиться. К примеру, если мы установим уровень Info, то Debug-сообщения не увидим. Поэтому для локальной разработки и Dev-окружения лучше использовать уровень Debug, а для продакшена — Info.

Для удобства вынесем создание логгера в отдельную функцию:

// cmd/url-shortener/main.go
const (
    envLocal = "local"
    envDev   = "dev"
    envProd  = "prod"
)

func main() {
    // ...
}

func setupLogger(env string) *slog.Logger {
    var log *slog.Logger

    switch env {
    case envLocal:
        log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
    case envDev, envProd:
        log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
    case envProd:
        log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
    }

    return log
}


В зависимости от окружения эта функция создает логгер с разными параметрами — TextHandler / JSONHandler и уровень LevelDebug / LevelInfo.


Теперь создадим логгер в main, добавим параметр env с помощью метода log.With и выведем информацию о запуске приложения:

// cmd/url-shortener/main.go

func main() {
    cfg := config.MustLoad()

    log := setupLogger(cfg.Env)
    log = log.With(slog.String("env", cfg.Env)) // к каждому сообщению будет добавляться поле с информацией о текущем окружении

    log.Info("initializing server", slog.String("address", cfg.Address)) // Помимо сообщения выведем параметр с адресом
    log.Debug("logger debug mode enabled")
}


Попробуем запустить приложение и посмотреть вывод:

time=2023-06-18T19:27:41.720+06:00 level=INFO msg="initializing server" env=local address=localhost:8082
time=2023-06-18T19:27:41.720+06:00 level=DEBUG msg="logger debug mode enabled" env=local


Благодаря функции With к каждому сообщению будет добавлено поле env с информацией о текущем окружении. Это очень удобно, советую получше изучить эту механику и обогащать свой логгер необходимой информацией.


Помимо стандартных реализаций, нам все же придется написать одну свою — DiscardHandler. В таком виде логгер будет игнорировать все сообщения, которые мы в него отправляем, — это понадобится в тестах. Создадим пакет slogdiscard и имплементируем в нем интерфейс slog.Handler:

// internal/lib/logger/handlers/slogdiscard/slogdiscard.go
package slogdiscard

import (
    "context"

    "golang.org/x/exp/slog"
)

func NewDiscardLogger() *slog.Logger {
    return slog.New(NewDiscardHandler())
}

type DiscardHandler struct{}

func NewDiscardHandler() *DiscardHandler {
    return &DiscardHandler{}
}

func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error {
    // Просто игнорируем запись журнала
    return nil
}

func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler {
    // Возвращает тот же обработчик, так как нет атрибутов для сохранения
    return h
}

func (h *DiscardHandler) WithGroup(_ string) slog.Handler {
    // Возвращает тот же обработчик, так как нет группы для сохранения
    return h
}

func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool {
    // Всегда возвращает false, так как запись журнала игнорируется
    return false
}


Также предлагаю создать пакет sl (сокращенно от slog), в который добавим некоторые функции для работы с логгером. Они пригодятся в будущем.

// internal/lib/logger/sl/sl.go
package sl

import (
    "golang.org/x/exp/slog"

    "url-shortener/internal/lib/logger/handlers/slogdiscard"
)

func Err(err error) slog.Attr {
    return slog.Attr{
        Key:   "error",
        Value: slog.StringValue(err.Error()),
    }
}


Пишем Storage


Теперь научим приложение сохранять информацию, с которой оно будет работать. Хранить будем всего одну сущность — ссылку с двумя полями:

  • url — длинный адрес, который мы сохраняем,
  • alias — короткий идентификатор, по которому будем искать оригинальный адрес.


Предлагаю использовать следующий формат таблицы:

CREATE TABLE IF NOT EXISTS url(
        id INTEGER PRIMARY KEY,
        alias TEXT NOT NULL UNIQUE,
        url TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_alias ON url(alias);


Обратите внимание:

  • поле alias уникальное (параметр UNIQUE), чтобы не было коллизий,
  • все поля обязательные,
  • для быстрого поиска записей создаем индекс idx_alias.


Здесь мы могли бы обойтись даже без поле id и оставить в качестве уникального идентификатора только alias, но мне такой вариант не нравится. Например, потому что записи будут удаляться и создаваться с повторяющимися алиасами, но id всегда будет уникальным. Это может когда-нибудь помочь в дебаге и т.п.

Код Storage будет находиться в папке internal/storage — создадим в ней файл storage.go. В нем будет лишь базовый для всех имплементаций код. Сейчас такого кода мало — только информация о возможных ошибках:

// internal/storage/storage.go

package storage

import "errors"

var (
    ErrURLNotFound = errors.New("url not found")
    ErrURLExists   = errors.New("url exists")
)


Далее здесь же создаем папку sqlite, в которой будем писать код для этой СУБД. Если в будущем захотите переехать на другой тип хранилища, просто создайте рядом соответствующую папку и напишите аналогичную реализацию. Таким образом мы не будем привязываться к конкретной реализации.

Теперь установим библиотеку для работы с sqlite и создадим структуру для объекта Storage.

go get github.com/mattn/go-sqlite3

// internal/storage/sqlite/sqlite.go

type Storage struct {
    db *sql.DB
}


И его конструктор:

// internal/storage/sqlite/sqlite.go

func New(storagePath string) (*Storage, error) {
    const op = "storage.sqlite.NewStorage" // Имя текущей функции для логов и ошибок

    db, err := sql.Open("sqlite3", storagePath) // Подключаемся к БД
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    // Создаем таблицу, если ее еще нет
    stmt, err := db.Prepare(`
    CREATE TABLE IF NOT EXISTS url(
        id INTEGER PRIMARY KEY,
        alias TEXT NOT NULL UNIQUE,
        url TEXT NOT NULL);
    CREATE INDEX IF NOT EXISTS idx_alias ON url(alias);
    `)
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    _, err = stmt.Exec()
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    return &Storage{db: db}, nil
}


Зачем здесь константа op? Я стараюсь всегда добавлять имя текущей функции в возвращаемые ошибки и в логгер, чтобы потом было проще «искать хвосты» в логах. Ведь разные функции часто возвращают одинаковые ошибки и пишут одинаковые логи, а нам обычно нужно понимать, где именно произошло событие.

К примеру, я оборачиваю возвращаемую функцией sql.Open ошибку таким образом: fmt.Errorf (»%s: %w», op, err).

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


Методы хранилища


У нашего хранилища будет всего два метода — SaveURL () и GetURL (). Начнем с первого:

// internal/storage/sqlite/sqlite.go

func (s *Storage) SaveURL(urlToSave string, alias string) (int64, error) {
    const op = "storage.sqlite.SaveURL"

    // Подготавливаем запрос
    stmt, err := s.db.Prepare("INSERT INTO url(url,alias) values(?,?)")
    if err != nil {
        return 0, fmt.Errorf("%s: prepare statement: %w", op, err)
    }

    // Выполняем запрос
    res, err := stmt.Exec(urlToSave, alias)
    if err != nil {
        if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
            return 0, fmt.Errorf("%s: %w", op, storage.ErrURLExists)
        }

        return 0, fmt.Errorf("%s: execute statement: %w", op, err)
    }

    // Получаем ID созданной записи
    id, err := res.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("%s: failed to get last insert id: %w", op, err)
    }

    // Возвращаем ID
    return id, nil
}


Тут все просто и понятно, поясню только эту строчку:

if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
// …


Здесь мы приводим полученную ошибку ко внутреннему типу библиотеки sqlite3, чтобы посмотреть, не является ли эта ошибка sqlite3.ErrConstraintUnique. Если это так, значит, мы попытались добавить дубликат имеющейся записи. Об этом мы сообщим в вызывающую функцию, вернув уже свою ошибку для данной ситуации: storage.ErrURLExists. Получив ее, сервер сможет сообщить клиенту о том, что такой alias у нас уже есть.

Тут можно поступить иначе: сначала проверять наличие записи с помощью SELECT и добавлять записи при отсутствии дубликатов. Но тогда бы понадобились транзакции, и код стал бы сложнее.

Аналогичным образом пишем метод GetURL ():

// internal/storage/sqlite/sqlite.go

func (s *Storage) GetURL(alias string) (string, error) {
    const op = "storage.sqlite.GetURL"

    stmt, err := s.db.Prepare("SELECT url FROM url WHERE alias = ?")
    if err != nil {
        return "", fmt.Errorf("%s: prepare statement: %w", op, err)
    }

    var resURL string
    
    err = stmt.QueryRow(alias).Scan(&resURL)
    if errors.Is(err, sql.ErrNoRows) {
        return "", storage.ErrURLNotFound
    }
    if err != nil {
        return "", fmt.Errorf("%s: execute statement: %w", op, err)
    }

    return resURL, nil
}


Надеюсь, здесь пояснения не нужны. Метод DeleteURL можете написать самостоятельно, в качестве упражнения.


Наконец добавим создание объекта Storage в функцию main:

// cmd/url-shortener/main.go

func main() {
    // ...
    storage, err := sqlite.New(cfg.StoragePath)
    if err != nil {
        log.Error("failed to initialize storage", sl.Err(err))
    }


Забегая вперед, интерфейс для Storage мы тут объявлять не будем — он будет находиться в месте использования. Мотивацией такого решения я делился в отдельном ролике.

Подготовка HTTP Server

Переходим к самому интересному — работе с HTTP-сервером. Первым делом установим наш chi:

go get -u github.com/go-chi/chi/v5


И еще нам понадобится пакет go-chi/render, который идет отдельно от роутера:

go get github.com/go-chi/render

Middleware


В main создадим объект роутера и подключим к нему необходимый middleware:

// cmd/url-shortener/main.go

router := chi.NewRouter()  
  
router.Use(middleware.RequestID) // Добавляет request_id в каждый запрос, для трейсинга
router.Use(middleware.Logger) // Логирование всех запросов
router.Use(middleware.Recoverer)  // Если где-то внутри сервера (обработчика запроса) произойдет паника, приложение не должно упасть
router.Use(middleware.URLFormat) // Парсер URLов поступающих запросов


Все эти middleware доступны из коробки в пакете chi. Обсудим тут пару моментов.

По умолчанию middleware.Logger использует свой собственный внутренний логгер, который желательно переопределить, чтобы использовался наш. Иначе могут возникнуть проблемы — например, со сбором логов. Либо можно написать собственный middleware для логирования запросов:

// internal/http-server/middleware/logger/logger.go

package logger

import (
    "net/http"
    "time"

    "github.com/go-chi/chi/v5/middleware"
    "golang.org/x/exp/slog"
)

func New(log *slog.Logger) func(next http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        log = log.With(
            slog.String("component", "middleware/logger"),
        )

        log.Info("logger middleware enabled")

        // код самого обработчика
        fn := func(w http.ResponseWriter, r *http.Request) {
            // собираем исходную информацию о запросе
            entry := log.With(
                slog.String("method", r.Method),
                slog.String("path", r.URL.Path),
                slog.String("remote_addr", r.RemoteAddr),
                slog.String("user_agent", r.UserAgent()),
                slog.String("request_id", middleware.GetReqID(r.Context())),
            )
            
            // создаем обертку вокруг `http.ResponseWriter`
            // для получения сведений об ответе
            ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)

            // Момент получения запроса, чтобы вычислить время обработки
            t1 := time.Now()
            
            // Запись отправится в лог в defer
            // в этот момент запрос уже будет обработан
            defer func() {
                entry.Info("request completed",
                    slog.Int("status", ww.Status()),
                    slog.Int("bytes", ww.BytesWritten()),
                    slog.String("duration", time.Since(t1).String()),
                )
            }()

            // Передаем управление следующему обработчику в цепочке middleware
            next.ServeHTTP(ww, r)
        }

        // Возвращаем созданный выше обработчик, приведя его к типу http.HandlerFunc
        return http.HandlerFunc(fn)
    }
}


Подключается middleware следующим образом:

router.Use(mwLogger.New(log))


Если вы решили завести себе такой middleware, разместить его рекомендую в internal/http-server/middleware.

Handlers — обработчики запросов


Save — сохранение нового URL


Вот и добрались до главного — обработчиков запросов. Начнем с запроса на сохранение новой записи. Создаем папку internal/http-server/handlers/save и одноименный файл save.go.

Заведем сразу две структуры — Request и Response. В первый будем анмаршалить запрос, а из второго формировать ответ.

// internal/http-server/handlers/url/save/save.go

type Request struct {
    URL   string `json:"url" validate:"required,url"`
    Alias string `json:"alias,omitempty"`
}

type Response struct {
    Status string `json:"status"`
    Error  string `json:"error,omitempty"`
    Alias string `json:"alias"`
}


validate: «required, url» — эта строчка для валидации, об этом будет ниже.

Зачем в ответе поле Alias: если в запросе он не был указан, то мы сгенерируем случайный, и клиент должен его узнать.

Опытный глаз сразу заметит два привычных поля — Status и Error. Как и во многих других API-сервисах, эти поля могут присутствовать в ответе любого хэндлера. А раз так, то имеет смысл их вынести в общий пакет. В моем случае он будет тут: internal/lib/api/response.

Также я завел константы, которыми будем заполнять поле Status:

// internal/lib/api/response/response.go

type Response struct {
    Status string `json:"status"`
    Error  string `json:"error,omitempty"`
}

const (
    StatusOK    = "OK"
    StatusError = "Error"
)


Теперь Response будет выглядеть следующим образом:


// internal/http-server/handlers/url/save/save.go

import (
    // ...

    // для краткости даем короткий алиас пакету
    resp "url-shortener/internal/lib/api/response"
)

type Response struct {
    resp.Response
    Alias string `json:"alias,omitempty"`
}


Этот хендлер будет сохранять полученные URL-строки, поэтому ему нужен Storage, а точнее его метод — SaveURL. Опишем соответствующий интерфейс:

type URLSaver interface {
    SaveURL(URL, alias string) (int64, error)
}


Теперь переходим к самому хендлеру. Для его получения будем использовать конструктор — функцию New.

// internal/http-server/handlers/url/save/save.go

import (
    // ...
    
    // Напоминаю, что тут мы используем алиас для краткости
    resp "url-shortener/internal/lib/api/response"
)

func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        const op = "handlers.url.save.New"


        // Добавляем к текущму объекту логгера поля op и request_id
        // Они могут очень упростить нам жизнь в будущем
        log = log.With(
            slog.String("op", op),
            slog.String("request_id", middleware.GetReqID(r.Context())),
        )

        // Создаем объект запроса и анмаршаллим в него запрос
        var req Request

        err := render.DecodeJSON(r.Body, &req)
        if errors.Is(err, io.EOF) {
            // Такую ошибку встретим, если получили запрос с пустым телом
            // Обработаем её отдельно
            log.Error("request body is empty")

            render.JSON(w, r, render.JSON(w, r, resp.Response{
                Status: resp.StatusError,
                Error:  "empty request",
            }))

            return
        }
        if err != nil {
            log.Error("failed to decode request body", sl.Err(err))

            render.JSON(w, r, render.JSON(w, r, resp.Response{
                Status: resp.StatusError,
                Error:  "failed to decode request",
            }))

            return
        }

        // Лучше больше логов, чем меньше - лишнее мы легко сможем почистить,
        // при необходимости. А вот недостающую информацию мы уже не получим.
        log.Info("request body decoded", slog.Any("req", req))

        // ...
    }
}


Объект urlSaver передадим при создании хендлера из main.

Этот код можно сделать немного красивее, если вынести повторяющийся код формирования объекта ответа в общую функцию. Напишем ее в том же пакете response:

// internal/lib/api/response/response.go

func Error(msg string) Response {
    return Response{
        Status: StatusError,
        Error:  msg,
    }
}

func OK() Response {
    return Response{
        Status: StatusOK,
    }
}


Теперь код в save.go будет выглядеть следующим образом:

// internal/http-server/handlers/url/save/save.go

err := render.DecodeJSON(r.Body, &req)
if errors.Is(err, io.EOF) {
    log.Error("request body is empty")

    render.JSON(w, r, resp.Error("empty request")) // <----

    return
}
if err != nil {
    log.Error("failed to decode request body", sl.Err(err))

    render.JSON(w, r, resp.Error("failed to decode request")) // <----

    return
}


Далее нам нужно провалидировать запрос. Один из вариантов — сделать это вручную, проверив, что URL — это действительно URL, что он не пустой. Наш сервис очень маленький, поэтому такого метода вполне достаточно. Но в общем случае лучше использовать специализированный пакет, который сильно упрощает жизнь — например, go-playground/validator. Я покажу, как им пользоваться, а вы сами решайте, что вам больше нравится.

Вспоминаем про validate: «required, url» в объекте Request — он как раз будет использован валидатором. Для валидации нужно проделать следующее:

// internal/http-server/handlers/url/save/save.go

func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {

    // ...

    // Создаем объект валидатора
    // и передаем в него структуру, которую нужно провалидировать
    if err := validator.New().Struct(req); err != nil {
        // Приводим ошибку к типу ошибки валидации
        validateErr := err.(validator.ValidationErrors)
    
        log.Error("invalid request", sl.Err(err))
    
        render.JSON(w, r, resp.Error(validateErr.Error()))
    
        return
    }


В случае некорректного ввода данных, клиент получит такой ответ:

{
    "status": "Error",
    "error": "Key: 'Request.URL' Error:Field validation for 'URL' failed on the 'url' tag"
}


Можете оставить так, но мне такой ответ не нравится — пользователю сервиса может быть непонятно, что тут написано. Для формирование более ясного ответа лучше добавить в пакет response такую функцию:

// internal/lib/api/response/response.go

func ValidationError(errs validator.ValidationErrors) Response {
    var errMsgs []string

    for _, err := range errs {
        switch err.ActualTag() {
        case "required":
            errMsgs = append(errMsgs, fmt.Sprintf("field %s is a required field", err.Field()))
        case "url":
            errMsgs = append(errMsgs, fmt.Sprintf("field %s is not a valid URL", err.Field()))
        default:
            errMsgs = append(errMsgs, fmt.Sprintf("field %s is not valid", err.Field()))
        }
    }

    return Response{
        Status: StatusError,
        Error:  strings.Join(errMsgs, ", "),
    }
}


Теперь обработчик вернет внятный ответ:

render.JSON(w, r, resp.ValidationError(validateErr))
{
    "status": "Error",
    "error": "field URL is not a valid URL"
}


Alias проверяем вручную. Если он пустой — генерируем случайный:

// internal/http-server/handlers/url/save/save.go

// TODO: move to config when needed
const aliasLength = 6

func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // ...
    
        alias := req.Alias
        if alias == "" {
            alias = random.NewRandomString(aliasLength)
        }
    }
}


Тут можете сгенерировать строку своими методами либо использовать для этого готовую библиотеку. Я же использую random, в котором реализовал функцию NewRandomString:

// internal/lib/random/random.go

// NewRandomString generates random string with given size.
func NewRandomString(size int) string {
    rnd := rand.New(rand.NewSource(time.Now().UnixNano()))

    chars := []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
        "abcdefghijklmnopqrstuvwxyz" +
        "0123456789")

    b := make([]rune, size)
    for i := range b {
        b[i] = chars[rnd.Intn(len(chars))]
    }

    return string(b)
}


Советую покрыть эту функцию тестами и сделать это в качестве упражнения, самостоятельно. Если все же хотите посмотреть мои тесты, они будут в репозитории проекта.


Осталось только сохранить URL и Alias, а после — вернуть ответ с сообщением об успехе.

// internal/http-server/handlers/url/save/save.go

func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // ...

        id, err := urlSaver.SaveURL(req.URL, alias)
        if errors.Is(err, storage.ErrURLExists) {
            // Отдельно обрабатываем ситуацию,
            // когда запись с таким Alias уже существует
            log.Info("url already exists", slog.String("url", req.URL))

            render.JSON(w, r, resp.Error("url already exists"))

            return
        }
        if err != nil {
            log.Error("failed to add url", sl.Err(err))

            render.JSON(w, r, resp.Error("failed to add url"))

            return
        }

        log.Info("url added", slog.Int64("id", id))

        responseOK(w, r, alias)
    }
}


Функцию responseOK опишем в этом же файле:

// internal/http-server/handlers/url/save/save.go

func responseOK(w http.ResponseWriter, r *http.Request, alias string) {
    render.JSON(w, r, Response{
        Response: resp.OK(),
        Alias:    alias,
    })
}


Супер — хендлер полностью написан. Если хотите посмотреть его код целиком, можете заглянуть в репозиторий проекта.

Чтобы все это протестировать, напишем простой тест с использованием пакета httptest из стандартной библиотеки. И вместо настоящего Storage будем использовать Mock (мок). На эту тему у меня также есть подробный ролик — там я рассказываю про суть моков и их генерацию.

Для генерации мока используем библиотеку mockery, добавив рядом с описанием интерфейса вот такую аннотацию:

//go:generate go run github.com/vektra/mockery/v2@v2.28.2 --name=URLSaver
type URLSaver interface {
    SaveURL(URL, alias string) (int64, error)
}


После — генерируем сам мок с помощью команды:

./internal/http-server/handlers/url/save/save.go


Теперь напишем сам тест. Рядом с файлом save.go создадим — save_test.go. Там у нас будет классический табличный тест:

// internal/http-server/handlers/url/save/save_test.go
func TestSaveHandler(t *testing.T) {
    cases := []struct {
        name      string // Имя теста
        alias     string // Отправляемый alias
        url       string // Отправляемый URL
        respError string // Какую ошибку мы должны получить?
        mockError error  // Ошибку, которую вернёт мок
    }{
        {
            name:  "Success",
            alias: "test_alias",
            url:   "https://google.com",
            // Тут поля respError и mockError оставляем пустыми,
            // т.к. это успешный запрос
        },
        // Другие кейсы ...
    }

    for _, tc := range cases {  
        t.Run(tc.name, func(t *testing.T) {
            // Создаем объект мока стораджа
            urlSaverMock := mocks.NewURLSaver(t)

            // Если ожидается успешный ответ, значит к моку точно будет вызов
            // Либо даже если в ответе ожидаем ошибку,
            // но мок должен ответить с ошибкой, к нему тоже будет запрос:
            if tc.respError == "" || tc.mockError != nil {
                // Сообщаем моку, какой к нему будет запрос, и что надо вернуть
                urlSaverMock.On("SaveURL", tc.url, mock.AnythingOfType("string")).
                    Return(int64(1), tc.mockError).
                    Once() // Запрос будет ровно один
            }

            // Создаем наш хэндлер
            handler := save.New(sl.NewDiscardLogger(), urlSaverMock)

            // Формируем тело запроса
            input := fmt.Sprintf(`{"url": "%s", "alias": "%s"}`, tc.url, tc.alias)

            // Создаем объект запроса
            req, err := http.NewRequest(http.MethodPost, "/save", bytes.NewReader([]byte(input)))
            require.NoError(t, err)

            // Создаем ResponseRecorder для записи ответа хэндлера
            rr := httptest.NewRecorder()
            // Обрабатываем запрос, записывая ответ в рекордер
            handler.ServeHTTP(rr, req)

            // Проверяем, что статус ответа корректный
            require.Equal(t, rr.Code, http.StatusOK)

            body := rr.Body.String()

            var resp save.Response

            // Анмаршаллим тело, и проверяем что при этом не возникло ошибок
            require.NoError(t, json.Unmarshal([]byte(body), &resp))

            // Проверяем наличие требуемой ошибки в ответе
            require.Equal(t, tc.respError, resp.Error)

            // Другие проверки
        })
    }
}


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


Возвращаемся в main и добавляем наш первый хендлер в роутер:

router.Post("/", save.New(log, storage))


На этом этапе советую остановиться и поиграться с получившимся сервисом. Убедитесь, что он запускается и попробуйте отправить в него «честные» запросы — например, через Postman.

Redirect — перенаправление на сохраненный URL


Переходим к следующему хендлеру — redirect. Это будет GET-запрос, поэтому объект Request здесь не потребуется, как и Response. Ведь возвращать мы тоже ничего не будем, а просто сделаем редирект. Код хендера будет таким:

// cmd/url-shortener/main.go

//go:generate go run github.com/vektra/mockery/v2@v2.28.2 --name=URLGetter
//
// URLGetter is an interface for getting url by alias.
type URLGetter interface {
    GetURL(alias string) (string, error)
}

func New(log *slog.Logger, urlGetter URLGetter) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        const op = "handlers.url.redirect.New"

        log = log.With(
            slog.String("op", op),
            slog.String("request_id", middleware.GetReqID(r.Context())),
        )

        // Роутер chi позволяет делать вот такие финты -
        // получать GET-параметры по их именам.
        // Имена определяются при добавлении хэндлера в роутер, это будет ниже.
        alias := chi.URLParam(r, "alias")
        if alias == "" {
            log.Info("alias is empty")

            render.JSON(w, r, resp.Error("not found"))

            return
        }

        // Находим URL по алиасу в БД
        resURL, err := urlGetter.GetURL(alias)
        if errors.Is(err, storage.ErrURLNotFound) {
            // Не нашли URL, сообщаем об этом клиенту
            log.Info("url not found", "alias", alias)

            render.JSON(w, r, resp.Error("not found"))

            return
        }
        if err != nil {
            // Не удалось осуществить поиск
            log.Error("failed to get url", sl.Err(err))

            render.JSON(w, r, resp.Error("internal error"))

            return
        }

        log.Info("got url", slog.String("url", resURL))

        // Делаем редирект на найденный URL
        http.Redirect(w, r, resURL, http.StatusFound)
    }
}


В последней строчке делаем редирект со статусом http.StatusFound — код HTTP 302. Он обычно используется для временных перенаправлений, а не постоянных, за которые отвечает 301.

Наш сервис может перенаправлять на разные URL в зависимости от ситуации (мы ведь можем удалить или изменить сохраненный URL), поэтому есть смысл использовать именно http.StatusFound. Это важно для систем кэширования и поисковых машин — они обычно кэшируют редиректы с кодом 301, то есть считают их постоянными. Нам такое поведение не нужно.

Подключаем новый хендлер в main:

router.Get("/{alias}", redirect.New(log, storage))


Здесь формируем путь для обращения и именуем его параметр — {alias}. В хендлере можно получить этот параметр по указанному имени, что мы и сделали выше.


Это очень удобная и гибкая штука. Вы можете формировать и более сложные пути, например:

router.Get("/v1/{user_id}/uid", redirect.New(log, storage))


При запросе вида /v1/1234/uid вы можете извлечь параметр 1234 по имени user_id. Если в будущем формат пути изменится, на код хендлера это никак не повлияет. Главное — сохранить имя параметра.

Рекомендую вам написать самостоятельно тесты для этого запроса, в качестве очередного упражнения. Либо можете посмотреть мои в репозитории проекта.

Также можете самостоятельно написать запрос на удаление URL. Подключать его рекомендую так:

r.Delete("/{alias}", remove.New(log, storage))


Путь будет как у редиректа, но тип запроса — DELETE. Это более правильно для REST API. Либо /url/{alias}, если планируете удалять какие-то сущности, кроме URL.

Авторизация


Функционал сервиса полностью готов, но его ручки открыты для всех пользователей. Если мы пишем сервис для личного пользования, то мы этого, конечно же, не хотим. Поэтому нужно добавить авторизацию для ручки save. Ну и для remove/delete, если вы такую написали.

Здесь мы реализуем самую простейшую авторизацию HTTP Basic Auth — стандартную проверку по логину и паролю. Если захотите выдать доступы своим друзьям, достаточно просто завести несколько пар «логин-пароль» — это не проблема. Но если вы решите открыть сервис для всех желающих, лучше написать более серьезную систему — возможно, с распределением прав доступа и другими фичами. Либо можете взять готовое решение.

Пару логин-пароль (креды, credentials) будем брать из конфига приложения. Не переживайте, мы НЕ будем хранить пароль в общем конфиге. При деплое будем его пробрасывать через секреты GitHub Actions.

Для начала добавим в объект конфига сервера поля User и Password:

// internal/config/config.go

type HTTPServer struct {
    Address     string        `yaml:"address" env-default:"0.0.0.0:8080"`
    Timeout     time.Duration `yaml:"timeout" env-default:"5s"`
    IdleTimeout time.Duration `yaml:"idle_timeout" env-default:"60s"`
    // Добавляем:
    User        string        `yaml:"user" env-required:"true"`
    Password    string        `yaml:"password" env-required:"true" env:"HTTP_SERVER_PASSWORD"`
}


В свой локальный конфиг проекта можете добавить креды в явном виде:

# config/local.yaml

env: "local"
storage_path: "./storage/storage.db"
http_server:
  address: "localhost:8082"
  timeout: 4s
  idle_timeout: 30s
  user: "my_user"
  password: "my_pass"


Обратите внимание: в продакшен-конфиг добавляем только логин. Пароль важно хранить более безопасным образом — подробнее в разделе про деплой.


В функции main немного изменим регистрацию хендлеров в роутере. Для защищенных хендлеров создадим отдельный вложенный роутер, к которому подключим middleware с авторизацией (он идет вместе с пакетом chi).

// cmd/url-shortener/main.go

// Все пути этого роутера будут начинаться с префикса `/url`
router.Route("/url", func(r chi.Router) {
    // Подключаем авторизацию
    r.Use(middleware.BasicAuth("url-shortener", map[string]string{
        // Передаем в middleware креды
        cfg.HTTPServer.User: cfg.HTTPServer.Password,
        // Если у вас более одного пользователя,
        // то можете добавить остальные пары по аналогии.
    }))

    r.Post("/", save.New(log, storage))
})

// Хэндлер redirect остается снаружи, в основном роутере
router.Get("/{alias}", redirect.New(log, storage))


Функциональные тесты


Ранее мы уже писали тесты, но они проверяют отдельные кусочки сервиса, пропуская некоторые важные этапы. К примеру, тесты хэндлеров не проверяют авторизацию запросов, а это очень важный компонент.

Для тестирования всего сервиса целиком имеет смысл написать функциональные тесты, которые будут его тестировать как некую черную коробку. Сервис будет честно запускаться и не подозревать, что его тестируют.

Инфраструктура при этом поднимается в докере, а зависимости, при наличии, заменяются сетевыми моками. Зависимостей у меня пока нет, но в будущем я планирую записать более подробный ролик про функциональные тесты, где будет разобран и этот момент (подписывайтесь на мой канал, чтобы не пропустить).

Здесь мы используем две новые библиотеки, которые очень упрощают написание тестов: