Go標準ライブラリでREST APIを作る - JSONとCRUDの基本

Go標準ライブラリでREST APIを作る - JSONとCRUDの基本

2025/12/21に公開

前回の記事では、Go標準ライブラリnet/httpを使ってHTMLを返すWebサーバーを作りました。

今回はJSONを返すREST APIを作っていきます。フロントエンドやモバイルアプリと連携するなら、APIは必須スキルですよね。

僕も最初はJSON処理で「構造体とJSONの変換ってどうやるの?」と戸惑いましたが、標準ライブラリのencoding/jsonを使えば驚くほどシンプルに書けます。

本記事では、コードスニペットを管理するAPIを題材に、JSONの送受信からCRUD操作、テストの書き方まで一緒に学んでいきましょう。

Note

動作確認環境:

  • Go 1.22以上(パターンルーティング機能を使用)
  • 前回記事の知識があると理解しやすいです

今回作るもの

コードスニペットを管理するREST APIを作ります。

GET /snippets - スニペット一覧取得 GET /snippets/{id} - スニペット詳細取得 POST /snippets - スニペット作成 PUT /snippets/{id} - スニペット更新 DELETE /snippets/{id} - スニペット削除

最終的なディレクトリ構成はこうなります。

snippetapi/ ├── go.mod ├── main.go └── main_test.go

シンプルに1ファイルで実装して、最後にテストを追加します。

プロジェクトの準備

新しいプロジェクトを作成しましょう。

ターミナル
mkdir snippetapi cd snippetapi go mod init snippetapi

Step 1: JSONの基本

まずは、GoでJSONを扱う基本を押さえておきましょう。

構造体とJSONタグ

GoでJSONを扱うには、構造体にjsonタグをつけます。

