← レッスン一覧に戻る

03.2 HTTP解剖 — メソッド・パス・ステータス・ヘッダー・ボディ¶

03.1 では、TCP の上に生のバイト列が流れているところを見ました。HTTP は、その生のバイト列に 共通のフォーマットを与えるプロトコルです。ブラウザとサーバーが「何を・どこに・どうやって」 やり取りするかを、双方が読める形に決めています。

リクエストは次の要素からできています:

  • メソッド(GET / POST / PUT / DELETE など)— 「何をしたいか」
  • パス(/users / /users/1 など)— 「どのリソースに対してか」
  • ヘッダー(Content-Type: application/json など)— リクエストに関するメタデータ
  • ボディ(JSON など)— 実際に送るデータ(GET では基本的に使わない)

レスポンスも対応する要素を持ちます:

  • ステータスコード(200 / 201 / 404 / 405 など)— 「結果がどうだったか」
  • ヘッダー(Content-Type / Location など)
  • ボディ(実際に返すデータ)

このレッスンのゴール:

  • HTTP リクエスト/レスポンスの構成要素(メソッド・パス・ヘッダー・ボディ・ステータス)を実物で確認する
  • 同じパスでもメソッドが違えばステータスが変わる、同じメソッドでもパスが違えばステータスが変わることを実際に送って観察する
  • http.Get / http.Post の便利さと限界(ヘッダーを自由に付けられない)を理解する

非自明ポイント¶

  • ステータスコードは「パス」と「メソッド」の両方で決まります。/users に GET すれば一覧が 返り(200)、POST すれば新規作成され(201)、対応していないメソッド(DELETE など)を送ると 405 Method Not Allowed が返ります。同じ /users でも何が起きるかはメソッド次第です。
  • http.Get / http.Post はヘッダーを自由に追加できません。 便利な分、決め打ちのヘッダーしか 付きません(http.Post は Content-Type だけ自動で付けます)。独自ヘッダーを付けたい場合は http.NewRequest でリクエストを組み立て、http.Client.Do で送信します。
  • サーバーは Content-Length や Date のようなヘッダーを自動で付け足します。この表では、 レッスンの主題に関係するヘッダー(Content-Type / Location / Allow)だけを抜き出して表示します (自動付与分まで全部載せると見づらくなるためです)。
In [1]:
import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"

	"github.com/janpfeifer/gonb/gonbui"
)

// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")

// newTestServer は、/users(一覧取得・新規作成)と /users/{id}(単体取得)を持つ
// テスト用サーバーを起動する。外部インターネットへは一切アクセスしない
// httptest.NewServer のみを使う(Go Playground サンドボックスでも動作する)。
func newTestServer() *httptest.Server {
	mux := http.NewServeMux()

	users := map[int]string{1: "tanaka", 2: "suzuki"}
	nextID := 3

	mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodGet:
			var b strings.Builder
			b.WriteString("[")
			first := true
			for id := 1; id < nextID; id++ {
				name, ok := users[id]
				if !ok {
					continue
				}
				if !first {
					b.WriteString(",")
				}
				fmt.Fprintf(&b, `{"id":%d,"name":"%s"}`, id, name)
				first = false
			}
			b.WriteString("]")
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			w.Write([]byte(b.String()))
		case http.MethodPost:
			var payload struct {
				Name string `json:"name"`
			}
			if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
				w.WriteHeader(http.StatusBadRequest)
				w.Write([]byte(`{"error":"invalid json"}`))
				return
			}
			id := nextID
			users[id] = payload.Name
			nextID++
			w.Header().Set("Content-Type", "application/json")
			w.Header().Set("Location", fmt.Sprintf("/users/%d", id))
			w.WriteHeader(http.StatusCreated)
			fmt.Fprintf(w, `{"id":%d,"name":"%s"}`, id, payload.Name)
		default:
			w.Header().Set("Allow", "GET, POST")
			w.WriteHeader(http.StatusMethodNotAllowed)
			w.Write([]byte(`{"error":"method not allowed"}`))
		}
	})

	mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			w.Header().Set("Allow", "GET")
			w.WriteHeader(http.StatusMethodNotAllowed)
			w.Write([]byte(`{"error":"method not allowed"}`))
			return
		}
		idStr := strings.TrimPrefix(r.URL.Path, "/users/")
		id, err := strconv.Atoi(idStr)
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			w.Write([]byte(`{"error":"invalid id"}`))
			return
		}
		name, ok := users[id]
		if !ok {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusNotFound)
			w.Write([]byte(`{"error":"not found"}`))
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		fmt.Fprintf(w, `{"id":%d,"name":"%s"}`, id, name)
	})

	return httptest.NewServer(mux)
}

// reqResult は、1回のリクエスト/レスポンスの観察結果をHTML表用にまとめたもの。
type reqResult struct {
	Label       string
	Method      string
	Path        string
	ReqHeaders  string
	ReqBody     string
	Status      string
	RespHeaders string
	RespBody    string
}

