ข้ามไปยังเนื้อหา

REST Mapping

บทนี้ไม่มีไฟล์ใหม่ — gateway/cmd/main.go จาก grpc-gateway → ไม่เปลี่ยนเลย บทนี้ว่าด้วยการทำความเข้าใจว่าไฟล์นั้นทำอะไรอยู่แล้วในทุก request — แต่ละ option google.api.http จาก The Contracts → ตัดสิน path, body และ query parameter ของ request อย่างไร JSON บน wire หน้าตาเป็นแบบไหนจริง ๆ และ gRPC error กลายเป็น HTTP status code อะไรโดย default ทั้งที่ไม่มีใครเขียนโค้ดไว้เลยสักบรรทัด

reverse proxy ที่ generate มาจาก annotation จะน่าเชื่อถือได้ก็ต่อเมื่อคุณรู้แน่ชัดว่าเกิดอะไรขึ้นกับ request ที่เข้ามา ไม่อย่างนั้นทุก REST endpoint ก็เป็นแค่กล่องดำที่บังเอิญทำงานได้ในวันนี้ ทุกกฎในบทนี้อ่านมาจากไฟล์ .proto ทั้งสองไฟล์โดยตรง หรือไม่ก็เป็นพฤติกรรม default ของ grpc-gateway ที่กำหนดไว้ชัดเจนและ config ไม่ได้ ไม่มีอะไรในนี้ที่เดาว่า gateway “น่าจะ” ทำงานยังไง

พึ่งพา status mapping default และ error body default ของ grpc-gateway (สิ่งที่คอร์สนี้ทำ)

  • Pros: ไม่มีโค้ดเพิ่มเลย — ทุก codes.NotFound/codes.InvalidArgument/codes.FailedPrecondition ที่ Catalog กับ Order service คืนค่าอยู่แล้ว (จาก The Product API → กับ The Order Repository →) ถูกแปลเป็น HTTP status โดยอัตโนมัติ; mapping นี้มีเอกสารรองรับ เสถียร และเหมือนกันทุกเซอร์วิสที่อยู่หลัง grpc-gateway ที่ไหนก็ตาม ดังนั้น client ที่เคยเจอ grpc-gateway API มาก่อนก็รู้อยู่แล้วว่าจะเจออะไรจากตัวนี้
  • Cons: error body default เป็น envelope ทั่วไป {"code":..., "message":..., "details":[]} — ไม่มีทางเพิ่ม field เฉพาะโปรเจกต์ เช่นสตริง error_code ที่เครื่องอ่านได้แน่นอน หรือข้อความที่แปลภาษาไว้ ถ้าไม่ override ตัว envelope นี้ อีกทั้ง gRPC status code ทุกตัวไหลผ่าน mapping table เดียวกันที่ตายตัว เซอร์วิสจึงบอกไม่ได้ว่า “NotFound ตัวนี้ควรจะเป็น 410 Gone จริง ๆ” ถ้าไม่ไปใช้ extension point ด้านล่าง

runtime.WithErrorHandler แบบกำหนดเอง

  • Pros: ควบคุมรูปแบบของ JSON error body ได้เต็มที่ ทั้งเพิ่ม field แปลข้อความ และ log ทุกความล้มเหลวระดับ gateway ไว้ที่เดียว ไม่ว่า RPC ตัวไหนจะเป็นต้นเหตุ
  • Cons: มีอีกหนึ่งฟังก์ชันที่ REST error response ทุกตัวต้องไหลผ่าน บั๊กตรงนี้จึงพังการรายงาน error ของ ทั้ง gateway ไม่ใช่แค่ endpoint เดียว และต้องคอย sync ด้วยมือกับพฤติกรรม default ที่ override อยู่ เพราะ default ของ grpc-gateway เองอาจเปลี่ยนไปตาม version

คอร์สนี้ใช้ default ต่อจากทั้งสองบทที่ผ่านมา (main.go ของ grpc-gateway.mdx เรียก runtime.NewServeMux() แบบไม่มี option เลย) — mapping ด้านล่างนี้ถูกต้อง เสถียร และไม่มีต้นทุนอะไรเลย runtime.WithErrorHandler ถูกบันทึกไว้ตรงนี้ในฐานะ extension point ที่มีอยู่จริง ไม่ใช่สิ่งที่ main.go ของคอร์สนี้ใช้อยู่ในตอนนี้

rpc GetProduct(GetProductRequest) returns (Product) {
option (google.api.http) = { get: "/v1/products/{id}" };
}

