REST API開発

REST API開発

現実世界のアナロジー

レストランを経営していると想像してください。お客様(クライアント)がフロントデスクに来店し、ウェイター(ルーター)がニーズに基づいて異なる窓口に案内します:

キッチン(サーバー)がリクエストを処理し、準備された料理(レスポンス)をウェイターを通じてお客様に届けます。同時に、警備員(ミドルウェア)がお客様が入る前に身元を確認し、来店時間を記録し、緊急事態に対応できます。

REST APIはこのような標準化されたサービスワークフローです。クライアントは統一された「動詞」(HTTPメソッド)と「アドレス」(URL)を使用してサーバーと通信し、サーバーは構造化されたデータ(JSON)を返します。

プロジェクト要件

以下の機能をサポートするTodo REST APIを開発します:

操作 メソッド パス 説明
すべてのTodoを取得 GET /api/todos Todoリストを返す
単一のTodoを取得 GET /api/todos/{id} IDで詳細を返す
Todoを作成 POST /api/todos 新しいTodoを追加
Todoを更新 PUT /api/todos/{id} 指定されたTodoを変更
Todoを削除 DELETE /api/todos/{id} 指定されたTodoを削除

追加要件:

システム設計

クライアントリクエスト
    │
    ▼
┌──────────────────────────────┐
│        ミドルウェアチェーン      │
│  Recovery → Auth → Logging   │
│         ↓         ↓         ↓ │
└──────────────────────────────┘
    │
    ▼
┌──────────────────────────────┐
│          ルーター              │
│  GET    /api/todos           │
│  GET    /api/todos/{id}      │
│  POST   /api/todos           │
│  PUT    /api/todos/{id}      │
│  DELETE /api/todos/{id}      │
└──────────────────────────────┘
    │
    ▼
┌──────────────────────────────┐
│    インメモリストレージレイヤー  │
│   map[string]Todo + sync.RWMutex │
└──────────────────────────────┘

例 1: 完全なコード

GO
package main

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

	"github.com/google/uuid"
)

