Open11

goのライブラリ agnosticなリクエストのハンドリングを考えてみる

podhmopodhmo

以前にも考えた気がするけれどもう一度考えてみる

podhmopodhmo

昔考えた式指向のやつの記録が見つからない
(後で貼る)

podhmopodhmo

こんな感じ

// @deriving:binding
type AnotherModel struct {
	ItemName  string `in:"query" query:"item_name" required:"true"`
	Quantity  *int   `in:"query" query:"quantity"`
	IsSpecial bool   `in:"header" header:"X-Special"`
}

func (s *AnotherModel) Bind(req *http.Request, pathVar func(string) string) error {
	b := binding.New(req, pathVar)
	return errors.Join(
		binding.One(b, &s.ItemName, binding.Query, "item_name", parser.String, binding.Required), // Field: ItemName (string)
		binding.OnePtr(b, &s.Quantity, binding.Query, "quantity", parser.Int, binding.Optional), // Field: Quantity (*int)
		binding.One(b, &s.IsSpecial, binding.Header, "X-Special", parser.Bool, binding.Optional), // Field: IsSpecial (bool)
	)
}
podhmopodhmo

parse, don't validate的な感じにしたい気もする

podhmopodhmo
podhmopodhmo

大雑把に必要なもの

Go 1.22以降の net/http パッケージを使用して、OpenAPIで定義されるような各種パラメータをバインディング(取得・割り当て)する方法には、以下の種類があります。

  • クエリパラメータ (Query Parameters)

    • HTTPリクエストのURLに含まれる ?key=value 形式のパラメータを取得します。リクエストオブジェクトの URL.Query() メソッドを使って値を取得できます。
  • パスパラメータ (Path Parameters)

    • URLのパスの一部(例: /users/{id}{id} 部分)をパラメータとして取得します。Go 1.22から http.ServeMux がパスのワイルドカードをサポートし、リクエストオブジェクトの PathValue() メソッドで値を取得できるようになりました。
  • リクエストヘッダ (Header Parameters)

    • HTTPリクエストヘッダ(例: X-API-Key)に含まれる値を取得します。リクエストオブジェクトの Header.Get() メソッドなどを使用します。
  • リクエストボディ (Request Body)

    • 主にJSONやXML形式で送信されるリクエストの本体部分を、構造体などにデコード(バインド)します。json.NewDecoder などをリクエストの Body と組み合わせて使用します。
  • フォームデータ (Form Data)

    • HTMLのフォームから送信される application/x-www-form-urlencodedmultipart/form-data 形式のデータを取得します。リクエストの ParseForm()ParseMultipartForm() を呼び出した後、FormValue()FormFile() などのメソッドで値やファイルを取得します。
  • クッキー (Cookie Parameters)

    • リクエストヘッダに含まれるCookieの値を取得します。リクエストオブジェクトの Cookie() メソッドで特定の名前のクッキーを取得できます。
podhmopodhmo

パスパラメーター以外はリクエストに生えてる

podhmopodhmo

はい、承知いたしました。
アプリケーションコードとhttptestを使ったテストコードを、Go Playgroundで直接実行できる単一のファイルにまとめます。

Go Playgroundでは go test コマンドを実行できるため、このコードを貼り付けて "Run" ではなく "Test" を選択すると、テストが実行されます。

Go Playground用 統合ソースコード

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

// --- アプリケーションコード ---

// UserData はリクエストボディのJSONをバインドするための構造体
type UserData struct {
	Age int `json:"age"`
}

// HandlerParams はアダプターからビジネスロジックへ渡す、全パラメータを格納する構造体
type HandlerParams struct {
	UserID    string
	QueryName string
	APIKey    string
	SessionID string
	// ボディの形式によってどちらか一方に値が入る
	JSONBody UserData
	FormData string // デモのためフォームの "email" フィールドを格納
}

// businessLogicHandler はHTTPの詳細に依存しないビジネスロジックを担当する
func businessLogicHandler(w http.ResponseWriter, r *http.Request, params HandlerParams) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)

	response := map[string]interface{}{
		"message":            "Successfully bound all parameters!",
		"path_parameter":     params.UserID,
		"query_parameter":    params.QueryName,
		"header_parameter":   params.APIKey,
		"cookie_parameter":   params.SessionID,
		"json_body_received": params.JSONBody,
		"form_data_received": params.FormData,
	}

	if err := json.NewEncoder(w).Encode(response); err != nil {
		log.Printf("レスポンスの書き込みに失敗しました: %v", err)
	}
}