path template ที่เขียนเป็น `{id}` ผูก URL segment นั้นตรงเข้ากับ field ชื่อ id บน GetProductRequest ตามชื่อเป๊ะ ๆ — GET /v1/products/8f14e45f-... มาถึง handler เป็น &GetProductRequest{Id: "8f14e45f-..."} โดยไม่มีการ parse URL มือเลย กฎเดียวกันนี้คือสิ่งที่ทำให้ GET /v1/orders/{id} ผูกกับ GetOrderRequest.id ได้

rpc CreateProduct(CreateProductRequest) returns (Product) {
option (google.api.http) = { post: "/v1/products" body: "*" };
}

body: "*" หมายความว่า JSON request bodyทั้งหมดที่เข้ามาถูก unmarshal ลงบนทุก field ของ CreateProductRequest — body ของ POST ที่เป็น {"name":"Coffee Mug","price_cents":1299} กลายเป็น &CreateProductRequest{Name: "Coffee Mug", PriceCents: 1299} ส่วน CreateOrder ที่มี post: "/v1/orders" body: "*" ก็ทำงานแบบเดียวกันกับ CreateOrderRequest ขณะที่ mapping แบบ get ไม่มี field body เลย เพราะ request แบบ GET ไม่มี body input จึงมีแค่ path template กับ query parameter

rpc ListProducts(ListProductsRequest) returns (ListProductsResponse) {
option (google.api.http) = { get: "/v1/products" };
}
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse) {
option (google.api.http) = { get: "/v1/orders" };
}

field ของ request message ที่ path template หรือ body ไม่ได้ใช้ จะถูกอ่านจาก query string โดยอัตโนมัติ ไม่ต้องประกาศแยกเลย field page/page_size ของ ListProductsRequest ไม่มี path segment ให้ผูก GET /v1/products?page=2&page_size=10 จึงเติมค่าให้ตรง ๆ จาก query string

ListOrdersRequest.customer_id ก็ทำงานแบบเดียวกัน GET /v1/orders?customer_id=cust-1 คือวิธีที่ client กรอง order ตาม customer ผ่าน REST โดยไม่มีโค้ด parse query เฉพาะของ gateway เขียนไว้ที่ไหนเลย เพราะเป็นการผูกตามชื่อ field แบบเดียวกับ path parameter เพียงแต่รับค่ามาจาก ?key=value แทน URL segment

ชื่อ field JSON ทุกตัวบน wire คือรูป lowerCamelCase ของชื่อ field แบบ snake_case ใน .protoprice_cents กลายเป็น priceCents, unit_price_cents กลายเป็น unitPriceCents, customer_id กลายเป็น customerId นี่คือ mapping มาตรฐานของ protojson ไม่ใช่สิ่งที่ grpc-gateway คิดขึ้นเอง — The Product API → แสดงกฎเดียวกันนี้ไปแล้วจากฝั่ง grpcurl

field แบบ int64 serialize เป็น JSON string ไม่ใช่ JSON number — "priceCents": "1299" ไม่ใช่ "priceCents": 1299 — เพราะ JSON number ไม่สามารถแทนค่า 64-bit integer ทุกค่าได้อย่างปลอดภัยโดยไม่เสีย precision ใน client JavaScript field แบบ int32 (stock, quantity) ยังคงเป็น JSON number ธรรมดา เพราะค่า 32-bit จะพอดีอย่างปลอดภัยเสมอ ถ้าเขียน request body มือแล้วพลาดตรงนี้ gateway จะปฏิเสธเป็น decode error ก่อนที่ request จะไปถึง gRPC service เลยด้วยซ้ำ

อีกหนึ่ง default ที่ควรพูดถึงตรง ๆ ที่เห็นไปแล้วในบทที่แล้วตอน curl localhost:8080/v1/products คืนค่ามาเป็น {}: protojson (และก็คือ runtime.JSONPb) ละ field ไหนก็ตามที่ยังเป็นzero valueอยู่ทิ้งไป — string ว่าง, 0, repeated field ที่ว่างเปล่า — เว้นแต่ marshaler จะถูก config ด้วย EmitUnpopulated อย่างชัดเจน main.go ของคอร์สนี้ไม่เคยทำแบบนั้นเลย ดังนั้น response ที่เป็นค่า default ทั้งหมดจึงกลับมาเป็น {} ได้จริง ๆ และ ListProductsResponse ที่ไม่มีแถวเลยจะละทั้ง products กับ total ทิ้งไป แทนที่จะส่ง {"products":[],"total":0}