// Todoアイテムの構造体
type Todo struct {
	ID        string `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
}

// TodoStoreはインメモリストレージで、読み書きロックによる並行安全性を確保
type TodoStore struct {
	mu    sync.RWMutex
	todos map[string]Todo
}

// NewTodoStoreは新しいストレージインスタンスを作成する
func NewTodoStore() *TodoStore {
	return &TodoStore{
		todos: make(map[string]Todo),
	}
}

// GetByIDはIDで単一のTodoアイテムを返す
func (s *TodoStore) GetByID(id string) (Todo, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	t, ok := s.todos[id]
	return t, ok
}

// GetAllはすべてのTodoアイテムを返す
func (s *TodoStore) GetAll() []Todo {
	s.mu.RLock()
	defer s.mu.RUnlock()

	list := make([]Todo, 0, len(s.todos))
	for _, t := range s.todos {
		list = append(list, t)
	}
	return list
}

// Createは新しいTodoアイテムを作成する
func (s *TodoStore) Create(title string) Todo {
	s.mu.Lock()
	defer s.mu.Unlock()

	now := time.Now().Format(time.RFC3339)
	t := Todo{
		ID:        uuid.New().String(),
		Title:     title,
		Completed: false,
		CreatedAt: now,
		UpdatedAt: now,
	}
	s.todos[t.ID] = t
	return t
}

// Updateは指定されたTodoアイテムを更新する
func (s *TodoStore) Update(id string, title string, completed bool) (Todo, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()

	t, ok := s.todos[id]
	if !ok {
		return Todo{}, false
	}

	t.Title = title
	t.Completed = completed
	t.UpdatedAt = time.Now().Format(time.RFC3339)
	s.todos[id] = t
	return t, true
}

// Deleteは指定されたTodoアイテムを削除する
func (s *TodoStore) Delete(id string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.todos[id]; !ok {
		return false
	}
	delete(s.todos, id)
	return true
}

// --- 共通レスポンス構造体 ---

// APIResponseは統一APIレスポンス形式
type APIResponse struct {
	Success bool        `json:"success"`
	Data    interface{} `json:"data,omitempty"`
	Error   string      `json:"error,omitempty"`
}

// jsonResponseはJSONレスポンスを送信する
func jsonResponse(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

// --- ミドルウェア ---

// Middleware型定義
type Middleware func(http.Handler) http.Handler

// RecoveryMiddlewareはパニック回復ミドルウェア
// ハンドラ関数内のパニックをキャッチし、プロセスをクラッシュさせる代わりに500エラーを返す
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)
				jsonResponse(w, http.StatusInternalServerError, APIResponse{
					Success: false,
					Error:   "内部サーバーエラー",
				})
			}
		}()
		next.ServeHTTP(w, r)
	})
}

// LoggingMiddlewareはリクエストログミドルウェア
// 各リクエストのメソッド、パス、所要時間を記録する
func LoggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("[LOG] %s %s %s", r.Method, r.URL.Path, time.Since(start))
	})
}

// AuthMiddlewareはシンプルな認証ミドルウェア
// リクエストヘッダーに有効なAPI Keyが含まれているかを確認する
func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		apiKey := r.Header.Get("X-API-Key")
		if apiKey != "secret-key-123" {
			jsonResponse(w, http.StatusUnauthorized, APIResponse{
				Success: false,
				Error:   "無効なAPIキー",
			})
			return
		}
		next.ServeHTTP(w, r)
	})
}

// Chainは複数のミドルウェアをチェーンに結合する
// パラメータの順序が実行順序:Chain(A, B, C) → A(B(C(handler)))
func Chain(middlewares ...Middleware) Middleware {
	return func(next http.Handler) http.Handler {
		for i := len(middlewares) - 1; i >= 0; i-- {
			next = middlewares[i](next)
		}
		return next
	}
}

// --- ルーターとハンドラ ---

// Routerはカスタムルーター
type Router struct {
	routes     map[string]map[string]http.HandlerFunc // method -> path -> handler
	middleware Middleware
}

// NewRouterはルーターを作成する
func NewRouter() *Router {
	return &Router{
		routes: make(map[string]map[string]http.HandlerFunc),
	}
}

// Useはグローバルミドルウェアを登録する
func (rt *Router) Use(m Middleware) {
	rt.middleware = m
}

// Handleはルートを登録する:メソッド + パス + ハンドラ
func (rt *Router) Handle(method, path string, handler http.HandlerFunc) {
	if _, ok := rt.routes[method]; !ok {
		rt.routes[method] = make(map[string]http.HandlerFunc)
	}
	rt.routes[method][path] = handler
}

// ServeHTTPはhttp.Handlerインターフェースを実装し、ルートマッチングを完了する
func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	methodRoutes, ok := rt.routes[r.Method]
	if !ok {
		jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{
			Success: false,
			Error:   "メソッドが許可されていません",
		})
		return
	}

	// 完全一致
	if handler, ok := methodRoutes[r.URL.Path]; ok {
		handler(w, r)
		return
	}

	// プレフィックス一致:パスパラメータの抽出を試みる(例:/api/todos/{id})
	for pattern, handler := range methodRoutes {
		if strings.HasSuffix(pattern, "/{id}") {
			prefix := strings.TrimSuffix(pattern, "/{id}")
			if strings.HasPrefix(r.URL.Path, prefix+"/") {
				// {id}をリクエストヘッダーに入れてハンドラに読み取らせる
				id := strings.TrimPrefix(r.URL.Path, prefix+"/")
				r.Header.Set("X-Resource-ID", id)
				handler(w, r)
				return
			}
		}
	}

	jsonResponse(w, http.StatusNotFound, APIResponse{
		Success: false,
		Error:   "リソースが見つかりません",
	})
}

// --- ハンドラ関数 ---

// handleGetTodosはすべてのTodoアイテムを返す
func handleGetTodos(store *TodoStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		todos := store.GetAll()
		jsonResponse(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    todos,
		})
	}
}

// handleGetTodoは単一のTodoアイテムを返す
func handleGetTodo(store *TodoStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Resource-ID")
		todo, ok := store.GetByID(id)
		if !ok {
			jsonResponse(w, http.StatusNotFound, APIResponse{
				Success: false,
				Error:   "Todoアイテムが見つかりません",
			})
			return
		}
		jsonResponse(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    todo,
		})
	}
}

// CreateTodoRequestはTodo作成のリクエストボディ
type CreateTodoRequest struct {
	Title string `json:"title"`
}

// handleCreateTodoはTodoアイテムを作成する
func handleCreateTodo(store *TodoStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req CreateTodoRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			jsonResponse(w, http.StatusBadRequest, APIResponse{
				Success: false,
				Error:   "無効なリクエストボディ",
			})
			return
		}

		if strings.TrimSpace(req.Title) == "" {
			jsonResponse(w, http.StatusBadRequest, APIResponse{
				Success: false,
				Error:   "タイトルは空にできません",
			})
			return
		}

		todo := store.Create(req.Title)
		jsonResponse(w, http.StatusCreated, APIResponse{
			Success: true,
			Data:    todo,
		})
	}
}

// UpdateTodoRequestはTodo更新のリクエストボディ
type UpdateTodoRequest struct {
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

// handleUpdateTodoはTodoアイテムを更新する
func handleUpdateTodo(store *TodoStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Resource-ID")

		var req UpdateTodoRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			jsonResponse(w, http.StatusBadRequest, APIResponse{
				Success: false,
				Error:   "無効なリクエストボディ",
			})
			return
		}

		if strings.TrimSpace(req.Title) == "" {
			jsonResponse(w, http.StatusBadRequest, APIResponse{
				Success: false,
				Error:   "タイトルは空にできません",
			})
			return
		}

		todo, ok := store.Update(id, req.Title, req.Completed)
		if !ok {
			jsonResponse(w, http.StatusNotFound, APIResponse{
				Success: false,
				Error:   "Todoアイテムが見つかりません",
			})
			return
		}

		jsonResponse(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    todo,
		})
	}
}

// handleDeleteTodoはTodoアイテムを削除する
func handleDeleteTodo(store *TodoStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Resource-ID")
		if !store.Delete(id) {
			jsonResponse(w, http.StatusNotFound, APIResponse{
				Success: false,
				Error:   "Todoアイテムが見つかりません",
			})
			return
		}
		jsonResponse(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    "正常に削除しました",
		})
	}
}

func main() {
	// ストレージを初期化
	store := NewTodoStore()

	// ルーターを作成
	router := NewRouter()

	// ミドルウェアチェーンを登録:Recovery → Auth → Logging
	router.Use(Chain(RecoveryMiddleware, AuthMiddleware, LoggingMiddleware))

	// ルートを登録
	router.Handle("GET", "/api/todos", handleGetTodos(store))
	router.Handle("GET", "/api/todos/{id}", handleGetTodo(store))
	router.Handle("POST", "/api/todos", handleCreateTodo(store))
	router.Handle("PUT", "/api/todos/{id}", handleUpdateTodo(store))
	router.Handle("DELETE", "/api/todos/{id}", handleDeleteTodo(store))

	// ミドルウェアを適用してサーバーを起動
	addr := ":8080"
	log.Printf("REST APIサーバーが http://localhost%s で起動しました", addr)
	log.Fatal(http.ListenAndServe(addr, router.middleware(router)))
}
▶ 試してみよう

実行とテスト

サービスを起動する前に、モジュールを初期化し依存関係をインストールします:

BASH
go mod init todo-api
go get github.com/google/uuid
go run main.go

curlを使用して各エンドポイントをテストします:

BASH
# Todoアイテムを作成
curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -H "X-API-Key: secret-key-123" \
  -d '{"title": "Goを学ぶ"}'

# すべてのTodoを取得
curl http://localhost:8080/api/todos \
  -H "X-API-Key: secret-key-123"

# 単一のTodoを取得({id}を実際の返されたIDに置き換えてください)
curl http://localhost:8080/api/todos/{id} \
  -H "X-API-Key: secret-key-123"

# Todoを更新
curl -X PUT http://localhost:8080/api/todos/{id} \
  -H "Content-Type: application/json" \
  -H "X-API-Key: secret-key-123" \
  -d '{"title": "Goを学ぶ(完了)", "completed": true}'

# Todoを削除
curl -X DELETE http://localhost:8080/api/todos/{id} \
  -H "X-API-Key: secret-key-123"

# 認証なしリクエストをテスト(401が返されるはず)
curl http://localhost:8080/api/todos

期待されるレスポンスの例:

TEXT
# 正常に作成
{"success":true,"data":{"id":"a1b2c3d4-...","title":"Goを学ぶ","completed":false,"created_at":"2026-06-27T10:00:00+08:00","updated_at":"2026-06-27T10:00:00+08:00"}}

# 認証なし
{"success":false,"error":"無効なAPIキー"}

コード解析

1. データモデルとストレージレイヤー

Todo構造体はTodoアイテムのデータ構造を定義し、JSONタグを使用してシリアライズされたフィールド名を制御します。TodoStoresync.RWMutexを使用してmapを保護します。読み取り操作は読み取りロック(RLock)を使用し、書き込み操作は書き込みロック(Lock)を使用して並行安全性を確保します。

2. ミドルウェアパターン

各ミドルウェアはfunc(http.Handler) http.Handler型で、デコレータパターンを通じて次のハンドラをラップします:

TEXT
RecoveryMiddleware(
  AuthMiddleware(
    LoggingMiddleware(
      final handler,
    ),
  ),
)

Chain関数は複数のミドルウェアをパラメータの順序でチェーンに結合します。

3. カスタムルーター

Go 1.22以前では標準ライブラリのhttp.ServeMuxがパスパラメータをサポートしていなかったため、シンプルなルーターを実装しました:

4. ハンドラクロージャ

handleGetTodos(store)などの関数はstore参照をキャプチャするクロージャを返します。これにより、ハンドラはグローバル変数なしで共有ストレージレイヤーにアクセスできます。

5. 統一レスポンス形式

すべてのエンドポイントは同じJSON構造{success, data, error}を返すため、クライアントは一度の解析でリクエストが成功したかどうかを判断できます。

❓ よくある質問

質問1:なぜsync.Mutexではなくsync.RWMutexを使用するのですか?

Mutexは1つのgoroutineしか同時にアクセスできませんが、RWMutexは複数の読み取り操作の同時実行を許可します。書き込み操作のみが排他的にロックします。「読み取りが多い、書き込みが少ない」シナリオ(APIのクエリが変更を大幅に上回る場合)では、RWMutexは並行性能を大幅に向上させます。

質問2:このルーターを本番環境で使用すべきですか?

推奨されません。このルーターは教育目的の簡略化された実装で、正規表現マッチング、ワイルドカード、メソッド自動検出などの高度な機能が欠けています。本番環境ではchigorilla/mux、またはGo 1.22+の組み込み強化ルーティングなどの成熟したルーティングライブラリを使用してください。

質問3:インメモリストレージのデータは再起動後に失われます。どう解決しますか?

このレッスンではREST APIの部分に集中するためにインメモリストレージを使用しています。実際のプロジェクトでは、データベース(SQLite、PostgreSQL、MongoDBなど)にデータを永続化する必要があります。次のレッスンではGoでデータベースを操作する方法を学びます。

質問4:ミドルウェアの実行順序は重要ですか?

はい。Recoveryは最も外側にして、すべての内部パニックをキャッチできるようにします。AuthはLoggingの前に配置し、認証されていないリクエストがビジネスログを生成しないようにします(ただし、逆にして認証されていないリクエストを含むすべてのリクエストをログに記録することも可能です)。ビジネスニーズに基づいて柔軟に調整してください。

📖 まとめ

このレッスンでは、以前学んだ複数の知識ポイントを総合的に活用して、ゼロから完全なREST APIを構築しました:

これらのパターンはGo Web開発の基盤です。これらをマスターすれば、データベース統合、JWT認証、APIレート制限などの複雑なシナリオに容易に拡張できます。

📝 演習

演習1:ページネーションサポートを追加

GET /api/todosエンドポイントを変更して、クエリパラメータ?page=1&size=10によるページネーション結果をサポートしてください。ヒント:r.URL.Query()を使用してパラメータを読み取り、GetAll結果をスライスします。

演習2:CORSミドルウェアを追加

以下のヘッダーをレスポンスに追加するCORSMiddlewareを作成してください:

OPTIONSプリフライトリクエストは204を直接返すように処理してください。ミドルウェアチェーンに追加してください。

演習3:検索機能を実装

新しいルートGET /api/todos/search?q=keywordを追加して、インメモリストレージ内で(strings.Containsを使用して)タイトルのあいまいマッチングを行い、一致するTodoアイテムを返してください。


次のレッスン:データベース操作

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%