Go: Go REST…

REST APIは単なるCRUDにとどまりません。リソースの設計、ステータスコードの選定、一貫性のあるエラー表示形式、そしてミドルウェアの連鎖――こうした細部の一つひとつが、APIの本番環境レベルの品質を左右します。

APIをフロントエンドアプリケーションとサードパーティサービスの両方から呼び出す必要がある場合、標準化されたエラー形式、適切なステータスコード、そして明確なバージョン管理は、もはや単なる「あれば望ましい」ものではなく、「必須」のものとなります。

1. 学習内容



2. フロントエンド担当者の実体験

(1) 課題:APIのエラー形式がインターフェースごとに異なり、フロントエンドのクラッシュを引き起こしている

Alice社のバックエンドチームとフロントエンドチームは、あるEコマースプロジェクトで協力しています:

「フロントエンドの同僚から、『APIごとにエラーの表示形式が違うよ。ユーザー一覧APIは{"error":"not found"}を返すし、注文APIは{"message":"Order not found","code":404}を返すし、商品APIはただ500エラーページを表示するだけだ。APIごとに異なるエラー処理コードを書かなきゃいけないじゃないか!』と言われた。」

GO
// Bad code: inconsistent error formats
// GET /users/1 → {"error":"not found"}            ← Format A
// GET /orders/1 → {"message":"Order not found","code":404}  ← Format B
// GET /products → <html>500 Internal Error</html>            ← Format C

(2) Goの解決策:標準化されたエラー応答

GO
// Unified error format
type APIError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
    Detail  string `json:"detail,omitempty"`
}

func (e *APIError) Error() string {
    return e.Message
}

// Factory functions
func NotFound(msg string) *APIError {
    return &APIError{Code: 404, Message: "not_found", Detail: msg}
}

func BadRequest(msg string) *APIError {
    return &APIError{Code: 400, Message: "bad_request", Detail: msg}
}

func InternalError(msg string) *APIError {
    return &APIError{Code: 500, Message: "internal_error", Detail: msg}
}

(3) 歳入:統一前と統一後

次元 不統一 統一形式
フロントエンド処理 APIごとに異なるロジックを実装 if (resp.error) handleError(resp)
ドキュメント作成コスト APIごとに個別のドキュメント 一文での説明形式
SDKの生成 自動化不可 OpenAPI経由でクライアントを直接生成
デバッグにかかるコスト 毎回具体的な形式を確認する 標準化されたフィールド名


3. RESTful設計の原則

(1) リソースの設計

GO
// Good RESTful URL design:
// Resource (noun) + Verb (HTTP method)

// Single resource
GET    /users          → List (collection)
POST   /users          → Create
GET    /users/{id}     → View single
PUT    /users/{id}     → Full update
PATCH  /users/{id}     → Partial update
DELETE /users/{id}     → Delete

// Sub-resource
GET    /users/{id}/orders      → User's order list
POST   /users/{id}/orders      → Create order for user
GET    /users/{id}/orders/{oid} → User's specific order

// Actions (use verbs for non-CRUD)
POST   /users/{id}/activate    → Activate user
POST   /orders/{id}/cancel     → Cancel order

(2) ステータスコードの選択

GO
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
)

// Unified response
type Response struct {
    Data  interface{} `json:"data,omitempty"`
    Error *APIError   `json:"error,omitempty"`
    Meta  *Meta       `json:"meta,omitempty"`
}

type APIError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
    Detail  string `json:"detail,omitempty"`
}

type Meta struct {
    Total   int `json:"total"`
    Page    int `json:"page"`
    PerPage int `json:"per_page"`
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func main() {
    fmt.Println("Response types: Response, APIError, Meta")
    fmt.Println("Helper: writeJSON(w, status, data)")
}
ステータスコード メソッド 意味
200 OK GET クエリが成功しました
201 作成済み POST 正常に作成されました
204 コンテンツなし 削除 削除に成功しました
400 Bad Request 無効なリクエストパラメータ
401 アクセス拒否 アクセス拒否
403 アクセス禁止 アクセス権限なし
404 Not Found リソースが存在しません
409 競合 POST/PUT リソースの競合(例:重複作成)
422 処理不可 POST/PUT リクエスト本文のセマンティックエラー
429 数を超過 レート制限
500 内部エラー サーバーエラー


4. REST APIの実践

▶ サンプル:ユーザー CRUD API

⚙️ 前提条件: go get github.com/mattn/go-sqlite3 を実行する(SQLite を使用する場合)

GO 📖 参照専用
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strconv"
    "sync"
    "time"
)