main.go
package main import ( "encoding/json" "fmt" ) type Snippet struct { ID int `json:"id"` Title string `json:"title"` Code string `json:"code"` Language string `json:"language"` } func main() { // 構造体 → JSON(Marshal) snippet := Snippet{ ID: 1, Title: "Hello World", Code: "fmt.Println(\"Hello\")", Language: "go", } jsonData, _ := json.Marshal(snippet) fmt.Println(string(jsonData)) // {"id":1,"title":"Hello World","code":"fmt.Println(\"Hello\")","language":"go"} // JSON → 構造体(Unmarshal) jsonString := `{"id":2,"title":"Test","code":"print('test')","language":"python"}` var s Snippet json.Unmarshal([]byte(jsonString), &s) fmt.Printf("%+v\n", s) // {ID:2 Title:Test Code:print('test') Language:python} }

JSONタグのポイント

タグ説明
json:"name"JSONのキー名を指定json:"title"
json:"name,omitempty"空の場合は出力しないjson:"description,omitempty"
json:"-"JSONに含めないjson:"-"
Tip

Goの構造体フィールドはIDのように大文字始まりですが、JSONではidのように小文字が一般的です。タグで変換しましょう。

Step 2: 最初のAPIエンドポイント

それでは、実際にAPIを作っていきます。まずはヘルスチェック用のエンドポイントから。

main.go
package main import ( "encoding/json" "log" "net/http" "time" ) func main() { mux := http.NewServeMux() mux.HandleFunc("GET /health", healthHandler) log.Println("Server starting on http://localhost:8080") server := &http.Server{ Addr: ":8080", Handler: mux, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } log.Fatal(server.ListenAndServe()) } func healthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") response := map[string]string{ "status": "ok", } json.NewEncoder(w).Encode(response) }

実行方法:

ターミナル
go run main.go

動作確認:

ターミナル
curl http://localhost:8080/health # {"status":"ok"}

コードの解説

w.Header().Set("Content-Type", "application/json")

レスポンスがJSONであることを示すヘッダーを設定します。APIでは必須です。

json.NewEncoder(w).Encode(response)

json.NewEncoderは、データをJSONに変換しながら直接w(ResponseWriter)に書き込みます。json.Marshalしてから書き込むより効率的です。

Step 3: スニペットのCRUD実装

いよいよ本番です。スニペットのCRUD操作を実装していきましょう。

データ構造の定義

まず、スニペットの構造体とインメモリストアを定義します。

main.go
package main import ( "encoding/json" "log" "net/http" "strconv" "sync" "time" ) type Snippet struct { ID int `json:"id"` Title string `json:"title"` Code string `json:"code"` Language string `json:"language"` CreatedAt time.Time `json:"created_at"` } // インメモリストア var ( snippets = make(map[int]Snippet) nextID = 1 mu sync.RWMutex ) func main() { mux := http.NewServeMux() mux.HandleFunc("GET /health", healthHandler) mux.HandleFunc("GET /snippets", listSnippets) mux.HandleFunc("GET /snippets/{id}", getSnippet) mux.HandleFunc("POST /snippets", createSnippet) mux.HandleFunc("PUT /snippets/{id}", updateSnippet) mux.HandleFunc("DELETE /snippets/{id}", deleteSnippet) log.Println("Server starting on http://localhost:8080") server := &http.Server{ Addr: ":8080", Handler: mux, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } log.Fatal(server.ListenAndServe()) }
Note

sync.RWMutexは、複数のリクエストが同時にデータにアクセスしても安全に動作するようにするためのロックです。本番環境ではデータベースを使いますが、学習用にはインメモリストアで十分です。

Create(作成)

func createSnippet(w http.ResponseWriter, r *http.Request) { var input struct { Title string `json:"title"` Code string `json:"code"` Language string `json:"language"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest) return } // バリデーション if input.Title == "" || input.Code == "" { http.Error(w, `{"error":"title and code are required"}`, http.StatusBadRequest) return } mu.Lock() snippet := Snippet{ ID: nextID, Title: input.Title, Code: input.Code, Language: input.Language, CreatedAt: time.Now(), } snippets[nextID] = snippet nextID++ mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(snippet) }

ポイント:

  • json.NewDecoder(r.Body).Decode(&input) でリクエストボディをパース
  • 作成成功時は201 Createdを返す
  • 入力用の構造体は匿名構造体でOK(IDやCreatedAtは受け取らない)

Read(取得)

func listSnippets(w http.ResponseWriter, r *http.Request) { mu.RLock() result := make([]Snippet, 0, len(snippets)) for _, s := range snippets { result = append(result, s) } mu.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func getSnippet(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, `{"error":"Invalid ID"}`, http.StatusBadRequest) return } mu.RLock() snippet, exists := snippets[id] mu.RUnlock() if !exists { http.Error(w, `{"error":"Snippet not found"}`, http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(snippet) }

ポイント:

  • r.PathValue("id") でURLパラメータを取得(Go 1.22の機能)
  • 存在しない場合は404 Not Foundを返す
  • 読み取りはRLock(複数同時に読める)

Update(更新)

func updateSnippet(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, `{"error":"Invalid ID"}`, http.StatusBadRequest) return } var input struct { Title string `json:"title"` Code string `json:"code"` Language string `json:"language"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest) return } mu.Lock() snippet, exists := snippets[id] if !exists { mu.Unlock() http.Error(w, `{"error":"Snippet not found"}`, http.StatusNotFound) return } // 更新(空でなければ上書き) if input.Title != "" { snippet.Title = input.Title } if input.Code != "" { snippet.Code = input.Code } if input.Language != "" { snippet.Language = input.Language } snippets[id] = snippet mu.Unlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(snippet) }

Delete(削除)

func deleteSnippet(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, `{"error":"Invalid ID"}`, http.StatusBadRequest) return } mu.Lock() _, exists := snippets[id] if !exists { mu.Unlock() http.Error(w, `{"error":"Snippet not found"}`, http.StatusNotFound) return } delete(snippets, id) mu.Unlock() w.WriteHeader(http.StatusNoContent) }

ポイント:

  • 削除成功時は204 No Contentを返す(レスポンスボディなし)

healthHandler

最初に作ったヘルスチェックも忘れずに。

func healthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }

Step 4: 動作確認

サーバーを起動して、curlで動作確認してみましょう。

ターミナル
go run main.go

スニペット作成:

