Перейти к содержанию

Как добавить HTTP-endpoint

Пошаговый рецепт на примере POST /v1/reviews (создание отзыва). В другом сервисе шаги такие же, меняется только имя ресурса.

Правила, которые ниже только упоминаются, подробно описаны в ../conventions/http-api.md и ../conventions/go-style.md.

Содержание

1. Спроектировать контракт

До кода — реши:

  • Method / path. Для создания — POST /v1/reviews. Для получения одного — GET /v1/reviews/{id}. Для частичного обновления — PATCH, не PUT.
  • Request shape. Какие поля обязательны, какие опциональны, какие лимиты (min/max, длина строк).
  • Response shape. Что вернём клиенту — id, всё тело ресурса, что-то production. Cursor для list'ов.
  • Коды ошибок. Какие из sentinel-ошибок service будут возвращаться и в какие HTTP-статусы маппиться.

Запиши как mini-OpenAPI-сниппет прямо в PR-описании или в api/openapi.yaml сервиса:

paths:
  /v1/reviews:
    post:
      summary: Create review
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateReviewRequest' }
      responses:
        '201': { description: Created }
        '400': { description: Validation failed }
        '409': { description: Review already exists }

2. Создать DTO

Если endpoint только для собственных клиентов сервиса — в internal/handler/types.go. Если это публичный internal API, который дёргают другие сервисы, — в pkg/dto/review.go.

// internal/handler/types.go
package handler

type CreateReviewRequest struct {
    PlaceID int64  `json:"place_id" validate:"required,gt=0"`
    Rating  int16  `json:"rating"   validate:"required,min=1,max=5"`
    Text    string `json:"text"     validate:"max=2000"`
}

type CreateReviewResponse struct {
    ID        int64     `json:"id"`
    PlaceID   int64     `json:"place_id"`
    Rating    int16     `json:"rating"`
    Text      string    `json:"text"`
    CreatedAt time.Time `json:"created_at"`
}

JSON-теги — snake_case. Валидатор-теги — go-playground/validator. Подробнее про именование — ../conventions/go-style.md.

3. Validation helper (если нужен)

Стандартных тегов validator'а хватает на 90% случаев. Если нужна кастомная проверка (например, «текст не матерщинный»), добавь helper в internal/handler/validation.go:

func containsProfanity(fl validator.FieldLevel) bool {
    return !profanity.Matches(fl.Field().String())
}

func init() {
    _ = validatorInstance.RegisterValidation("clean", containsProfanity)
}

Потом в DTO: Text string \validate:"max=2000,clean"``.

4. Написать handler-метод

// internal/handler/review.go
package handler

import (
    "net/http"
    "strings"

    "github.com/example/review-service/internal/middleware"
    "github.com/example/review-service/internal/service"
    "github.com/example/review-service/internal/log"
)

type ReviewHandler struct {
    svc *service.ReviewService
}

func NewReviewHandler(svc *service.ReviewService) *ReviewHandler {
    return &ReviewHandler{svc: svc}
}

func (h *ReviewHandler) CreateReview(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
        writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "need application/json")
        return
    }

    var req CreateReviewRequest
    if err := decodeJSON(r, &req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid_request", "bad json body")
        return
    }
    if err := validate(req); err != nil {
        writeValidationError(w, err)
        return
    }

    userID, ok := middleware.UserIDFrom(ctx)
    if !ok {
        writeError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
        return
    }

    review, err := h.svc.Create(ctx, service.CreateReviewCommand{
        UserID:  userID,
        PlaceID: req.PlaceID,
        Rating:  req.Rating,
        Text:    req.Text,
    })
    if err != nil {
        log.FromCtx(ctx).Warn("create review failed", "err", err, "place_id", req.PlaceID)
        mapServiceError(w, err)
        return
    }
    log.FromCtx(ctx).Info("review created", "review_id", review.ID, "place_id", review.PlaceID)

    writeJSON(w, http.StatusCreated, map[string]any{
        "data": CreateReviewResponse{
            ID:        review.ID,
            PlaceID:   review.PlaceID,
            Rating:    review.Rating,
            Text:      review.Text,
            CreatedAt: review.CreatedAt,
        },
    })
}

Шаблон каждого handler'а: декод → валидация → достать user_id из ctx → вызов service → маппинг ошибок → success-ответ. Не смешивай эти шаги и не добавляй «заодно» бизнес-проверки в handler.

5. Добавить service-метод

// internal/service/review.go
package service

type CreateReviewCommand struct {
    UserID  int64
    PlaceID int64
    Rating  int16
    Text    string
}