// ---------- Model ----------

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
}

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

type UpdateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

// ---------- Store ----------

type UserStore struct {
    mu     sync.RWMutex
    users  map[int]User
    nextID int
}

func NewUserStore() *UserStore {
    return &UserStore{
        users:  make(map[int]User),
        nextID: 1,
    }
}

func (s *UserStore) List() []User {
    s.mu.RLock()
    defer s.mu.RUnlock()
    result := make([]User, 0, len(s.users))
    for _, u := range s.users {
        result = append(result, u)
    }
    return result
}

func (s *UserStore) Create(req CreateUserRequest) User {
    s.mu.Lock()
    defer s.mu.Unlock()
    u := User{
        ID:        s.nextID,
        Name:      req.Name,
        Email:     req.Email,
        CreatedAt: time.Now(),
    }
    s.nextID++
    s.users[u.ID] = u
    return u
}

func (s *UserStore) Get(id int) (User, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    u, ok := s.users[id]
    return u, ok
}

func (s *UserStore) Update(id int, req UpdateUserRequest) (User, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    u, ok := s.users[id]
    if !ok {
        return User{}, false
    }
    u.Name = req.Name
    u.Email = req.Email
    s.users[id] = u
    return u, true
}

func (s *UserStore) Delete(id int) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    _, ok := s.users[id]
    if !ok {
        return false
    }
    delete(s.users, id)
    return true
}

// ---------- Handler ----------

type UserHandler struct {
    store *UserStore
}

func NewUserHandler(store *UserStore) *UserHandler {
    return &UserHandler{store: store}
}

func (h *UserHandler) Register(mux *http.ServeMux) {
    mux.HandleFunc("GET /api/v1/users", h.ListUsers)
    mux.HandleFunc("POST /api/v1/users", h.CreateUser)
    mux.HandleFunc("GET /api/v1/users/{id}", h.GetUser)
    mux.HandleFunc("PUT /api/v1/users/{id}", h.UpdateUser)
    mux.HandleFunc("DELETE /api/v1/users/{id}", h.DeleteUser)
}

// Unified response
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, message string) {
    writeJSON(w, status, map[string]interface{}{
        "error": map[string]interface{}{
            "code":    status,
            "message": message,
        },
    })
}

func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
    users := h.store.List()
    writeJSON(w, http.StatusOK, map[string]interface{}{
        "data": users,
        "meta": map[string]int{"total": len(users)},
    })
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON body")
        return
    }

    // Parameter validation
    if req.Name == "" {
        writeError(w, http.StatusBadRequest, "name is required")
        return
    }
    if req.Email == "" {
        writeError(w, http.StatusBadRequest, "email is required")
        return
    }

    user := h.store.Create(req)
    writeJSON(w, http.StatusCreated, map[string]interface{}{
        "data": user,
    })
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid user ID")
        return
    }

    user, ok := h.store.Get(id)
    if !ok {
        writeError(w, http.StatusNotFound, "user not found")
        return
    }

    writeJSON(w, http.StatusOK, map[string]interface{}{
        "data": user,
    })
}

func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid user ID")
        return
    }

    var req UpdateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON body")
        return
    }

    user, ok := h.store.Update(id, req)
    if !ok {
        writeError(w, http.StatusNotFound, "user not found")
        return
    }

    writeJSON(w, http.StatusOK, map[string]interface{}{
        "data": user,
    })
}