runtime.ServeMux ของ grpc-gateway แปลทุก status.Error(code, msg) ที่เซอร์วิสคืนค่าเป็น HTTP status ผ่าน table ที่ตายตัวและมีเอกสารรองรับ — นี่คือส่วนย่อยที่เซอร์วิสของคอร์สนี้คืนค่าจริง ๆ:

gRPC codeHTTP statusคืนค่าโดย
OK200ทุก call ที่สำเร็จ
InvalidArgument400input หายไป/ผิดรูปแบบ — id ว่างของ GetProduct, name หายไปของ CreateProduct
FailedPrecondition400CreateOrder ตั้งราคา product_id ที่ Catalog รายงานว่าไม่มีอยู่จริง
NotFound404GetProduct/GetOrder สำหรับ id ที่ไม่มีอยู่จริง
AlreadyExists409(เซอร์วิสของคอร์สนี้ยังไม่ใช้วันนี้ แสดงไว้เพื่อความครบถ้วน)
Unauthenticated401(ยังไม่ใช้ — Resilience → คือจุดที่ auth จะลงมา)
Unimplemented501RPC ไหนก็ตามที่ยังไม่ implement — ตรงกับ response ของ placeholder server ทุกตัวก่อนที่ Server จริงจะมีอยู่
Internal500ความล้มเหลวของ repository/database ใด ๆ ที่ไม่ใช่ NotFound
Unavailable503gRPC service ฝั่ง backend เข้าถึงไม่ได้เลย

table นี้คือเหตุผลตรง ๆ ที่ codes.FailedPrecondition เป็นทางเลือกที่จงใจเลือกใน The Order Repository → สำหรับ “Catalog บอกว่า product นี้ไม่มีอยู่จริง” แทนที่จะเป็น codes.InvalidArgument — ทั้งคู่ map เป็น 400 เหมือนกันตรงนี้ ดังนั้น REST client แยกทั้งสองออกจากกันไม่ได้เลยด้วย status code อย่างเดียว แยกได้แค่จาก message ใน error body นี่คือข้อจำกัดที่แท้จริงและตรงไปตรงมาของการพึ่งพา default mapping แทนที่จะใช้ error handler แบบกำหนดเอง

6. Extension point ไว้อ้างอิง — runtime.WithErrorHandler กับ runtime.WithIncomingHeaderMatcher

หัวข้อที่มีชื่อว่า “6. Extension point ไว้อ้างอิง — runtime.WithErrorHandler กับ runtime.WithIncomingHeaderMatcher”

ไม่ได้ถูกใช้ใน main.go ของคอร์สนี้ แต่ควรรู้ว่ามีอยู่:

mux := runtime.NewServeMux(
runtime.WithErrorHandler(func(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) {
// Full control over the JSON error body and status code here —
// this course relies on runtime.DefaultHTTPErrorHandler instead,
// which is what mux uses automatically when this option is omitted.
}),
runtime.WithIncomingHeaderMatcher(func(header string) (string, bool) {
// Controls which incoming HTTP headers are forwarded into the
// gRPC call's metadata — the default matcher forwards a small
// standard set (like grpc-metadata- prefixed headers) and drops
// the rest.
return header, true
}),
)

WithErrorHandler แทนที่การแปล error เป็น JSON ทั้งหมดที่แสดงใน table ด้านบน; WithIncomingHeaderMatcher ตัดสินใจว่า HTTP request header ตัวไหน (header Authorization, header สำหรับ tracing) จะถูกส่งต่อเข้าไปใน gRPC call เป็น metadata เทียบกับถูกทิ้งไปเงียบ ๆ ที่ gateway ทั้งสองเป็น extension point จริง ๆ ที่ gateway ใน production คงต้องใช้ — การแนบ request ID หรือ auth token เข้ากับทุก gRPC call ที่ยิงลงไปต้องใช้แบบนี้เป๊ะ — แต่ยังไม่จำเป็นสำหรับ REST surface ของคอร์สนี้ในวันนี้ ดังนั้น main.go จึงยังคงใช้ runtime.NewServeMux() ธรรมดาจากบทที่แล้ว

ให้ Catalog, Order, และ gateway ยังรันอยู่จาก grpc-gateway → รัน flow เต็มรูปแบบ create-product → create-order → get-order ผ่าน REST ล้วน ๆ:

Terminal window
curl -s -X POST localhost:8080/v1/products \
-H 'Content-Type: application/json' \
-d '{"name":"Coffee Mug","description":"350ml ceramic mug","price_cents":1299,"stock":50}'
{
"id": "8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21",
"name": "Coffee Mug",
"description": "350ml ceramic mug",
"priceCents": "1299",
"stock": 50
}

