콘텐츠로 이동

API Conventions

HTTP API (apps/api) 의 라우팅, 요청/응답 포맷, 에러 핸들링, OpenAPI 문서화 규칙. 프론트엔드와의 type-safe 계약을 유지하는 방법을 포함한다.

Why conventions matter

Umbra 의 API 는 두 소비자를 가진다:

  1. apps/web (React SPA) — api.gen.ts 로 타입 동기화
  2. 외부 통합 (Phase 2+) — 3rd party 개발자용 open API

둘 다 "HTTP 계약 안정성" 과 "예측 가능한 에러 모델" 이 필수다. 또 swag 기반 OpenAPI 생성을 위해 handler 작성 패턴을 표준화해야 한다.

Stack

  • Framework — Echo v4 (ADR-0003)
  • OpenAPI — swag (Go annotation → swagger.json)
  • Type sync — openapi-typescript (swagger.json → api.gen.ts)
  • Validation — 구조체 태그 + 커스텀 validator

URL design

Versioning

모든 경로는 /api/v1/ prefix:

/api/v1/subscriptions/{id}
/api/v1/guilds/{id}/snapshots
/api/v1/oauth/discord/callback
/api/v1/webhooks/toss

v2 는 breaking change 시 도입. 내부 사용 endpoint 는 /internal/ prefix.

Resource naming

  • 복수형 리소스: /subscriptions, /snapshots, /guilds
  • ID 는 URL path: /subscriptions/{id}
  • 동사는 subresource 로: /subscriptions/{id}/cancel vs /cancel-subscription
  • Nested: /guilds/{guild_id}/snapshots — 소유 관계 명확 시

Path patterns

Pattern Use
GET /resources 목록 (pagination 필요 시 cursor)
GET /resources/{id} 단일 조회
POST /resources 생성
PATCH /resources/{id} 부분 수정
DELETE /resources/{id} 삭제
POST /resources/{id}/action 동사 액션 (cancel, retry 등)

PUT 은 거의 쓰지 않음 (전체 교체가 드뭄).

Request

Content-Type

  • application/json — 기본. body 있는 요청 (POST, PATCH).
  • 쿼리 파라미터 — 필터, 페이지네이션

Request body

snake_case JSON keys (DB, event payload 와 일관):

POST /api/v1/subscriptions
{
  "guild_id": "018f...",
  "plan_code": "PRO",
  "billing_key_id": "018f..."
}

Pagination

Cursor-based (offset 은 대용량에서 느림):

GET /api/v1/audit/events?guild_id=018f...&before=2026-04-13T00:00:00Z&limit=50

Response:

{
  "items": [...],
  "next_cursor": "2026-04-10T00:00:00Z"
}

MVP 는 간단히 before (timestamp) 사용. 더 정교한 cursor 는 Phase 2.

Filtering

쿼리 파라미터로:

GET /api/v1/snapshots?guild_id=018f...&trigger=manual&limit=20

Dates

  • Request/response 모두 ISO 8601 / RFC 3339 : "2026-04-13T12:34:56Z"
  • 타임존은 UTC 고정 (UI 에서 로케일 변환)

Response

Success

2xx 응답 + JSON body:

// 201 Created
{
  "id": "018f...",
  "status": "active",
  "created_at": "2026-04-13T12:34:56Z"
}
  • 2xx:
  • 200 OK — 조회, 업데이트, 일반 성공
  • 201 Created — 생성 성공
  • 202 Accepted — 비동기 시작 (예: restore)
  • 204 No Content — 삭제, body 없음

Error

모든 에러 응답은 동일 구조:

{
  "error": {
    "code": "PLAN_LIMIT_EXCEEDED",
    "message": "수동 스냅샷 한도를 초과했습니다",
    "details": {
      "current_count": 1,
      "limit": 1
    },
    "request_id": "req_018f..."
  }
}

Error fields

Field Required Description
code Yes SCREAMING_SNAKE_CASE 식별자
message Yes 사람이 읽을 한국어
details No 구조화된 부가 정보
request_id Yes Trace ID (middleware 자동)