func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid user ID")
        return
    }

    if !h.store.Delete(id) {
        writeError(w, http.StatusNotFound, "user not found")
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

func main() {
    store := NewUserStore()
    handler := NewUserHandler(store)

    mux := http.NewServeMux()
    handler.Register(mux)

    log.Println("User API started on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
論理コード 192 行(40 行制限超過、参照専用)

5. パラメータの検証

▶ サンプル:構造化検証

GO 📖 参照専用
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "regexp"
    "strings"
)

// Validator
type Validator struct {
    errors []string
}

func (v *Validator) Required(field, value string) {
    if strings.TrimSpace(value) == "" {
        v.errors = append(v.errors, fmt.Sprintf("%s is required", field))
    }
}

func (v *Validator) MinLength(field, value string, min int) {
    if len(value) < min {
        v.errors = append(v.errors, fmt.Sprintf("%s must be at least %d characters", field, min))
    }
}

func (v *Validator) MaxLength(field, value string, max int) {
    if len(value) > max {
        v.errors = append(v.errors, fmt.Sprintf("%s must be at most %d characters", field, max))
    }
}

func (v *Validator) Email(field, value string) {
    pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
    matched, _ := regexp.MatchString(pattern, value)
    if !matched {
        v.errors = append(v.errors, fmt.Sprintf("%s is not a valid email", field))
    }
}

func (v *Validator) Valid() bool {
    return len(v.errors) == 0
}

func (v *Validator) Errors() []string {
    return v.errors
}

// ---------- Usage ----------

type SignupRequest struct {
    Name     string `json:"name"`
    Email    string `json:"email"`
    Password string `json:"password"`
    Age      int    `json:"age"`
}

func validateSignup(req SignupRequest) *Validator {
    v := &Validator{}
    v.Required("name", req.Name)
    v.MinLength("name", req.Name, 2)
    v.MaxLength("name", req.Name, 50)
    v.Required("email", req.Email)
    v.Email("email", req.Email)
    v.Required("password", req.Password)
    v.MinLength("password", req.Password, 8)
    return v
}

func signupHandler(w http.ResponseWriter, r *http.Request) {
    var req SignupRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    if v := validateSignup(req); !v.Valid() {
        writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{
            "error": map[string]interface{}{
                "code":    422,
                "message": "validation failed",
                "details": v.Errors(),
            },
        })
        return
    }

    writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
}

func main() {
    v := validateSignup(SignupRequest{Name: "A", Email: "bad", Password: "123"})
    if !v.Valid() {
        fmt.Println("Validation errors:", v.Errors())
    }
}
論理コード 80 行(40 行制限超過、参照専用)

6. ミドルウェア・チェーンの統合

▶ サンプル:API ミドルウェアチェーン

GO 📖 参照専用
package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "strings"
    "time"
)

type contextKey string

const UserContextKey contextKey = "user"

// ---------- Middleware ----------

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

// Request logging
func RequestLogging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        log.Printf("[%s] %s %s", r.Method, r.URL.Path, r.RemoteAddr)
        next.ServeHTTP(w, r)
        log.Printf("[%s] %s → %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// Auth (simple Token)
func Auth(token string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            auth := r.Header.Get("Authorization")
            if !strings.HasPrefix(auth, "Bearer ") {
                writeError(w, http.StatusUnauthorized, "missing or invalid token")
                return
            }
            if auth[7:] != token {
                writeError(w, http.StatusForbidden, "invalid token")
                return
            }
            // Inject user info into Context
            ctx := context.WithValue(r.Context(), UserContextKey, "admin")
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// Request timeout
func RequestTimeout(timeout time.Duration) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), timeout)
            defer cancel()
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// CORS
func CORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// Recovery
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("[PANIC] %v", err)
                writeError(w, http.StatusInternalServerError, "internal server error")
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// Helper functions
func writeError(w http.ResponseWriter, status int, msg string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "error": map[string]interface{}{
            "code":    status,
            "message": msg,
        },
    })
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

// ---------- Main program ----------

func helloHandler(w http.ResponseWriter, r *http.Request) {
    user := r.Context().Value(UserContextKey)
    writeJSON(w, http.StatusOK, map[string]interface{}{
        "message": "Hello, " + user.(string),
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/hello", helloHandler)

    // Middleware chain
    handler := Chain(mux,
        Recovery,
        RequestLogging,
        CORS,
        Auth("secret-token"),
        RequestTimeout(5*time.Second),
    )

    log.Println("API started on :8080")
    log.Fatal(http.ListenAndServe(":8080", handler))
}
論理コード 109 行(40 行制限超過、参照専用)

7. 完全な例:図書館管理システム API

GO
// book_api.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strconv"
    "strings"
    "sync"
    "time"
)

// ---------- Models ----------

type Book struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Author    string    `json:"author"`
    ISBN      string    `json:"isbn"`
    Year      int       `json:"year"`
    Available bool      `json:"available"`
    CreatedAt time.Time `json:"created_at"`
}