ターミナル
curl -X POST http://localhost:8080/snippets \ -H "Content-Type: application/json" \ -d '{"title":"Hello Go","code":"fmt.Println(\"Hello\")","language":"go"}'

一覧取得:

ターミナル
curl http://localhost:8080/snippets

詳細取得:

ターミナル
curl http://localhost:8080/snippets/1

更新:

ターミナル
curl -X PUT http://localhost:8080/snippets/1 \ -H "Content-Type: application/json" \ -d '{"title":"Updated Title"}'

削除:

ターミナル
curl -X DELETE http://localhost:8080/snippets/1

一連の操作を実行すると、このようになります。

動作確認: curlでAPIをテスト

Step 5: エラーレスポンスの統一

現在はエラーを文字列で返していますが、もう少し構造化しましょう。

// エラーレスポンス用の構造体 type ErrorResponse struct { Error string `json:"error"` Message string `json:"message,omitempty"` } // エラーレスポンスを返すヘルパー関数 func respondError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(ErrorResponse{Error: http.StatusText(status), Message: message}) } // JSONレスポンスを返すヘルパー関数 func respondJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) }

これを使うと、ハンドラーがスッキリします。

エラーレスポンス:

// Before http.Error(w, `{"error":"Invalid ID"}`, http.StatusBadRequest) // After respondError(w, http.StatusBadRequest, "Invalid ID")

成功レスポンス:

// Before w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(snippet) // After respondJSON(w, http.StatusCreated, snippet)

Step 6: テストを書く

APIができたら、テストを書きましょう。Goのnet/http/httptestパッケージを使います。

main_test.go
package main import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" ) // テスト前にデータをリセットするヘルパー関数 func resetTestData() { mu.Lock() snippets = make(map[int]Snippet) nextID = 1 mu.Unlock() } // テスト用のスニペットを作成するヘルパー関数 func createTestSnippet(title, code, language string) Snippet { mu.Lock() defer mu.Unlock() snippet := Snippet{ ID: nextID, Title: title, Code: code, Language: language, } snippets[nextID] = snippet nextID++ return snippet } func TestHealthHandler(t *testing.T) { req := httptest.NewRequest("GET", "/health", nil) rec := httptest.NewRecorder() healthHandler(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) } var response map[string]string json.NewDecoder(rec.Body).Decode(&response) if response["status"] != "ok" { t.Errorf("expected status 'ok', got '%s'", response["status"]) } } func TestListSnippets(t *testing.T) { resetTestData() createTestSnippet("Test1", "code1", "go") createTestSnippet("Test2", "code2", "python") req := httptest.NewRequest("GET", "/snippets", nil) rec := httptest.NewRecorder() listSnippets(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) } var result []Snippet json.NewDecoder(rec.Body).Decode(&result) if len(result) != 2 { t.Errorf("expected 2 snippets, got %d", len(result)) } } func TestGetSnippet(t *testing.T) { resetTestData() created := createTestSnippet("Test", "code", "go") req := httptest.NewRequest("GET", "/snippets/1", nil) req.SetPathValue("id", "1") rec := httptest.NewRecorder() getSnippet(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) } var snippet Snippet json.NewDecoder(rec.Body).Decode(&snippet) if snippet.Title != created.Title { t.Errorf("expected title '%s', got '%s'", created.Title, snippet.Title) } } func TestGetSnippetNotFound(t *testing.T) { resetTestData() req := httptest.NewRequest("GET", "/snippets/999", nil) req.SetPathValue("id", "999") rec := httptest.NewRecorder() getSnippet(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("expected status %d, got %d", http.StatusNotFound, rec.Code) } } func TestCreateSnippet(t *testing.T) { resetTestData() body := `{"title":"Test","code":"test code","language":"go"}` req := httptest.NewRequest("POST", "/snippets", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() createSnippet(rec, req) if rec.Code != http.StatusCreated { t.Errorf("expected status %d, got %d", http.StatusCreated, rec.Code) } var snippet Snippet json.NewDecoder(rec.Body).Decode(&snippet) if snippet.Title != "Test" { t.Errorf("expected title 'Test', got '%s'", snippet.Title) } } func TestCreateSnippetValidation(t *testing.T) { resetTestData() body := `{"code":"test code"}` req := httptest.NewRequest("POST", "/snippets", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() createSnippet(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("expected status %d, got %d", http.StatusBadRequest, rec.Code) } } func TestUpdateSnippet(t *testing.T) { resetTestData() createTestSnippet("Original", "code", "go") body := `{"title":"Updated"}` req := httptest.NewRequest("PUT", "/snippets/1", bytes.NewBufferString(body)) req.SetPathValue("id", "1") req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() updateSnippet(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) } var snippet Snippet json.NewDecoder(rec.Body).Decode(&snippet) if snippet.Title != "Updated" { t.Errorf("expected title 'Updated', got '%s'", snippet.Title) } } func TestUpdateSnippetNotFound(t *testing.T) { resetTestData() body := `{"title":"Updated"}` req := httptest.NewRequest("PUT", "/snippets/999", bytes.NewBufferString(body)) req.SetPathValue("id", "999") req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() updateSnippet(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("expected status %d, got %d", http.StatusNotFound, rec.Code) } } func TestDeleteSnippet(t *testing.T) { resetTestData() createTestSnippet("ToDelete", "code", "go") req := httptest.NewRequest("DELETE", "/snippets/1", nil) req.SetPathValue("id", "1") rec := httptest.NewRecorder() deleteSnippet(rec, req) if rec.Code != http.StatusNoContent { t.Errorf("expected status %d, got %d", http.StatusNoContent, rec.Code) } // 削除されたことを確認 mu.RLock() _, exists := snippets[1] mu.RUnlock() if exists { t.Error("snippet should be deleted") } } func TestDeleteSnippetNotFound(t *testing.T) { resetTestData() req := httptest.NewRequest("DELETE", "/snippets/999", nil) req.SetPathValue("id", "999") rec := httptest.NewRecorder() deleteSnippet(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("expected status %d, got %d", http.StatusNotFound, rec.Code) } }