Status codes

Code Use
400 Bad Request 요청 형식 오류, validation 실패
401 Unauthorized 세션 없음 또는 만료
403 Forbidden 권한 부족
404 Not Found 리소스 없음
409 Conflict 상태 충돌 (동시 실행, 한도 초과, race)
422 Unprocessable Entity 타당한 요청이지만 비즈니스 규칙 위반
429 Too Many Requests Rate limit
500 Internal Server Error 서버 오류
502 Bad Gateway 업스트림 의존성 (Toss, Discord) 오류
503 Service Unavailable 점검 중

Error code 예시

Code When
UNAUTHENTICATED 세션 없음
PERMISSION_DENIED 권한 없음
NOT_FOUND 리소스 없음
VALIDATION_FAILED body 필드 오류
CONFLICT 일반 충돌
PLAN_LIMIT_EXCEEDED Plan 한도 초과
RESTORE_ALREADY_IN_PROGRESS 중복 실행
SNAPSHOT_TOO_LARGE size overflow
TOSS_CHARGE_FAILED 결제 업스트림 실패
RATE_LIMITED 너무 많은 요청

Error code 는 도메인별로 engine/{domain}/domain/errors.go 에 sentinel 로, handler 에서 HTTP mapper 가 code 로 변환.

Domain error → HTTP mapping

// apps/api/internal/middleware/error_mapper.go
func errorToHTTP(err error) (int, string, string) {
    switch {
    case errors.Is(err, domain.ErrNotFound):
        return 404, "NOT_FOUND", "리소스를 찾을 수 없습니다"
    case errors.Is(err, domain.ErrPermissionDenied):
        return 403, "PERMISSION_DENIED", "권한이 없습니다"
    case errors.Is(err, billing.ErrPlanLimitExceeded):
        return 409, "PLAN_LIMIT_EXCEEDED", "플랜 한도를 초과했습니다"
    case errors.Is(err, restore.ErrAlreadyInProgress):
        return 409, "RESTORE_ALREADY_IN_PROGRESS", "이미 진행 중인 복구가 있습니다"
    ...
    default:
        return 500, "INTERNAL", "서버 오류"
    }
}

Handler 는 그냥 domain error 를 반환, middleware 가 변환.

Handler pattern

Structure

// apps/api/internal/handler/subscription.go
type SubscriptionHandler struct {
    billingSvc  billing.Service
    logger      *slog.Logger
}

// StartSubscription godoc
// @Summary Start a new subscription
// @Description Start a Pro subscription for a guild
// @Tags subscriptions
// @Accept json
// @Produce json
// @Param request body StartSubscriptionRequest true "Subscription details"
// @Success 201 {object} SubscriptionResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Router /subscriptions [post]
// @Security CookieAuth
func (h *SubscriptionHandler) StartSubscription(c echo.Context) error {
    ctx := c.Request().Context()

    // 1. Bind & validate
    var req StartSubscriptionRequest
    if err := c.Bind(&req); err != nil {
        return NewValidationError(err)
    }
    if err := req.Validate(); err != nil {
        return NewValidationError(err)
    }

    // 2. Auth
    userID := getAuthenticatedUserID(ctx)
    if userID == uuid.Nil { return domain.ErrUnauthenticated }

    // 3. Domain call
    sub, err := h.billingSvc.StartSubscription(ctx, billing.StartInput{
        PayerUserID:   userID,
        GuildID:       req.GuildID,
        PlanCode:      req.PlanCode,
        AuthKey:       req.AuthKey,
        CustomerKey:   req.CustomerKey,
    })
    if err != nil { return err }  // middleware 가 HTTP 변환

    // 4. Response
    return c.JSON(201, toSubscriptionResponse(sub))
}

DTO vs domain

  • DTOapps/api/internal/dto/ 또는 handler 파일 안
  • Domain entity 를 API response 에 직접 노출 금지
  • 변환 함수 (toSubscriptionResponse) 명시적