// pickHeaders は、指定した名前のヘッダーだけを "Name: value" の形で抜き出して連結する。
func pickHeaders(h http.Header, names ...string) string {
	var parts []string
	for _, n := range names {
		v := h.Get(n)
		if v == "" {
			continue
		}
		parts = append(parts, fmt.Sprintf("%s: %s", n, v))
	}
	if len(parts) == 0 {
		return "(なし)"
	}
	return strings.Join(parts, "<br>")
}

// captureResult は、送信済みのレスポンスからHTML表に出す情報を組み立てる。
// resp.Request には実際に送信されたリクエスト(メソッド・パス・ヘッダー)が入っている。
func captureResult(label, reqBody string, resp *http.Response) reqResult {
	defer resp.Body.Close()
	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}
	if reqBody == "" {
		reqBody = "(なし)"
	}
	return reqResult{
		Label:       label,
		Method:      resp.Request.Method,
		Path:        resp.Request.URL.Path,
		ReqHeaders:  pickHeaders(resp.Request.Header, "Content-Type", "X-Course"),
		ReqBody:     reqBody,
		Status:      resp.Status,
		RespHeaders: pickHeaders(resp.Header, "Content-Type", "Location", "Allow"),
		RespBody:    string(bodyBytes),
	}
}

func renderResultsTable(results []reqResult) string {
	var b strings.Builder
	b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">
<tr><th>呼び出し</th><th>メソッド</th><th>パス</th><th>リクエストヘッダー</th><th>リクエストボディ</th><th>ステータス</th><th>レスポンスヘッダー</th><th>レスポンスボディ</th></tr>`)
	for _, r := range results {
		fmt.Fprintf(&b, "<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>",
			r.Label, r.Method, r.Path, r.ReqHeaders, r.ReqBody, r.Status, r.RespHeaders, r.RespBody)
	}
	b.WriteString("</table>")
	return b.String()
}

1. 5つのリクエストを送って観察する¶

同じ /users に対して GET・POST・DELETE、同じ GET に対して存在するID・存在しないIDを 送り、メソッドとパスの組み合わせでステータスがどう変わるかを観察します。

このセルは httptest.NewServer の起動から Close() までを1セルで完結させます (サーバーはセルをまたいで生かしたまま持ち越しません)。

In [2]:
%%
srv := newTestServer()
defer srv.Close()

var results []reqResult

// 1. GET /users → 200(一覧)
resp1, err := http.Get(srv.URL + "/users")
if err != nil {
	panic(err)
}
results = append(results, captureResult("1. GET /users(一覧取得)", "", resp1))

// 2. GET /users/1 → 200(存在するID)
resp2, err := http.Get(srv.URL + "/users/1")
if err != nil {
	panic(err)
}
results = append(results, captureResult("2. GET /users/1(存在するID)", "", resp2))

// 3. GET /users/999 → 404(存在しないID)
resp3, err := http.Get(srv.URL + "/users/999")
if err != nil {
	panic(err)
}
results = append(results, captureResult("3. GET /users/999(存在しないID)", "", resp3))

// 4. POST /users → 201(新規作成。http.Post は Content-Type を自動で付ける)
reqBody4 := `{"name":"yamada"}`
resp4, err := http.Post(srv.URL+"/users", "application/json", strings.NewReader(reqBody4))
if err != nil {
	panic(err)
}
results = append(results, captureResult("4. POST /users(新規作成)", reqBody4, resp4))

// 5. DELETE /users → 405(このパスは GET/POST しか受け付けない)
// http.Get/http.Post には無い「独自ヘッダーを付ける」ために http.NewRequest を使う。
req5, err := http.NewRequest(http.MethodDelete, srv.URL+"/users", nil)
if err != nil {
	panic(err)
}
req5.Header.Set("X-Course", "recursion-go")
resp5, err := http.DefaultClient.Do(req5)
if err != nil {
	panic(err)
}
results = append(results, captureResult("5. DELETE /users(未対応メソッド + 独自ヘッダー)", "", resp5))

for _, r := range results {
	fmt.Printf("%s → %s\n", r.Label, r.Status)
}

gonbui.DisplayHTML(renderResultsTable(results))
gonbui.Sync()
1. GET /users(一覧取得) → 200 OK
2. GET /users/1(存在するID) → 200 OK
3. GET /users/999(存在しないID) → 404 Not Found
4. POST /users(新規作成) → 201 Created
5. DELETE /users(未対応メソッド + 独自ヘッダー) → 405 Method Not Allowed
呼び出しメソッドパスリクエストヘッダーリクエストボディステータスレスポンスヘッダーレスポンスボディ
1. GET /users(一覧取得)GET/users(なし)(なし)200 OKContent-Type: application/json[{"id":1,"name":"tanaka"},{"id":2,"name":"suzuki"}]
2. GET /users/1(存在するID)GET/users/1(なし)(なし)200 OKContent-Type: application/json{"id":1,"name":"tanaka"}
3. GET /users/999(存在しないID)GET/users/999(なし)(なし)404 Not FoundContent-Type: application/json{"error":"not found"}
4. POST /users(新規作成)POST/usersContent-Type: application/json{"name":"yamada"}201 CreatedContent-Type: application/json
Location: /users/3
{"id":3,"name":"yamada"}
5. DELETE /users(未対応メソッド + 独自ヘッダー)DELETE/usersX-Course: recursion-go(なし)405 Method Not AllowedContent-Type: text/plain; charset=utf-8
Allow: GET, POST
{"error":"method not allowed"}

読んでください:

  • /users は メソッドで結果が変わりました: GET → 200(一覧)、POST → 201(作成)、 DELETE → 405(未対応)。パスは全部同じ /users です。
  • /users/1 と /users/999 は パスで結果が変わりました: 同じ GET でも、存在するIDなら 200、存在しないIDなら 404 です。
  • リクエストヘッダーの列を見ると、GET の2件は (なし) ですが、POST は Content-Type が 自動で付き、DELETE は明示的に付けた X-Course が出ています。http.Get では独自ヘッダーを 付けられなかったことが、http.NewRequest を使った5番目の呼び出しとの違いで分かります。
  • レスポンスヘッダーの 405 の行には Allow: GET, POST が付いています。「このパスで使えるメソッド は何か」をサーバーが教えてくれています。

2. 直感・類推: 郵便物の宛名・種別・中身¶

HTTP リクエストは、郵便物に似ています。

  • パス(/users/1)は宛先住所。どこに届けるか。
  • メソッド(GET/POST)は郵便の種別。「お尋ね(GET)」なのか「新規申請書の提出(POST)」なのか。
  • ヘッダーは封筒に書かれた付帯情報(差出人・優先度など)。中身そのものではない。
  • ボディは封筒の中身。実際に届けたいデータ。

そして相手の返事(レスポンス)にも同じ構造があります。ステータスコードは「受理しました (200)」「新しく登録しました(201)」「その住所には誰もいません(404)」「その種別の郵便は ここでは扱っていません(405)」という定型の返答区分です。文面(ボディ)を読まなくても、 返答区分だけで大まかに結果が分かるのが、ステータスコードの役割です。

練習問題 3.2: RequestStatus を実装しよう¶

指定したテスト用サーバーに、指定したメソッド・パス・ボディでリクエストを送り、 レスポンスのステータスコードを返す関数 RequestStatus を実装してください。

仕様:

func RequestStatus(srv *httptest.Server, method, path string, body io.Reader) (int, error)
  • http.NewRequest(method, srv.URL+path, body) でリクエストを組み立てる
  • http.DefaultClient.Do(req) で送信する
  • レスポンスの Body を Close() してから(defer resp.Body.Close())、resp.StatusCode を返す
  • リクエスト組み立て・送信でエラーが起きたら (0, err) を返す

ヒント: 上の「1. 5つのリクエストを送って観察する」の 5番目(DELETE)の書き方がほぼそのまま使えます。

In [3]:
// YOUR CODE HERE
// RequestStatus を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func RequestStatus(srv *httptest.Server, method, path string, body io.Reader) (int, error) {
	return 0, ErrUnanswered
}

チェックのためのヘルパー¶

In [4]:
import "reflect"

func mustEqual(got, want any, name string) {
	if reflect.DeepEqual(got, want) {
		fmt.Printf("✅ Passed: %s\n", name)
		return
	}
	panic(fmt.Sprintf("❌ %s\n  got  = %v (%T)\n  want = %v (%T)", name, got, got, want, want))
}
In [5]:
%%
srv := newTestServer()
defer srv.Close()

status1, err1 := RequestStatus(srv, http.MethodGet, "/users", nil)
if errors.Is(err1, ErrUnanswered) {
	fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
	if err1 != nil {
		panic(err1)
	}
	status2, err2 := RequestStatus(srv, http.MethodGet, "/users/999", nil)
	if err2 != nil {
		panic(err2)
	}
	status3, err3 := RequestStatus(srv, http.MethodPost, "/users", strings.NewReader(`{"name":"kimura"}`))
	if err3 != nil {
		panic(err3)
	}
	status4, err4 := RequestStatus(srv, http.MethodDelete, "/users", nil)
	if err4 != nil {
		panic(err4)
	}

	mustEqual(status1, http.StatusOK, "GET /users は200")
	mustEqual(status2, http.StatusNotFound, "GET /users/999 は404")
	mustEqual(status3, http.StatusCreated, "POST /users は201")
	mustEqual(status4, http.StatusMethodNotAllowed, "DELETE /users は405")
	fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください

まとめ¶

  • HTTP リクエストは メソッド + パス + ヘッダー + ボディ、レスポンスは ステータス + ヘッダー + ボディ
  • ステータスコードは パスとメソッドの組み合わせで決まる。同じパスでもメソッドが違えば結果が変わる
  • http.Get/http.Post は手軽だが独自ヘッダーは付けられない。付けたいなら http.NewRequest + http.Client.Do
  • サーバーは Content-Length/Date のようなヘッダーを自動で付ける

答え合わせは 03.2-http-anatomy-solutions.ipynb で行ってください。 次は 03.3 で「net/http でWebサーバーを0から書く」を学びます。