copy id แล้วสั่งซื้อสองชิ้น:

Terminal window
curl -s -X POST localhost:8080/v1/orders \
-H 'Content-Type: application/json' \
-d '{"customer_id":"cust-1","items":[{"product_id":"8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21","quantity":2}]}'
{
"id": "3a7c9e21-1e4d-4b8a-9c6e-2f8b1d5a7c90",
"customerId": "cust-1",
"status": "ORDER_STATUS_PENDING",
"totalCents": "2598",
"items": [
{
"productId": "8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21",
"quantity": 2,
"unitPriceCents": "1299"
}
],
"createdAt": "2026-07-14T09:12:03Z"
}

totalCents คือ 1299 × 2 = 2598 HTTP request นี้ไม่เคยส่งราคาไปเลย แต่ Order service ไปหาราคาสด ๆ จาก Catalog มาให้ ตรงตามที่ The Order Repository → สร้างไว้ gateway แค่แปลงชั้น transport เท่านั้น ดึง order นั้นกลับมาด้วย id โดยใช้การผูก path-parameter จากขั้นตอนที่ 1 ของบทนี้:

Terminal window
curl -s localhost:8080/v1/orders/3a7c9e21-1e4d-4b8a-9c6e-2f8b1d5a7c90

list order ทั้งหมดของ customer นั้น โดยใช้การผูก query-parameter จากขั้นตอนที่ 3:

Terminal window
curl -s "localhost:8080/v1/orders?customer_id=cust-1"

ยืนยัน status mapping table ด้วย request จริง — codes.NotFound กลายเป็น 404:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/v1/products/00000000-0000-0000-0000-000000000000
404

และ codes.InvalidArgument กลายเป็น 400 พร้อม error envelope default:

Terminal window
curl -s -X POST localhost:8080/v1/products \
-H 'Content-Type: application/json' \
-d '{"description":"no name given"}'
{
"code": 3,
"message": "name is required",
"details": []
}

"code": 3 คือค่า numeric ของ codes.InvalidArgument ใน enum ของ gRPC status code — code table เดียวกับที่ The Product API → ใช้ไปแล้ว เพียงแต่ตรงนี้แสดงออกมาเป็น JSON แทนข้อความ Code: InvalidArgument ของ grpcurl

สุดท้าย ยืนยันว่าทั้ง module ยัง build ผ่านสะอาด:

Terminal window
go build ./...

ไม่มี output แปลว่าสำเร็จ

ไม่มีอะไรใน gateway/cmd/main.go เปลี่ยนเลยในบทนี้ — ทุกกฎที่นี่ทำงานอยู่แล้วโดยไม่มีใครเห็น ตั้งแต่ grpc-gateway →: path segment `{id}` ผูกกับ request field ชื่อเดียวกัน; body: "*" unmarshal JSON body ทั้งหมดลงบน request message; field ที่เหลือ (page/page_size, customer_id) ถูกอ่านจาก query string โดยอัตโนมัติ; ชื่อ field ทุกตัวบน wire เป็น lowerCamelCase โดย int64 serialize เป็น JSON string และ field ที่เป็น zero value ถูกละทิ้งทั้งหมด; และทุก status.Error(code, ...) ที่เซอร์วิสคืนค่ากลายเป็น HTTP status ผ่าน table ที่ตายตัวและมีเอกสารรองรับ — InvalidArgument/FailedPrecondition → 400, NotFound → 404, Internal → 500 — โดยมี runtime.WithErrorHandler/runtime.WithIncomingHeaderMatcher เป็น extension point จริงที่คอร์สนี้แค่ยังไม่จำเป็นต้องใช้ REST flow เต็มรูปแบบ — สร้าง product, สั่งซื้อที่ตั้งราคาสดจาก product นั้น, ดึง order กลับมา, list ตาม customer, และเห็นทั้ง 404 กับ 400 ทำงานถูกต้อง — ยืนยันทั้งหมดนี้แบบ end-to-end โดยไม่มีโค้ดเฉพาะของ gateway เขียนไว้ที่ไหนเลย บทถัดไป OpenAPI Documentation → จะ serve เอกสาร *.swagger.json ที่ Code Generation → สร้างไปแล้วจาก annotation ชุดเดียวกันนี้ พร้อม Swagger UI ในฐานะเอกสารที่มีชีวิตของ REST surface นี้