func (s *ReviewService) Create(ctx context.Context, cmd CreateReviewCommand) (*domain.Review, error) {
    if cmd.Rating < 1 || cmd.Rating > 5 {
        return nil, fmt.Errorf("rating: %w", ErrInvalidInput)
    }
    var review *domain.Review
    err := s.db.InTx(ctx, func(tx pgx.Tx) error {
        r, err := s.reviews.CreateTx(ctx, tx, cmd.UserID, cmd.PlaceID, cmd.Rating, cmd.Text)
        if err != nil {
            return err
        }
        if err := s.publisher.Publish(ctx, tx, "review.created", r.ID, ReviewCreatedPayload{
            ReviewID: r.ID, PlaceID: r.PlaceID, UserID: r.UserID, Rating: r.Rating,
        }); err != nil {
            return err
        }
        review = r
        return nil
    })
    if err != nil {
        return nil, fmt.Errorf("create review: %w", err)
    }
    return review, nil
}

Service-слой не знает про HTTP. Принимает command, возвращает domain. Транзакция + outbox внутри. См. ../conventions/db-pgx.md и ../conventions/events.md.

6. Sentinel-ошибки

Если появились новые бизнес-ошибки (ErrReviewAlreadyExists), объявляй их в internal/service/errors.go:

var (
    ErrInvalidInput          = errors.New("invalid input")
    ErrReviewAlreadyExists   = errors.New("review already exists for this place")
    ErrReviewNotFound        = errors.New("review not found")
)

И добавь ветку в mapServiceError (internal/handler/http.go):

case errors.Is(err, service.ErrReviewAlreadyExists):
    writeError(w, http.StatusConflict, "review_exists", "review already exists")

7. Зарегистрировать маршрут

// internal/handler/router.go
r.Route("/v1/reviews", func(r chi.Router) {
    r.Use(middleware.GatewayAuth(d.Cfg.Gateway.HMACKey))
    r.Use(middleware.RateLimit(d.Redis, "review_create", d.Cfg.Rate.ReviewCreatePerMin))

    r.Post("/", d.Review.CreateReview)
    r.Get("/{id}", d.Review.GetReview)
})
  • Правильный scope middleware: auth + rate-limit внутри /v1/reviews. Не пробрасывай их на /healthz и /metrics.
  • Не дублируй rate-limit, если он уже на корневом роутере — выбери один уровень.

8. Тесты

Unit для service

// internal/service/review_test.go
package service_test

func TestReviewService_Create(t *testing.T) {
    repo := &fakeReviewRepo{}
    pub := &fakePublisher{}
    db := &fakeTxRunner{}
    svc := service.NewReviewService(db, repo, pub)

    got, err := svc.Create(context.Background(), service.CreateReviewCommand{
        UserID: 42, PlaceID: 7, Rating: 5, Text: "ok",
    })
    if err != nil {
        t.Fatalf("create: %v", err)
    }
    if got.Rating != 5 {
        t.Fatalf("rating: got %d want 5", got.Rating)
    }
    if pub.lastEventType != "review.created" {
        t.Fatalf("event: got %q want review.created", pub.lastEventType)
    }
}

Handler-тест через httptest

// internal/handler/review_test.go
package handler_test

func TestCreateReview_Success(t *testing.T) {
    fakeSvc := &fakeReviewService{createResult: &domain.Review{ID: 1001, PlaceID: 7, Rating: 5}}
    h := handler.NewReviewHandler(fakeSvc)

    body := strings.NewReader(`{"place_id":7,"rating":5,"text":"ok"}`)
    req := httptest.NewRequest(http.MethodPost, "/v1/reviews", body)
    req.Header.Set("Content-Type", "application/json")
    req = req.WithContext(middleware.WithUserID(req.Context(), 42))

    rec := httptest.NewRecorder()
    h.CreateReview(rec, req)

    if rec.Code != http.StatusCreated {
        t.Fatalf("status: %d, body: %s", rec.Code, rec.Body.String())
    }
}

Правила тестов — ../conventions/testing.md.

9. OpenAPI

Если сервис держит api/openapi.yaml (или swaggo-аннотации), обнови контракт:

  • Добавь путь и схему request/response.
  • Укажи security-scheme (BearerAuth для /v1/*, ApiKeyAuth с X-Internal-Token для /internal/*).
  • Прогон make openapi-lint (если цель есть), либо валидация через redocly lint api/openapi.yaml.

10. .env.example

Если появились новые env-переменные (новый лимит rate-limit, новая ссылка на downstream), добавь их в .env.example с dev-значением:

RATE_REVIEW_CREATE_PER_MIN=60

И обнови internal/config/config.go, чтобы они действительно читались и валидировались (fail-fast при старте если обязательные пустые).

11. Прогон и ревью

make lint
make test

Всё зелёное — пройдись по ../checklists/pr-author.md и запрашивай review.

Типовые ошибки

  • Handler обращается в repository напрямую. Нет: handler → service → repository.
  • Sentinel-ошибка из service возвращается как 500. Добавь её в mapServiceError.
  • Забытый return после writeError — в ответ уйдут два JSON'а.
  • Декодирование тела в handler, который не должен принимать тело (GET/DELETE). Убери decodeJSON.
  • Пропущена валидация — в service попадает мусор. Всегда validate(req) после decodeJSON.

Связанные разделы