type SubscriptionResponse struct {
    ID                string    `json:"id"`
    Status            string    `json:"status"`
    PlanCode          string    `json:"plan_code"`
    CurrentPeriodEnd  time.Time `json:"current_period_end"`
    NextBillingAt     time.Time `json:"next_billing_at"`
    CancelAtPeriodEnd bool      `json:"cancel_at_period_end"`
}

func toSubscriptionResponse(sub *domain.Subscription, planCode string) SubscriptionResponse {
    return SubscriptionResponse{
        ID:               sub.ID.String(),
        Status:           string(sub.Status),
        PlanCode:         planCode,
        CurrentPeriodEnd: *sub.CurrentPeriodEnd,
        NextBillingAt:    *sub.NextBillingAt,
        CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
    }
}

Validation

type StartSubscriptionRequest struct {
    GuildID     uuid.UUID `json:"guild_id" validate:"required"`
    PlanCode    string    `json:"plan_code" validate:"required,oneof=PRO ENTERPRISE"`
    AuthKey     string    `json:"auth_key" validate:"required"`
    CustomerKey string    `json:"customer_key" validate:"required"`
}

func (r *StartSubscriptionRequest) Validate() error {
    return validate.Struct(r)
}

go-playground/validator 사용. 커스텀 rule 은 프로젝트 시작 시 register.

Authentication

Session-based (dashboard)

  • Discord OAuth2 → 세션 쿠키 (Set-Cookie: umbra_session=...)
  • 쿠키 flags: HttpOnly, Secure, SameSite=Lax
  • Middleware 가 쿠키 → session → user 검증 → ctx 에 user 주입
func SessionMiddleware(sessions SessionStore) echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            sessID, err := c.Cookie("umbra_session")
            if err != nil { return next(c) }

            sess, err := sessions.Get(c.Request().Context(), sessID.Value)
            if err != nil || sess == nil { return next(c) }

            // Inject user into context
            ctx := ctxpkg.WithUser(c.Request().Context(), sess.User)
            c.SetRequest(c.Request().WithContext(ctx))
            return next(c)
        }
    }
}