テスト実行:

ターミナル
go test -v

実行結果:

=== RUN TestHealthHandler --- PASS: TestHealthHandler (0.00s) === RUN TestListSnippets --- PASS: TestListSnippets (0.00s) === RUN TestGetSnippet --- PASS: TestGetSnippet (0.00s) === RUN TestGetSnippetNotFound --- PASS: TestGetSnippetNotFound (0.00s) === RUN TestCreateSnippet --- PASS: TestCreateSnippet (0.00s) === RUN TestCreateSnippetValidation --- PASS: TestCreateSnippetValidation (0.00s) === RUN TestUpdateSnippet --- PASS: TestUpdateSnippet (0.00s) === RUN TestUpdateSnippetNotFound --- PASS: TestUpdateSnippetNotFound (0.00s) === RUN TestDeleteSnippet --- PASS: TestDeleteSnippet (0.00s) === RUN TestDeleteSnippetNotFound --- PASS: TestDeleteSnippetNotFound (0.00s) PASS ok snippetapi 0.372s

テストのポイント

関数説明
httptest.NewRequestテスト用のHTTPリクエストを作成
httptest.NewRecorderレスポンスを記録するRecorderを作成
req.SetPathValueパスパラメータを設定(Go 1.22)
Tip

今回は各APIごとに個別のテスト関数を書きましたが、Goにはテーブル駆動テストという手法があります。複数のテストケースを1つの関数でまとめて実行できるので、コードの重複を減らせます。テーブル駆動テストについては、別の記事で詳しく紹介する予定です。

完成コード

Step 1〜6を適用した最終的なコードです。

Tip

完成コードは以下のGitHubリポジトリからダウンロードできます。

https://techarm/blog-code-examples/go-api-standard-library

