API Conventions¶
HTTP API (apps/api) 의 라우팅, 요청/응답 포맷, 에러 핸들링, OpenAPI 문서화 규칙. 프론트엔드와의 type-safe 계약을 유지하는 방법을 포함한다.
Why conventions matter¶
Umbra 의 API 는 두 소비자를 가진다:
apps/web(React SPA) —api.gen.ts로 타입 동기화- 외부 통합 (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}/cancelvs/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 은 대용량에서 느림):
Response:
MVP 는 간단히 before (timestamp) 사용. 더 정교한 cursor 는 Phase 2.
Filtering¶
쿼리 파라미터로:
Dates¶
- Request/response 모두 ISO 8601 / RFC 3339 :
"2026-04-13T12:34:56Z" - 타임존은 UTC 고정 (UI 에서 로케일 변환)
Response¶
Success¶
2xx 응답 + JSON body:
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¶
- DTO 는
apps/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,memberbilling,licensingsnapshots,restore,antinukeaudit,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.mdfrontend-conventions.mdhexagonal-pattern.md../adr/0003-http-framework-echo.md../adr/0030-openapi-type-sync.md