Internal endpoints

  • /internal/* — Fly.io private network 또는 IP 화이트리스트
  • API key (header X-Internal-Key) 옵션

Webhook endpoints

  • /webhooks/toss — HMAC 서명 검증
  • 세션 인증 불필요

Authorization

Middleware vs inline

  • 단순 "로그인 필수" → middleware (RequireAuth)
  • 리소스별 권한 (길드 MANAGE_GUILD 등) → handler 안에서 체크
// Inline example
func (h *Handler) CreateSnapshot(c echo.Context) error {
    ctx := c.Request().Context()
    userID := getAuthenticatedUserID(ctx)
    guildID := parseUUID(c.Param("guild_id"))

    // Discord permission 체크 (caching 포함)
    if !h.permission.HasManageGuild(ctx, userID, guildID) {
        return domain.ErrPermissionDenied
    }

    // ... domain call
}

OpenAPI / swag

Annotation

모든 핸들러는 swag annotation 필수:

// CreateSnapshot godoc
// @Summary Create a snapshot
// @Description Create a manual snapshot for a guild
// @Tags snapshots
// @Accept json
// @Produce json
// @Param guild_id path string true "Guild ID"
// @Param request body CreateSnapshotRequest false "Snapshot details"
// @Success 201 {object} SnapshotResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Router /guilds/{guild_id}/snapshots [post]
// @Security CookieAuth

Generation

make openapi
# 1. swag init -g apps/api/cmd/main.go -o docs/api
# 2. bunx openapi-typescript docs/api/openapi.json -o apps/web/src/types/api.gen.ts
  • PR 에 두 파일 모두 commit (swag 생성 + TS 생성)
  • CI 에서 재생성 후 diff 없는지 검증

Tags

도메인 별로 tag 정리:

  • identity, guild, member
  • billing, licensing
  • snapshots, restore, antinuke
  • audit, notification

API 문서 UI (Swagger) 에서 그룹핑.

Middleware order

Echo 서버 setup:

e.Use(middleware.RequestID())
e.Use(middleware.Recover())
e.Use(middleware.CORS())
e.Use(middleware.Gzip())
e.Use(LoggerMiddleware(logger))
e.Use(TelemetryMiddleware())       // OpenTelemetry
e.Use(SessionMiddleware(sessions)) // Cookie → User
e.Use(RateLimitMiddleware(redis))  // Optional per route

// Routes
registerRoutes(e, handlers)

// Error handler (맨 마지막)
e.HTTPErrorHandler = ErrorHandler

Error handler

func ErrorHandler(err error, c echo.Context) {
    status, code, msg := errorToHTTP(err)
    requestID := c.Response().Header().Get(echo.HeaderXRequestID)

    c.JSON(status, ErrorResponse{
        Error: ErrorDetail{
            Code:      code,
            Message:   msg,
            RequestID: requestID,
        },
    })

    // Log server errors
    if status >= 500 {
        logger.Error("handler error",
            "error", err,
            "request_id", requestID,
            "path", c.Request().URL.Path,
        )
    }
}

Rate limiting

Phase 2 에서 본격 도입. MVP 는 다음만:

  • Webhook 에 naive rate limit (per IP)
  • Discord API 호출 side 는 Discord SDK 의 built-in

Redis 기반 sliding window:

func RateLimitMiddleware(redis *redis.Client) echo.MiddlewareFunc {
    limiter := redislimiter.NewSlidingWindow(...)
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            key := "rl:" + c.RealIP() + ":" + c.Path()
            allowed, err := limiter.Allow(c.Request().Context(), key)
            if err != nil { return next(c) }  // fail open
            if !allowed { return echo.NewHTTPError(429, "rate limited") }
            return next(c)
        }
    }
}

Observability

Request logging

Middleware 가 자동 기록:

  • method, path, status, duration
  • request_id
  • user_id (인증된 경우)

PII 는 로깅 금지 (쿠키 값, body 내용 등).

Tracing

OpenTelemetry 자동 (otelecho). 각 요청에 trace_id 부여, 서비스 간 propagation.

Metrics

  • Counter: http_requests_total{method,path,status}
  • Histogram: http_request_duration_seconds{method,path}
  • Gauge: http_inflight_requests

Grafana Cloud 로 export.

Testing

Handler unit test

func TestSubscriptionHandler_Start(t *testing.T) {
    mockSvc := &mockBillingService{startResult: &billing.StartResult{...}}
    h := &SubscriptionHandler{billingSvc: mockSvc, logger: testLogger}

    e := echo.New()
    body := `{"guild_id":"018f...","plan_code":"PRO","auth_key":"x","customer_key":"y"}`
    req := httptest.NewRequest(http.MethodPost, "/subscriptions", strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()
    ctx := e.NewContext(req, rec)

    err := h.StartSubscription(ctx)
    assert.NoError(t, err)
    assert.Equal(t, 201, rec.Code)

    var resp SubscriptionResponse
    json.Unmarshal(rec.Body.Bytes(), &resp)
    assert.Equal(t, "active", resp.Status)
}

Integration

  • 실제 Echo 서버 + 실제 도메인 서비스 (mock 은 Toss, Discord 외부 API 만)
  • Postgres, Redis 는 Docker

Do / Don't

Do

  • /api/v1/ prefix
  • ✅ snake_case JSON
  • ✅ 통일된 error response 구조
  • ✅ Domain error 반환, middleware 가 HTTP 변환
  • ✅ DTO 로 domain entity 변환
  • ✅ Validation in handler boundary
  • ✅ swag annotation 필수
  • ✅ cursor-based pagination

Don't

  • ❌ camelCase JSON
  • ❌ Domain entity 직접 노출
  • ❌ Handler 에서 HTTP 코드 분기 (middleware 에서)
  • ❌ offset pagination (초기값 외)
  • ❌ Inline SQL in handlers
  • ❌ API key in URL query (header 만)
  • ❌ 버전 없는 endpoint

See also

  • backend-conventions.md
  • frontend-conventions.md
  • hexagonal-pattern.md
  • ../adr/0003-http-framework-echo.md
  • ../adr/0030-openapi-type-sync.md