type CreateBookRequest struct {
    Title  string `json:"title"`
    Author string `json:"author"`
    ISBN   string `json:"isbn"`
    Year   int    `json:"year"`
}

type UpdateBookRequest struct {
    Title     string `json:"title"`
    Author    string `json:"author"`
    Available *bool  `json:"available"`
}

// ---------- Validator ----------

type ValidationError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

func validateCreateBook(req CreateBookRequest) []ValidationError {
    var errs []ValidationError
    if strings.TrimSpace(req.Title) == "" {
        errs = append(errs, ValidationError{"title", "title is required"})
    }
    if strings.TrimSpace(req.Author) == "" {
        errs = append(errs, ValidationError{"author", "author is required"})
    }
    if strings.TrimSpace(req.ISBN) == "" {
        errs = append(errs, ValidationError{"isbn", "ISBN is required"})
    }
    if req.Year < 1000 || req.Year > 2100 {
        errs = append(errs, ValidationError{"year", "year must be between 1000 and 2100"})
    }
    return errs
}

// ---------- Store ----------

type BookStore struct {
    mu     sync.RWMutex
    books  map[int]Book
    nextID int
}

func NewBookStore() *BookStore {
    return &BookStore{
        books:  make(map[int]Book),
        nextID: 1,
    }
}

func (s *BookStore) List() []Book {
    s.mu.RLock()
    defer s.mu.RUnlock()
    result := make([]Book, 0, len(s.books))
    for _, b := range s.books {
        result = append(result, b)
    }
    return result
}

func (s *BookStore) Create(req CreateBookRequest) (Book, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    // Check ISBN uniqueness
    for _, b := range s.books {
        if b.ISBN == req.ISBN {
            return Book{}, fmt.Errorf("ISBN already exists: %s", req.ISBN)
        }
    }

    book := Book{
        ID:        s.nextID,
        Title:     req.Title,
        Author:    req.Author,
        ISBN:      req.ISBN,
        Year:      req.Year,
        Available: true,
        CreatedAt: time.Now(),
    }
    s.nextID++
    s.books[book.ID] = book
    return book, nil
}

func (s *BookStore) Get(id int) (Book, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    b, ok := s.books[id]
    return b, ok
}

func (s *BookStore) Update(id int, req UpdateBookRequest) (Book, bool, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    b, ok := s.books[id]
    if !ok {
        return Book{}, false, nil
    }
    if req.Title != "" {
        b.Title = req.Title
    }
    if req.Author != "" {
        b.Author = req.Author
    }
    if req.Available != nil {
        b.Available = *req.Available
    }
    s.books[id] = b
    return b, true, nil
}

func (s *BookStore) Delete(id int) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    _, ok := s.books[id]
    if !ok {
        return false
    }
    delete(s.books, id)
    return true
}

// ---------- API Response ----------

type APIResponse struct {
    Data  interface{} `json:"data,omitempty"`
    Error *APIError   `json:"error,omitempty"`
    Meta  *Meta       `json:"meta,omitempty"`
}

type APIError struct {
    Code    int               `json:"code"`
    Message string            `json:"message"`
    Details []ValidationError `json:"details,omitempty"`
}

type Meta struct {
    Total int `json:"total"`
}

func respond(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(APIResponse{Data: data})
}

