Как добавить HTTP-endpoint¶
Пошаговый рецепт на примере POST /v1/reviews (создание отзыва). В другом
сервисе шаги такие же, меняется только имя ресурса.
Правила, которые ниже только упоминаются, подробно описаны в
../conventions/http-api.md и
../conventions/go-style.md.
Содержание¶
- 1. Спроектировать контракт
- 2. Создать DTO
- 3. Validation helper (если нужен)
- 4. Написать handler-метод
- 5. Добавить service-метод
- 6. Sentinel-ошибки
- 7. Зарегистрировать маршрут
- 8. Тесты
- 9. OpenAPI
- 10.
.env.example - 11. Прогон и ревью
- Типовые ошибки
- Связанные разделы
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-значением:
И обнови internal/config/config.go, чтобы они действительно читались и
валидировались (fail-fast при старте если обязательные пустые).
11. Прогон и ревью¶
Всё зелёное — пройдись по ../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.
Связанные разделы¶
../conventions/http-api.md— routing, middleware, error mapping,/v1/*vs/internal/*.../checklists/new-endpoint.md— чеклист PR-автора на новый endpoint.../checklists/pr-author.md— общий чеклист перед review.../conventions/testing.md— table-driven тесты и httptest для handler'ов.