main.go
package main import ( "encoding/json" "log" "net/http" "strconv" "sync" "time" ) type Snippet struct { ID int `json:"id"` Title string `json:"title"` Code string `json:"code"` Language string `json:"language"` CreatedAt time.Time `json:"created_at"` } // エラーレスポンス用の構造体 type ErrorResponse struct { Error string `json:"error"` Message string `json:"message,omitempty"` } // インメモリストア var ( snippets = make(map[int]Snippet) nextID = 1 mu sync.RWMutex ) func main() { mux := http.NewServeMux() mux.HandleFunc("GET /health", healthHandler) mux.HandleFunc("GET /snippets", listSnippets) mux.HandleFunc("GET /snippets/{id}", getSnippet) mux.HandleFunc("POST /snippets", createSnippet) mux.HandleFunc("PUT /snippets/{id}", updateSnippet) mux.HandleFunc("DELETE /snippets/{id}", deleteSnippet) log.Println("Server starting on http://localhost:8080") server := &http.Server{ Addr: ":8080", Handler: mux, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } log.Fatal(server.ListenAndServe()) } // エラーレスポンスを返すヘルパー関数 func respondError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(ErrorResponse{Error: http.StatusText(status), Message: message}) } // JSONレスポンスを返すヘルパー関数 func respondJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func healthHandler(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func createSnippet(w http.ResponseWriter, r *http.Request) { var input struct { Title string `json:"title"` Code string `json:"code"` Language string `json:"language"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { respondError(w, http.StatusBadRequest, "Invalid JSON") return } // バリデーション if input.Title == "" || input.Code == "" { respondError(w, http.StatusBadRequest, "title and code are required") return } mu.Lock() snippet := Snippet{ ID: nextID, Title: input.Title, Code: input.Code, Language: input.Language, CreatedAt: time.Now(), } snippets[nextID] = snippet nextID++ mu.Unlock() respondJSON(w, http.StatusCreated, snippet) } func listSnippets(w http.ResponseWriter, r *http.Request) { mu.RLock() result := make([]Snippet, 0, len(snippets)) for _, s := range snippets { result = append(result, s) } mu.RUnlock() respondJSON(w, http.StatusOK, result) } func getSnippet(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.Atoi(idStr) if err != nil { respondError(w, http.StatusBadRequest, "Invalid ID") return } mu.RLock() snippet, exists := snippets[id] mu.RUnlock() if !exists { respondError(w, http.StatusNotFound, "Snippet not found") return } respondJSON(w, http.StatusOK, snippet) } func updateSnippet(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.Atoi(idStr) if err != nil { respondError(w, http.StatusBadRequest, "Invalid ID") return } var input struct { Title string `json:"title"` Code string `json:"code"` Language string `json:"language"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { respondError(w, http.StatusBadRequest, "Invalid JSON") return } mu.Lock() snippet, exists := snippets[id] if !exists { mu.Unlock() respondError(w, http.StatusNotFound, "Snippet not found") return } // 更新(空でなければ上書き) if input.Title != "" { snippet.Title = input.Title } if input.Code != "" { snippet.Code = input.Code } if input.Language != "" { snippet.Language = input.Language } snippets[id] = snippet mu.Unlock() respondJSON(w, http.StatusOK, snippet) } func deleteSnippet(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.Atoi(idStr) if err != nil { respondError(w, http.StatusBadRequest, "Invalid ID") return } mu.Lock() _, exists := snippets[id] if !exists { mu.Unlock() respondError(w, http.StatusNotFound, "Snippet not found") return } delete(snippets, id) mu.Unlock() w.WriteHeader(http.StatusNoContent) }

まとめ

この記事では、Go標準ライブラリを使ったREST API開発の基礎を学びました。

学んだこと:

  • encoding/jsonによるJSON処理(Marshal/Unmarshal)
  • 構造体タグによるJSONフィールド名の指定
  • RESTfulなエンドポイント設計
  • CRUDエンドポイントの実装
  • net/http/httptestを使ったAPIテスト

前回のHTMLテンプレートと合わせれば、Goの標準ライブラリでWebアプリの基礎はバッチリですね。個人的には、外部ライブラリなしでここまでシンプルに書けるGoの設計思想が好きです。

Tip

次のステップ:

  • データベース(PostgreSQL)との連携
  • 認証・認可の実装
  • Ginなどのフレームワークへの移行

参考リンク

この記事はいかがでしたか?

もしこの記事が参考になりましたら、
高評価をいただけると大変嬉しいです!

皆様からの応援が励みになります。ありがとうございます! ✨