🪪

Ginで最小CORS対応:フロントとAPIをつなぐ実践ガイド

に公開

1. 最小API(ベース)

まずは CORS を入れる前のベース API。
(※ すでに動いている方は読み飛ばしてOK)

下記のブログ記事にて取り上げたコードです。

該当コード
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"strconv"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

type Memo struct {
	ID      uint   `gorm:"primaryKey" json:"id"`
	Content string `gorm:"not null" json:"content"`
}

var db *gorm.DB

func main() {
	var err error
	// SQLite データベースに接続。ファイルが存在しない場合は自動生成される
	db, err = gorm.Open(sqlite.Open("memo.db"), &gorm.Config{})
	if err != nil {
		log.Fatal("failed to connect database:", err)
	}

	// 自動マイグレーションにより、 Memo モデルに基づいたテーブルを作成
	db.AutoMigrate(&Memo{})

	// エンドポイントの定義
	// /memo では GET と POST を処理
	http.HandleFunc("/memo", memoHandler)
	// /memo/ 以降は DELETE 用など、URLパスにIDを含むリクエストを処理
	http.HandleFunc("/memo/", memoHandlerWithID)

	fmt.Println("Server is running on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

// memoHandler は、 GETリクエストで一覧表示、POSTリクエストでメモの追加を行う
func memoHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case "GET":
		listMemos(w, r)
	case "POST":
		addMemo(w, r)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

// memoHandlerWithID は、URLパスからIDを取得し、DELETEリクエストでメモを削除する
func memoHandlerWithID(w http.ResponseWriter, r *http.Request) {
	if r.Method != "DELETE" {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}
	// URL例: /memo/3 から "3" を抽出
	idStr := r.URL.Path[len("/memo/"):]
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "Invalid ID", http.StatusBadRequest)
		return
	}
	deleteMemo(w, r, uint(id))
}

