Day 7: 実践プロジェクト - 解答例

プロダクショングレード TODO API

Day 7では、これまで学んだ全ての概念を統合したプロダクショングレードのREST APIを構築します。

---

アプローチ1: 標準ライブラリベース(推奨)

完全実装

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "strconv"
    "strings"
    "sync"
    "time"
)

// ===================================
// Domain Models
// ===================================

// Todo represents a task item with metadata
type Todo struct {
    ID          int       `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description,omitempty"`
    Completed   bool      `json:"completed"`
    Priority    int       `json:"priority"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

// CreateTodoRequest represents the request body for creating a todo
type CreateTodoRequest struct {
    Title       string `json:"title"`
    Description string `json:"description,omitempty"`
    Priority    int    `json:"priority"`
}

// UpdateTodoRequest represents the request body for updating a todo
type UpdateTodoRequest struct {
    Title       *string `json:"title,omitempty"`
    Description *string `json:"description,omitempty"`
    Completed   *bool   `json:"completed,omitempty"`
    Priority    *int    `json:"priority,omitempty"`
}

// Validate checks if the create request is valid
func (r *CreateTodoRequest) Validate() error {
    if strings.TrimSpace(r.Title) == "" {
        return errors.New("title is required")
    }
    if len(r.Title) > 200 {
        return errors.New("title must be less than 200 characters")
    }
    if r.Priority < 0 || r.Priority > 5 {
        return errors.New("priority must be between 0 and 5")
    }
    return nil
}

// ===================================
// Data Store Layer
// ===================================

// TodoStore manages todo items with thread-safety
type TodoStore struct {
    mu     sync.RWMutex
    todos  map[int]*Todo
    nextID int
}

// NewTodoStore creates a new todo store
func NewTodoStore() *TodoStore {
    return &TodoStore{
        todos: make(map[int]*Todo),
    }
}

// Create adds a new todo to the store
func (s *TodoStore) Create(req CreateTodoRequest) (*Todo, error) {
    if err := req.Validate(); err != nil {
        return nil, err
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    s.nextID++
    now := time.Now()
    todo := &Todo{
        ID:          s.nextID,
        Title:       strings.TrimSpace(req.Title),
        Description: strings.TrimSpace(req.Description),
        Completed:   false,
        Priority:    req.Priority,
        CreatedAt:   now,
        UpdatedAt:   now,
    }

    s.todos[todo.ID] = todo
    return todo, nil
}

// Get retrieves a todo by ID
func (s *TodoStore) Get(id int) (*Todo, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    todo, exists := s.todos[id]
    if !exists {
        return nil, errors.New("todo not found")
    }
    return todo, nil
}

// List returns all todos, optionally filtered
func (s *TodoStore) List(completed *bool) []*Todo {
    s.mu.RLock()
    defer s.mu.RUnlock()

    result := make([]*Todo, 0, len(s.todos))
    for _, todo := range s.todos {
        if completed == nil || todo.Completed == *completed {
            result = append(result, todo)
        }
    }

    // Sort by priority (descending) then by ID
    for i := 0; i < len(result)-1; i++ {
        for j := i + 1; j < len(result); j++ {
            if result[i].Priority < result[j].Priority ||
                (result[i].Priority == result[j].Priority && result[i].ID > result[j].ID) {
                result[i], result[j] = result[j], result[i]
            }
        }
    }

    return result
}

// Update modifies an existing todo
func (s *TodoStore) Update(id int, req UpdateTodoRequest) (*Todo, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    todo, exists := s.todos[id]
    if !exists {
        return nil, errors.New("todo not found")
    }

    // Apply updates
    if req.Title != nil {
        title := strings.TrimSpace(*req.Title)
        if title == "" {
            return nil, errors.New("title cannot be empty")
        }
        todo.Title = title
    }
    if req.Description != nil {
        todo.Description = strings.TrimSpace(*req.Description)
    }
    if req.Completed != nil {
        todo.Completed = *req.Completed
    }
    if req.Priority != nil {
        if *req.Priority < 0 || *req.Priority > 5 {
            return nil, errors.New("priority must be between 0 and 5")
        }
        todo.Priority = *req.Priority
    }

    todo.UpdatedAt = time.Now()
    return todo, nil
}

// Delete removes a todo from the store
func (s *TodoStore) Delete(id int) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, exists := s.todos[id]; !exists {
        return errors.New("todo not found")
    }

    delete(s.todos, id)
    return nil
}

// Stats returns statistics about todos
func (s *TodoStore) Stats() map[string]interface{} {
    s.mu.RLock()
    defer s.mu.RUnlock()

    total := len(s.todos)
    completed := 0
    for _, todo := range s.todos {
        if todo.Completed {
            completed++
        }
    }

    return map[string]interface{}{
        "total":     total,
        "completed": completed,
        "active":    total - completed,
    }
}

// ===================================
// HTTP Handler Layer
// ===================================

// TodoHandler handles HTTP requests for todos
type TodoHandler struct {
    store *TodoStore
}

// NewTodoHandler creates a new todo handler
func NewTodoHandler(store *TodoStore) *TodoHandler {
    return &TodoHandler{store: store}
}

// ServeHTTP implements http.Handler
func (h *TodoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // CORS headers
    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")

    if r.Method == http.MethodOptions {
        w.WriteHeader(http.StatusOK)
        return
    }

    // Route based on path and method
    path := strings.TrimPrefix(r.URL.Path, "/api/todos")

    switch {
    case path == "" || path == "/":
        h.handleTodos(w, r)
    case strings.HasPrefix(path, "/"):
        h.handleTodoByID(w, r, strings.TrimPrefix(path, "/"))
    default:
        http.NotFound(w, r)
    }
}

// handleTodos handles /api/todos
func (h *TodoHandler) handleTodos(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        h.listTodos(w, r)
    case http.MethodPost:
        h.createTodo(w, r)
    default:
        respondError(w, http.StatusMethodNotAllowed, "method not allowed")
    }
}

// handleTodoByID handles /api/todos/{id}
func (h *TodoHandler) handleTodoByID(w http.ResponseWriter, r *http.Request, idStr string) {
    id, err := strconv.Atoi(idStr)
    if err != nil {
        respondError(w, http.StatusBadRequest, "invalid todo ID")
        return
    }

    switch r.Method {
    case http.MethodGet:
        h.getTodo(w, r, id)
    case http.MethodPut:
        h.updateTodo(w, r, id)
    case http.MethodDelete:
        h.deleteTodo(w, r, id)
    default:
        respondError(w, http.StatusMethodNotAllowed, "method not allowed")
    }
}

// listTodos handles GET /api/todos
func (h *TodoHandler) listTodos(w http.ResponseWriter, r *http.Request) {
    var completed *bool
    if completedStr := r.URL.Query().Get("completed"); completedStr != "" {
        val := completedStr == "true"
        completed = &val
    }

    todos := h.store.List(completed)
    respondJSON(w, http.StatusOK, todos)
}

// createTodo handles POST /api/todos
func (h *TodoHandler) createTodo(w http.ResponseWriter, r *http.Request) {
    var req CreateTodoRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "invalid request body")
        return
    }

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

    respondJSON(w, http.StatusCreated, todo)
}

// getTodo handles GET /api/todos/{id}
func (h *TodoHandler) getTodo(w http.ResponseWriter, r *http.Request, id int) {
    todo, err := h.store.Get(id)
    if err != nil {
        respondError(w, http.StatusNotFound, err.Error())
        return
    }

    respondJSON(w, http.StatusOK, todo)
}

// updateTodo handles PUT /api/todos/{id}
func (h *TodoHandler) updateTodo(w http.ResponseWriter, r *http.Request, id int) {
    var req UpdateTodoRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "invalid request body")
        return
    }

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

    respondJSON(w, http.StatusOK, todo)
}

// deleteTodo handles DELETE /api/todos/{id}
func (h *TodoHandler) deleteTodo(w http.ResponseWriter, r *http.Request, id int) {
    if err := h.store.Delete(id); err != nil {
        respondError(w, http.StatusNotFound, err.Error())
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

// ===================================
// Stats Handler
// ===================================

// StatsHandler handles statistics requests
type StatsHandler struct {
    store *TodoStore
}

// NewStatsHandler creates a new stats handler
func NewStatsHandler(store *TodoStore) *StatsHandler {
    return &StatsHandler{store: store}
}

// ServeHTTP implements http.Handler
func (h *StatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        respondError(w, http.StatusMethodNotAllowed, "method not allowed")
        return
    }

    stats := h.store.Stats()
    respondJSON(w, http.StatusOK, stats)
}

// ===================================
// Middleware
// ===================================

// loggingMiddleware logs HTTP requests
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap the ResponseWriter to capture status code
        wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

        next.ServeHTTP(wrapped, r)

        log.Printf(
            "%s %s %d %s",
            r.Method,
            r.URL.Path,
            wrapped.statusCode,
            time.Since(start),
        )
    })
}

// responseWriter wraps http.ResponseWriter to capture status code
type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

// recoveryMiddleware recovers from panics
func recoveryMiddleware(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)
    })
}

// ===================================
// Helper Functions
// ===================================

// respondJSON sends a JSON response
func respondJSON(w http.ResponseWriter, code int, payload interface{}) {
    response, err := json.Marshal(payload)
    if err != nil {
        respondError(w, http.StatusInternalServerError, "failed to encode response")
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    w.Write(response)
}

// respondError sends an error response
func respondError(w http.ResponseWriter, code int, message string) {
    respondJSON(w, code, map[string]string{"error": message})
}

// ===================================
// Main Application
// ===================================

func main() {
    // Initialize store
    store := NewTodoStore()

    // Setup routes
    mux := http.NewServeMux()
    mux.Handle("/api/todos", NewTodoHandler(store))
    mux.Handle("/api/todos/", NewTodoHandler(store))
    mux.Handle("/api/stats", NewStatsHandler(store))

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

    // Apply middleware
    handler := recoveryMiddleware(loggingMiddleware(mux))

    // Configure server
    server := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Graceful shutdown
    go func() {
        log.Printf("Server starting on %s", server.Addr)
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server failed: %v", err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    <-quit

    log.Println("Shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }

    log.Println("Server exited")
}

---

アプローチ2: テスト駆動開発(TDD)

テストファイル例

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestTodoStore_Create(t *testing.T) {
    store := NewTodoStore()

    tests := []struct {
        name    string
        req     CreateTodoRequest
        wantErr bool
    }{
        {
            name: "valid todo",
            req: CreateTodoRequest{
                Title:    "Test Todo",
                Priority: 1,
            },
            wantErr: false,
        },
        {
            name: "empty title",
            req: CreateTodoRequest{
                Title:    "",
                Priority: 1,
            },
            wantErr: true,
        },
        {
            name: "invalid priority",
            req: CreateTodoRequest{
                Title:    "Test",
                Priority: 10,
            },
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            todo, err := store.Create(tt.req)
            if (err != nil) != tt.wantErr {
                t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !tt.wantErr && todo.Title != tt.req.Title {
                t.Errorf("Title = %v, want %v", todo.Title, tt.req.Title)
            }
        })
    }
}

func TestTodoHandler_CreateTodo(t *testing.T) {
    store := NewTodoStore()
    handler := NewTodoHandler(store)

    reqBody := CreateTodoRequest{
        Title:       "Test Todo",
        Description: "Test Description",
        Priority:    2,
    }

    body, _ := json.Marshal(reqBody)
    req := httptest.NewRequest(http.MethodPost, "/api/todos", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")

    rr := httptest.NewRecorder()
    handler.ServeHTTP(rr, req)

    if status := rr.Code; status != http.StatusCreated {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusCreated)
    }

    var todo Todo
    if err := json.NewDecoder(rr.Body).Decode(&todo); err != nil {
        t.Errorf("failed to decode response: %v", err)
    }

    if todo.Title != reqBody.Title {
        t.Errorf("Title = %v, want %v", todo.Title, reqBody.Title)
    }
}

func TestTodoHandler_ListTodos(t *testing.T) {
    store := NewTodoStore()
    handler := NewTodoHandler(store)

    // Create some todos
    store.Create(CreateTodoRequest{Title: "Todo 1", Priority: 1})
    store.Create(CreateTodoRequest{Title: "Todo 2", Priority: 2})

    req := httptest.NewRequest(http.MethodGet, "/api/todos", nil)
    rr := httptest.NewRecorder()

    handler.ServeHTTP(rr, req)

    if status := rr.Code; status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
    }

    var todos []*Todo
    if err := json.NewDecoder(rr.Body).Decode(&todos); err != nil {
        t.Errorf("failed to decode response: %v", err)
    }

    if len(todos) != 2 {
        t.Errorf("expected 2 todos, got %d", len(todos))
    }
}

func TestConcurrentAccess(t *testing.T) {
    store := NewTodoStore()

    const goroutines = 100
    done := make(chan bool)

    for i := 0; i < goroutines; i++ {
        go func(id int) {
            store.Create(CreateTodoRequest{
                Title:    fmt.Sprintf("Todo %d", id),
                Priority: 1,
            })
            done <- true
        }(i)
    }

    for i := 0; i < goroutines; i++ {
        <-done
    }

    todos := store.List(nil)
    if len(todos) != goroutines {
        t.Errorf("expected %d todos, got %d", goroutines, len(todos))
    }
}

---

アプローチ3: ベンチマーク

package main

import (
    "fmt"
    "testing"
)

func BenchmarkTodoStore_Create(b *testing.B) {
    store := NewTodoStore()
    req := CreateTodoRequest{
        Title:    "Benchmark Todo",
        Priority: 1,
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        store.Create(req)
    }
}

func BenchmarkTodoStore_List(b *testing.B) {
    store := NewTodoStore()

    // Prepare data
    for i := 0; i < 1000; i++ {
        store.Create(CreateTodoRequest{
            Title:    fmt.Sprintf("Todo %d", i),
            Priority: i % 5,
        })
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        store.List(nil)
    }
}

func BenchmarkConcurrentCreate(b *testing.B) {
    store := NewTodoStore()
    req := CreateTodoRequest{
        Title:    "Concurrent Todo",
        Priority: 1,
    }

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            store.Create(req)
        }
    })
}

---

実行方法

サーバー起動

go run main.go

APIテスト

# Create todo
curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Go","priority":1}'

# List todos
curl http://localhost:8080/api/todos

# Get todo by ID
curl http://localhost:8080/api/todos/1

# Update todo
curl -X PUT http://localhost:8080/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'

# Delete todo
curl -X DELETE http://localhost:8080/api/todos/1

# Get stats
curl http://localhost:8080/api/stats

# Health check
curl http://localhost:8080/health

テスト実行

# All tests
go test -v

# With coverage
go test -cover -coverprofile=coverage.out
go tool cover -html=coverage.out

# Benchmarks
go test -bench=. -benchmem

---

プロダクション改善案

  • データ永続化: SQLite, PostgreSQL
  • 認証: JWT, OAuth2
  • レート制限: golang.org/x/time/rate
  • メトリクス: Prometheus
  • ロギング: structured logging (zerolog, zap)
  • 構成管理: 環境変数、設定ファイル
  • API文書: OpenAPI/Swagger
  • コンテナ化: Docker, Kubernetes