// handlerAdapter は net/http のハンドラで、リクエストからパラメータを抽出し、
// ビジネスロジックを呼び出すアダプターの役割を担う
func handlerAdapter(w http.ResponseWriter, r *http.Request) {
	params := HandlerParams{}
	var err error

	params.UserID = r.PathValue("id")
	params.QueryName = r.URL.Query().Get("name")
	params.APIKey = r.Header.Get("X-API-Key")

	cookie, err := r.Cookie("session_id")
	if err == nil {
		params.SessionID = cookie.Value
	}

	contentType := r.Header.Get("Content-Type")
	if strings.HasPrefix(contentType, "application/json") {
		if err := json.NewDecoder(r.Body).Decode(&params.JSONBody); err != nil {
			http.Error(w, "Invalid JSON body", http.StatusBadRequest)
			return
		}
	} else if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
		if err := r.ParseForm(); err != nil {
			http.Error(w, "Could not parse form", http.StatusBadRequest)
			return
		}
		params.FormData = r.FormValue("email")
	}

	businessLogicHandler(w, r, params)
}

// main関数は、Go Playgroundではサーバーとして実行できません。
// ローカル環境で `go run .` を実行する際に使用します。
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /users/{id}", handlerAdapter)
	fmt.Println("サーバーをポート 8080 で起動します。")

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	if err := server.ListenAndServe(); err != nil {
		log.Fatalf("サーバーの起動に失敗しました: %v", err)
	}
}

// --- テストコード ---

// TestHandlerAdapterWithJSON はJSONボディを持つリクエストをテストする
func TestHandlerAdapterWithJSON(t *testing.T) {
	// 1. リクエストボディの組み立て
	reqBody := `{"age": 30}`
	// 2. HTTPリクエストの作成
	req := httptest.NewRequest(http.MethodPost, "/users/123?name=gopher", strings.NewReader(reqBody))
	// 3. パスパラメータの設定 (Go 1.22+ の機能)
	req.SetPathValue("id", "123")
	// 4. ヘッダーとクッキーの組み立て
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-API-Key", "my-secret-key")
	req.AddCookie(&http.Cookie{Name: "session_id", Value: "abcdef12345"})

	// 5. レスポンスを記録するための ResponseRecorder を作成
	rr := httptest.NewRecorder()
	// 6. テスト対象のハンドラを直接呼び出す
	handlerAdapter(rr, req)

	// 7. 結果の検証
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	var resp map[string]interface{}
	if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
		t.Fatalf("Could not decode response body: %v", err)
	}

	if resp["path_parameter"] != "123" {
		t.Errorf("unexpected path parameter: got %v want %v", resp["path_parameter"], "123")
	}
	if resp["query_parameter"] != "gopher" {
		t.Errorf("unexpected query parameter: got %v want %v", resp["query_parameter"], "gopher")
	}
	if resp["header_parameter"] != "my-secret-key" {
		t.Errorf("unexpected header parameter: got %v want %v", resp["header_parameter"], "my-secret-key")
	}
	if resp["cookie_parameter"] != "abcdef12345" {
		t.Errorf("unexpected cookie parameter: got %v want %v", resp["cookie_parameter"], "abcdef12345")
	}
	jsonBody := resp["json_body_received"].(map[string]interface{})
	if jsonBody["age"].(float64) != 30 {
		t.Errorf("unexpected json body value: got %v want %v", jsonBody["age"], 30)
	}
}

// TestHandlerAdapterWithForm はフォームデータを持つリクエストをテストする
func TestHandlerAdapterWithForm(t *testing.T) {
	// 1. リクエストボディの組み立て
	formData := "email=gopher@example.com"
	// 2. HTTPリクエストの作成
	req := httptest.NewRequest(http.MethodPost, "/users/456?name=engineer", strings.NewReader(formData))
	// 3. パスパラメータの設定
	req.SetPathValue("id", "456")
	// 4. ヘッダーとクッキーの組み立て
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("X-API-Key", "my-secret-key-2")
	req.AddCookie(&http.Cookie{Name: "session_id", Value: "fedcba54321"})

	// 5. レスポンスレコーダーの作成
	rr := httptest.NewRecorder()
	// 6. ハンドラの呼び出し
	handlerAdapter(rr, req)

	// 7. 結果の検証
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	var resp map[string]interface{}
	if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
		t.Fatalf("Could not decode response body: %v", err)
	}

	if resp["form_data_received"] != "gopher@example.com" {
		t.Errorf("unexpected form data: got %v want %v", resp["form_data_received"], "gopher@example.com")
	}
}
podhmopodhmo

このハンドルアダプターは生成できる(やらない)