func respondError(w http.ResponseWriter, status int, msg string, details ...[]ValidationError) {
    err := APIError{Code: status, Message: msg}
    if len(details) > 0 {
        err.Details = details[0]
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(APIResponse{Error: &err})
}

// ---------- Handlers ----------

type BookHandler struct {
    store *BookStore
}

func NewBookHandler(store *BookStore) *BookHandler {
    return &BookHandler{store: store}
}

func (h *BookHandler) Register(mux *http.ServeMux, basePath string) {
    mux.HandleFunc("GET "+basePath, h.ListBooks)
    mux.HandleFunc("POST "+basePath, h.CreateBook)
    mux.HandleFunc("GET "+basePath+"/{id}", h.GetBook)
    mux.HandleFunc("PUT "+basePath+"/{id}", h.UpdateBook)
    mux.HandleFunc("DELETE "+basePath+"/{id}", h.DeleteBook)
}

func (h *BookHandler) ListBooks(w http.ResponseWriter, r *http.Request) {
    books := h.store.List()
    respond(w, http.StatusOK, map[string]interface{}{
        "items": books,
        "meta":  Meta{Total: len(books)},
    })
}

func (h *BookHandler) CreateBook(w http.ResponseWriter, r *http.Request) {
    var req CreateBookRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "invalid JSON body")
        return
    }

    if errs := validateCreateBook(req); len(errs) > 0 {
        respondError(w, http.StatusUnprocessableEntity, "validation failed", errs)
        return
    }

    book, err := h.store.Create(req)
    if err != nil {
        respondError(w, http.StatusConflict, err.Error())
        return
    }

    respond(w, http.StatusCreated, book)
}

func (h *BookHandler) GetBook(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        respondError(w, http.StatusBadRequest, "invalid book ID")
        return
    }

    book, ok := h.store.Get(id)
    if !ok {
        respondError(w, http.StatusNotFound, "book not found")
        return
    }

    respond(w, http.StatusOK, book)
}

func (h *BookHandler) UpdateBook(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        respondError(w, http.StatusBadRequest, "invalid book ID")
        return
    }

    var req UpdateBookRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "invalid JSON body")
        return
    }

    book, ok, err := h.store.Update(id, req)
    if err != nil {
        respondError(w, http.StatusInternalServerError, err.Error())
        return
    }
    if !ok {
        respondError(w, http.StatusNotFound, "book not found")
        return
    }

    respond(w, http.StatusOK, book)
}

func (h *BookHandler) DeleteBook(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        respondError(w, http.StatusBadRequest, "invalid book ID")
        return
    }

    if !h.store.Delete(id) {
        respondError(w, http.StatusNotFound, "book not found")
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

// ---------- Middleware ----------

func RequestLogging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        log.Printf("[%s] %s %s", r.Method, r.URL.Path, r.RemoteAddr)
        next.ServeHTTP(w, r)
        log.Printf("[%s] %s → %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("[PANIC] %v", err)
                respondError(w, http.StatusInternalServerError, "internal server error")
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// ---------- Main ----------

func main() {
    store := NewBookStore()
    handler := NewBookHandler(store)

    mux := http.NewServeMux()
    handler.Register(mux, "/api/v1/books")

    // Health check
    mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
        respond(w, http.StatusOK, map[string]string{"status": "ok"})
    })

    // Middleware chain
    app := Recovery(RequestLogging(mux))

    log.Println("Book API started on :8080")
    log.Println("Endpoints:")
    log.Println("  GET    /api/v1/books       — Book list")
    log.Println("  POST   /api/v1/books       — Create book")
    log.Println("  GET    /api/v1/books/{id}  — View book")
    log.Println("  PUT    /api/v1/books/{id}  — Update book")
    log.Println("  DELETE /api/v1/books/{id}  — Delete book")
    log.Fatal(http.ListenAndServe(":8080", app))
}
100%
graph LR
    subgraph API [REST API Layer]
        H[Handler]
        M[Middleware Chain]
        V[Validator]
    end
    subgraph Store [Store Layer]
        R[Repository<br/>map + RWMutex]
    end
    subgraph Model [Model Layer]
        Book
        User
        Order
    end
    Request --> M
    M --> H
    H --> V
    V --> R
    R --> Book
🔥 よくある間違い: レスポンスタイプとして interface{} を使用する際は注意が必要です。マップのゼロ値は、JSON では {} ではなく null としてシリアライズされます。ベストプラクティスとして、明示的なレスポンス構造体(APIResponse{Data, Error, Meta}など)を定義し、値がある場合はフィールドが表示され、空の場合は省略される(omitempty)ようにすることをお勧めします。


❓ よくある質問