func addMemo(w http.ResponseWriter, r *http.Request) {
	var memo Memo
	// リクエストボディからJSONをデコードして memo に格納する
	if err := json.NewDecoder(r.Body).Decode(&memo); err != nil {
		// JSON のパースに失敗した場合、 400 Bad Request を返す
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	result := db.Create(&memo)
	if result.Error != nil {
		// DB操作中にエラーが発生した場合、500 Internal Server Error を返す
		http.Error(w, result.Error.Error(), http.StatusInternalServerError)
		return
	}
	// 成功時は、 201 Created のステータスコードと共に、作成されたメモの情報を返す
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(memo)
}

func listMemos(w http.ResponseWriter, _ *http.Request) {
	var memos []Memo
	result := db.Find(&memos)
	if result.Error != nil {
		http.Error(w, result.Error.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(memos)
}

func deleteMemo(w http.ResponseWriter, _ *http.Request, id uint) {
	result := db.Delete(&Memo{}, id)
	if result.Error != nil {
		http.Error(w, result.Error.Error(), http.StatusInternalServerError)
		return
	}
	if result.RowsAffected == 0 {
		// 指定したIDのメモが存在しない場合、 404 Not Found を返す
		http.Error(w, "No memo found with given ID", http.StatusNotFound)
		return
	}
	// 削除成功時は 200 OK で成功メッセージを返す
	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "Memo deleted successfully")
}

2. CORS を正しく有効化する

2-1. 依存追加

% go get github.com/gin-contrib/cors

2-2. ミドルウェアを差し込む

import (
	"time"
	"github.com/gin-contrib/cors"
	// ほかの import は省略
)

main() で ルートを登録する前 に CORS を差し込むのがポイントです。

router := gin.Default()

// CORS 設定
router.Use(cors.New(cors.Config{
	AllowOrigins:     []string{"http://localhost:5173"}, // 許可するフロントエンド
	AllowMethods:     []string{"GET", "POST", "DELETE", "OPTIONS"},
	AllowHeaders:     []string{"Origin", "Content-Type"},
	ExposeHeaders:    []string{"Content-Length"},
	AllowCredentials: true,
	MaxAge:           12 * time.Hour, // プリフライト結果をキャッシュ
}))

続いてルート登録:

router.POST("/memo", addMemoHandler)
router.GET("/memo", listMemosHandler)
router.DELETE("/memo/:id", deleteMemoHandler)

log.Fatal(router.Run(":8080"))

3. 各設定項目の意味と落とし穴

CORS は「どのオリジンから、どのメソッドで、どのヘッダーを使ってアクセスできるか」をサーバーが明示し、ブラウザがそれを守る仕組みです。上記の各項目は以下の通り:

AllowOrigins

  • 例: []string{"http://localhost:5173"}
  • 許可するオリジンを列挙。完全一致のみ(ワイルドカードは * だが、Credentials と併用不可)。
  • フロントの URL と一致しないと失敗します。

AllowMethods

  • 例: []string{"GET", "POST", "DELETE", "OPTIONS"}
  • 許可するHTTPメソッド。
  • ブラウザは「これから POST 送るけどいい?」と プリフライトで確認します。

AllowHeaders

  • 例: []string{"Origin", "Content-Type"}
  • クライアントが 実際の本リクエストで送る追加ヘッダーを許可。
  • JSON を送るなら Content-Type: application/json をここに含める。

ExposeHeaders

  • 例: []string{"Content-Length"}
  • レスポンスヘッダーのうち、フロントJSから読めるものを追加解禁。
  • これを指定しないと、JSから Content-Length 等が見えません。

AllowCredentials

  • 例: true
  • fetch(..., { credentials: "include" }) のような Cookie/認証情報の送受信を許可。
  • これを true にしたら、AllowOrigins に * は使えません(仕様)。

MaxAge(=プリフライト結果のキャッシュ)

  • 例: 12 * time.Hour
  • プリフライト(後述) の結果をブラウザ側で何時間キャッシュして良いか を秒数で示す(Ginのtime.Durationで指定→秒に変換されます)。
  • 値が大きいほど、同一オリジン・同一メソッド・同一ヘッダーの組み合わせで、次からはプリフライトを省略できる→速くなる。
  • ただし API 仕様を頻繁に変える環境では、キャッシュが効きすぎて 古い許可設定が残ることに注意。

4. そもそも プリフライト って何?

  • ブラウザは安全のため、実リクエスト前に “確認” を投げる場合があります。
  • それが OPTIONS メソッドによる プリフライトリクエスト(Preflight Request)です。
  • 例:「Origin: http://localhost:5173 から POST して良い? Content-Type: application/json 使うけどOK?」
    サーバーは「OKなら Access-Control-Allow-* ヘッダー」で回答します。
  • OK なら、その後に本リクエスト(POST /memo 等)が送られます。
  • MaxAge により、この “OK” の結果を ブラウザがキャッシュ→同条件なら次回以降プリフライト省略。

いつ発生する?

  • GET でも、カスタムヘッダーを積む等でプリフライトが走る場合あり
  • POST で application/json を送るのも “プリフライト対象” です

5. curl で CORS を正しく検証する

CORS は 異なるオリジンから叩いたときだけ効きます。
バックエンドに直接 curl すると CORS は関係ないので、Origin ヘッダーを明示します。

5-1. 本リクエスト(例:GET /memo)

curl -i -H "Origin: http://localhost:5173" http://localhost:8080/memo

期待するレスポンス例:

HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Expose-Headers: Content-Length
Content-Type: application/json; charset=utf-8
Vary: Origin
Date: Sun, 14 Sep 2025 12:24:19 GMT
Content-Length: 2

[]%

5-2. プリフライト(OPTIONS)

curl -i -X OPTIONS http://localhost:8080/memo \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type"

期待レスポンス例:

HTTP/1.1 204 No Content
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Origin,Content-Type
Access-Control-Allow-Methods: GET,POST,DELETE,OPTIONS
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Max-Age: 43200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Date: Sun, 14 Sep 2025 12:41:43 GMT

6. まとめ

  • CORS は サーバーが “この条件だけ許可” と宣言、ブラウザが その宣言を厳守する仕組み
  • Gin の gin-contrib/cors を使えば 数行で正しく設定できる
  • プリフライト(OPTIONS) は、本リクエスト前の 安全確認。MaxAge で キャッシュし高速化
  • 検証は curl -H "Origin: ..." と OPTIONS を使うのが確実。レスポンスヘッダーを目視しよう

Discussion