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)だけを抜き出して表示します (自動付与分まで全部載せると見づらくなるためです)。
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セルで完結させます
(サーバーはセルをまたいで生かしたまま持ち越しません)。
%%
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 OK | Content-Type: application/json | [{"id":1,"name":"tanaka"},{"id":2,"name":"suzuki"}] |
| 2. GET /users/1(存在するID) | GET | /users/1 | (なし) | (なし) | 200 OK | Content-Type: application/json | {"id":1,"name":"tanaka"} |
| 3. GET /users/999(存在しないID) | GET | /users/999 | (なし) | (なし) | 404 Not Found | Content-Type: application/json | {"error":"not found"} |
| 4. POST /users(新規作成) | POST | /users | Content-Type: application/json | {"name":"yamada"} | 201 Created | Content-Type: application/json Location: /users/3 | {"id":3,"name":"yamada"} |
| 5. DELETE /users(未対応メソッド + 独自ヘッダー) | DELETE | /users | X-Course: recursion-go | (なし) | 405 Method Not Allowed | Content-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)の書き方がほぼそのまま使えます。
// YOUR CODE HERE
// RequestStatus を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func RequestStatus(srv *httptest.Server, method, path string, body io.Reader) (int, error) {
return 0, ErrUnanswered
}
チェックのためのヘルパー¶
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))
}
%%
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から書く」を学びます。