Skip to Content
How-toДобавить HTTP endpoint

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

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

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

Содержание

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.

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 и ../conventions/events.

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.

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 и запрашивай review.

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

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

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

Last updated on