Q RESTful APIのバージョン管理はどのように行いますか?
A 4つの方法があります:(1) URLパス /api/v1/(最も一般的);(2) リクエストヘッダー Accept: application/vnd.api+json; version=1;(3) クエリパラメータ ?v=1; (4) サブドメイン v1.api.example.com。URLパスが推奨されます。これは最も直感的であり、開発およびデバッグのコストも最も低くなります。
Q エラーの表示形式をどのように標準化すればよいでしょうか?
A コード、メッセージ、詳細のフィールドを含む APIError 構造体を定義します。すべてのハンドラは、標準化された respondError(w, status, msg) 関数を使用する必要があります。フロントエンドは、具体的な API を気にすることなく、if resp.error を確認するだけでエラーを表示できます。
Q パラメータの検証は、ハンドラ層で行うべきか、それともサービス層で行うべきか?
A 基本的な検証(必須フィールド、形式)はハンドラ層で行い、ビジネス検証(一意性、権限)はサービス層で行います。ハンドラ層で検証に失敗した場合はステータスコード400または422が返され、サービス層で失敗した場合はステータスコード409または403が返されます。
Q PUTとPATCHの違いは何ですか?
A PUTは完全置換です。クライアントはリソース全体を送信し、欠落しているフィールドはリセットされたものとみなされます。PATCHは部分更新です。クライアントは変更対象のフィールドのみを送信します。実装の観点では、PUTの方が単純ですが、PATCHでは部分的なフィールドの統合処理が必要となります。CRUD操作にはPUTを、複雑な更新にはPATCHを使用することをお勧めします。
Q 404「ルートが見つかりません」エラーにはどのように対処すればよいですか?
A デフォルトでは、ServeMuxは404ページを返します。カスタマイズ方法:キャッチオールハンドラを作成します:mux.HandleFunc("/", func(w, r) { writeError(w, 404, "not found") })。ServeMuxはベストマッチルーティングを採用しているため、このハンドラは最後に登録する必要がある点に注意してください。
Q ページネーションはどのように実装されていますか?
A クエリパラメータ ?page=1&per_page=20 を使用しています。ハンドラーがパラメータを解析し、ストア層がLIMITとOFFSETを使用してクエリを実行します。レスポンスにはmeta: {total, page, per_page}が返され、フロントエンドがページネーションコンポーネントを計算します。Go 1.22のルーティングにおける{path...}構文はページネーションパラメータには適していません。パラメータはクエリ文字列に記述する必要があります。
Q HATEOAS(Hypermedia-As-a-Service)を実装する必要はありますか?
A いいえ。REST APIの実務において、HATEOASが実際に使用されることはほとんどありません。ほとんどのAPIでは、リソースデータとメタデータを返すだけで十分です。フロントエンドはAPIドキュメントから次に何をすべきかを把握しており、APIから「何ができるか」を指示される必要はありません。

📖 まとめ


📝 練習問題

  1. 基本演習(難易度 ⭐):Author REST API を構築してください。要件:(1) 完全な CRUD 機能;(2) /api/v1/authors パスを使用すること;(3) 標準化されたエラー形式;(4) 基本的なパラメータの検証(name は必須)。curl を使用してすべてのエンドポイントをテストしてください。

  2. 上級演習(難易度 ⭐⭐):記事と著者を関連付ける記事APIを実装してください。要件:(1) POST /articles を使用して記事を作成する(既存の著者と関連付ける); (2) GET /articles?author_id=X を使用して著者を条件にフィルタリングすること;(3) ページネーションをサポートすること(page および per_page パラメータを使用);(4) 一貫したレスポンス形式を使用すること:{data, meta};(5) title および content が空でないことを検証すること。

  3. 課題(難易度:⭐⭐⭐)完全なミドルウェアチェーンを備えたユーザー管理システムを実装してください。要件:(1) ルート:users(CRUD)+ auth(ログイン/登録);(2) ミドルウェア:Recovery → Logging → CORS → RateLimit(トークンバケット)→ Auth(Bearer Token)→ Timeout;(3) 登録時に bcrypt を使用してパスワードを暗号化すること;ログイン時に JWT を返すこと; (4) 認証ミドルウェアは、JWTからuserIDを解析し、それをContextに注入する必要があります;(5) -race を使用して、並行処理の安全性を検証してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%