Skip to content

HTTP Layer

Built on Echo v5. core.IHTTPContext คือ core.IContext (ทุก capability) บวกกับผิวของ echo (request/response)

echo v5 เปลี่ยน echo.Context จาก interface เป็น structIHTTPContext จึง embed มันไม่ได้อีก แต่ประกาศ method ที่ handler ใช้จริงไว้เองแทน (JSON, Param, QueryParam, Bind, Request, …) โค้ด handler เดิมจึงไม่ต้องแก้ สิ่งที่ต้องแก้มีสองอย่าง ดู Migrating to echo v5 ท้ายหน้า

Server

go
e := core.NewHTTPServer(app, &core.HTTPOptions{
    AllowOrigins: []string{"https://app.example.com"},
})
e.POST("/users", controller.CreateUser)
core.StartHTTPServer(e, env) // blocking in dev; graceful shutdown otherwise
go
e := core.NewHTTPServer(app, &core.HTTPOptions{
    DisableRequestLog: core.BoolPtr(true),   // ปิด access log ของ server นี้
})

ปิดทั้งระบบด้วย APP_LOG_REQUEST=false — option ชนะ env เสมอ

The stack applies request-id, structured request logging, panic recovery (panic → 500, never a crash), and CORS. The error handler renders any core.IError as {code, message, fields} with the right status.

NewHTTPServer returns a *core.Server that remembers the App — route methods take the handler directly (no app threaded through every call). It embeds *echo.Echo, so all Echo methods (Use, Static, Pre, …) still work.

Route groups

go
e := core.NewHTTPServer(app, nil)

api := e.Group("/api", authMiddleware)   // *core.Group, also carries the App
api.GET("/users", ListUsers)
api.POST("/users", CreateUser)

v2 := api.Group("/v2")                    // nested groups work too
v2.GET("/users/:id", GetUser)

Route methods: GET, POST, PUT, PATCH, DELETE, each accepting a core.HandlerFunc plus optional echo.MiddlewareFunc.

Escape hatch: for raw Echo registration you can still use e.Echo.POST(path, core.WithHTTPContext(app, h)).

Handlers

go
type HandlerFunc func(c core.IHTTPContext) error

func CreateUser(c core.IHTTPContext) error {
    var req CreateUserRequest
    if err := c.BindWithValidate(&req); err != nil {
        return err
    }
    // c.DB(), c.Cache(), repository.New[T](c) — all ambient-context
    return c.JSON(201, result)
}

Binding

BindOnly / BindWithValidate bind every request source (like echo), using struct tags:

SourceTagExample
Path paramsparam:"id"/users/:id
Query paramsquery:"q"?q=foo (all methods, incl. GET)
JSON bodyjson:"name"Content-Type: application/json
Form / multipartform:"name"application/x-www-form-urlencoded, multipart/form-data
XML bodyxml:"name"application/xml
go
type UpdateUserReq struct {
    ID    string  `param:"id" json:"-"`
    Q     string  `query:"q"`
    Name  *string `json:"name" form:"name"`
}

func UpdateUser(c core.IHTTPContext) error {
    var req UpdateUserReq
    if err := c.BindWithValidate(&req); err != nil { // binds path+query+body, then Valid(ctx)
        return err
    }
    // req.ID from :id, req.Q from ?q, req.Name from JSON/form body
    ...
}
  • Uploaded files: c.FormFile("avatar") / c.MultipartForm() (echo methods).
  • Malformed JSON → {code: "INVALID_JSON"} (400).
  • JSON type mismatch → {code: "INVALID_PARAMS", fields: {<field>: INVALID_TYPE}}.
  • Bad path/query/form value → {code: "INVALID_PARAMS"} (400).
  • BindOnly binds without validating; BindWithValidate = bind + req.Valid(ctx).

Form (application/x-www-form-urlencoded)

tag form: bind ให้เหมือน JSON ทุกอย่าง — struct เดียวรับได้ทั้งสอง content-type และ Valid() ตัวเดียวกันทำงานทั้งคู่:

go
type LoginRequest struct {
    Email    *string `json:"email" form:"email"`
    Password *string `json:"password" form:"password"`
    Remember *bool   `json:"remember" form:"remember"`
}

func (r *LoginRequest) Valid(ctx core.IContext) core.IError {
    v := valid.New(ctx)
    v.Str("email", r.Email).Required().Email()
    v.Str("password", r.Password).Required()

    return v.Error()
}

ค่าเดี่ยวๆ อ่านตรงๆ ก็ได้ด้วย c.FormValue("email") / c.FormValues() (แต่ไม่มี validation ให้)

File upload (multipart/form-data)

ไฟล์ ไม่ถูก bind ลง struct — ดึงเองด้วย c.FormFile ส่วน field ที่เป็น text ที่ส่งมาพร้อมกัน bind ตามปกติด้วย tag form::

go
type UploadRequest struct {
    Title *string `form:"title"`
    Kind  *string `form:"kind"`
}

func (r *UploadRequest) Valid(ctx core.IContext) core.IError {
    v := valid.New(ctx)
    v.Str("title", r.Title).Required().Length(1, 120)
    v.Str("kind", r.Kind).Required().In("ID_CARD", "PASSPORT")

    return v.Error()
}

const maxUploadBytes = 5 << 20 // 5MB

func Upload(c core.IHTTPContext) error {
    input := &UploadRequest{}
    if err := c.BindWithValidate(input); err != nil {
        return err
    }

    fh, err := c.FormFile("document")
    if err != nil {
        // ไฟล์ที่ไม่ได้แนบมาเป็นความผิดของ client ไม่ใช่ 500
        return errmsgs.BadRequest.WithMessage("document is required")
    }

    // ตรวจขนาดจาก header ก่อนเปิดอ่าน — ปฏิเสธได้โดยไม่ต้องแตะ byte แม้แต่ตัวเดียว
    if fh.Size > maxUploadBytes {
        return errmsgs.BadRequest.
            WithCode("FILE_TOO_LARGE").
            WithFields(map[string]any{"max_bytes": maxUploadBytes, "size": fh.Size})
    }

    src, oErr := fh.Open()
    if oErr != nil {
        return c.NewError(oErr, errmsgs.InternalServerError)
    }
    defer src.Close()

    // ส่ง reader ต่อให้ service — ไม่ io.ReadAll ไฟล์ 200MB จึงไม่กลายเป็น
    // 200MB ใน heap
    return service.NewDocService(c).Store(*input.Title, fh.Filename, src)
}

หลายไฟล์ในคำขอเดียว:

go
form, err := c.MultipartForm()
if err != nil {
    return errmsgs.BadRequest
}

for _, fh := range form.File["pages"] {
    ...
}

⚠️ fh.Filename และ content-type มาจาก client ห้ามเชื่อ — อย่าเอาไปต่อเป็น path เพื่อเขียนไฟล์ (../../etc/...) และอย่าใช้ตัดสินว่าไฟล์เป็นชนิดอะไร ถ้า ต้องรู้ชนิดจริงให้ sniff จากไม่กี่ byte แรก:

go
head := make([]byte, 512)
n, _ := src.Read(head)
mime := http.DetectContentType(head[:n])
_, _ = src.Seek(0, io.SeekStart)   // multipart.File เป็น io.Seeker

เก็บไฟล์ขึ้น S3 ดู Storage — ส่งต่อไป service อื่นดู Requester

body ที่ใหญ่กว่า memory limit ของ echo จะถูก spill ลง temp file ให้เอง และ core.DefaultReadTimeout ถูกตั้งไว้ที่ 5 นาที (ไม่ใช่ 30 วินาทีของ echo v5) พอดีสำหรับ upload ผ่านเน็ตมือถือ — ดู Timeout ของ server

⚠️ เพดาน body ทั้ง server คือ 10 MB route ที่รับไฟล์ต้องตั้งของตัวเอง: e.POST("/avatars", h.Upload, core.BodyLimit(50<<20)) — ดู ขนาดของ body

Pagination

go
opts := c.GetPageOptions()                          // limit/page/q/order_by
opts := c.GetPageOptionsWithAllowed("name", "created_at")  // order-by allowlist

order_by=name asc,created_at desc parses to ["name asc", "created_at desc"].

ค่าที่ไม่ใช่ชื่อ column ถูกทิ้งเสมอ ทั้งสองแบบ — order_by กลายเป็น SQL ORDER BY จริงๆ จึงไม่มีทางที่ client จะยัด subquery หรือ statement ที่สองเข้าไปได้ ส่วน allowlist เป็นชั้นที่สอง: จำกัดว่า sort ด้วย column ไหนได้บ้าง ใช้กับทุก endpoint ที่ client เข้าถึงได้ (ดู Pagination)

Returning errors

Return any core.IError from a handler and the framework renders it:

go
return errmsgs.NotFound
return core.New(400, "INVALID_STATE", "cannot transition")
return ctx.NewError(dbErr, errmsgs.DBError) // logs at 500, wraps cause

Migrating to echo v5

v2 ใช้ echo v5 แล้ว (จาก v4) ของเดิมส่วนใหญ่ไม่ต้องแก้ — IHTTPContext ประกาศ method ของ echo ไว้เองทั้งชุด c.JSON / c.Param / c.QueryParam / c.Bind / c.Request() ใช้ได้เหมือนเดิมทุกตัว

สิ่งที่ ต้องแก้ ในโค้ด service มี 3 จุด:

เดิม (v4)ใหม่ (v5)เพราะอะไร
func(ec echo.Context) string ใน WithTokenLookup / middleware ที่เขียนเองfunc(ec *echo.Context) stringecho.Context เป็น struct แล้ว
c.Response().Status / .Committed / .Sizeecho.UnwrapResponse(c.Response())Response() คืน http.ResponseWriter ตรงๆ
res.Flush() (SSE)if f, ok := res.(http.Flusher); ok { f.Flush() }เหตุผลเดียวกัน

c.Response().Header().Set(...) และ res.Write(...) ยังใช้ได้เหมือนเดิม (เป็น method ของ http.ResponseWriter อยู่แล้ว)

สิ่งที่ framework จัดการให้แล้ว ไม่ต้องทำอะไร:

  • e.Shutdown(ctx) ยังอยู่ — echo v5 ถอด Echo.Shutdown ออก แต่ core.Server มีให้เอง (พร้อม e.Serve(ctx, echo.StartConfig{...}) ถ้าอยากคุม listener/TLS เอง)
  • 404 / 405 ยังตอบเป็น {code, message} เหมือนเดิม — v5 เปลี่ยนไปใช้ sentinel ที่ไม่ใช่ *echo.HTTPError ถ้าไม่ดักจะกลายเป็น 500 (framework ดักให้แล้ว)
  • CORS default ยังเป็น * — v5 ถอด DefaultCORSConfig ทิ้งและ บังคับ ให้ระบุ origin อย่างน้อยหนึ่งค่า ไม่งั้น panic
  • HideBanner / HidePort ย้ายไปอยู่ใน StartConfigStartHTTPServer ตั้งให้แล้ว

Timeout ของ server

echo v5 ตั้ง ReadTimeout: 30s ให้ทุก server (v4 ไม่มี timeout เลย) ซึ่งสั้นเกินไป สำหรับ upload ผ่านเน็ตมือถือ, long-poll และ SSE — framework จึงตั้งชุดของตัวเองแทน:

ค่าdefaultกันอะไร
DefaultReadTimeout5 นาทีrequest ที่ส่งไม่จบสักที (header + body) — กว้างพอสำหรับ upload
DefaultReadHeaderTimeout20 วินาทีslow loris จริงๆ — header ไม่มีวันใช้เวลาเป็นนาที
DefaultIdleTimeout120 วินาทีkeep-alive ที่ค้างสะสมจน fd หมด
WriteTimeoutไม่ตั้ง— ดูเหตุผลข้างล่าง

ReadHeaderTimeout คือตัวที่ปิดช่อง slow-loris จริง ส่วน ReadTimeout ต้องกว้าง เพราะ upload ที่ถูกต้องก็ใช้เวลาเป็นนาทีได้ สองค่านี้ทำคนละหน้าที่กัน

WriteTimeout ไม่ถูกตั้งโดยตั้งใจ — มันเป็น deadline ครอบทั้งการตอบกลับ ค่าที่ใหญ่ พอสำหรับ download ช้าๆ ก็ใหญ่เกินกว่าจะกันอะไรได้ ส่วนค่าที่เล็กพอจะกันได้ ก็ตัด SSE กับ long-poll ทิ้งกลางคัน — ซึ่งคือสิ่งที่ ReadTimeout ถูกขยายมาเพื่อรองรับตั้งแต่ต้น service ที่ไม่มีทั้งสองอย่างตั้งเองได้

ตั้งค่าเองผ่าน HTTPOptions0 = ใช้ default, ค่าติดลบ = ปิด timeout นั้น:

go
e := core.NewHTTPServer(app, &core.HTTPOptions{
    ReadHeaderTimeout: 5 * time.Second,
    WriteTimeout:      30 * time.Second,  // API ที่ไม่มี SSE
    IdleTimeout:       -1,                // ปิด
})

หรือใช้ BeforeServeFunc เมื่อต้องแตะ setting ที่ HTTPOptions ไม่ได้ตั้งชื่อไว้ (ทำงานทีหลัง จึงทับค่าของ framework):

go
go e.Serve(ctx, echo.StartConfig{
    Address: ":8080",
    BeforeServeFunc: func(srv *http.Server) error {
        srv.MaxHeaderBytes = 1 << 20
        return nil
    },
})

ขนาดของ body

NewHTTPServer ใส่เพดานให้ทุก request ที่ core.DefaultBodyLimit = 10 MB — ไม่มีเพดาน แปลว่า request ที่ใหญ่ที่สุดที่ใครส่งมา คือ memory ที่ process ใช้ และ client เดียวจบ service ได้ทั้งตัว

เกินเพดานจะได้ 413 REQUEST_TOO_LARGE ในรูปแบบ error ปกติของ framework โดยเช็ค ทั้ง Content-Length ที่ประกาศมา และจำนวน byte ที่อ่านได้จริง — upload แบบ chunked ที่ไม่บอกขนาดจึงถูกตัดระหว่างสตรีม ไม่ใช่หลังจากมาถึงครบแล้ว

go
core.NewHTTPServer(app, &core.HTTPOptions{BodyLimit: 2 << 20})  // 2 MB ทั้ง server
core.NewHTTPServer(app, &core.HTTPOptions{BodyLimit: -1})       // ปิดเพดาน

route ที่รับไฟล์ตั้งเพดานของตัวเองได้ — สูงหรือต่ำกว่าของ server ก็ได้ เพราะค่าถูก อ่านตอนอ่าน body ซึ่งตอนนั้น middleware ชั้นในสุดตั้งค่าไว้แล้ว:

go
e.POST("/avatars", h.Upload, core.BodyLimit(50<<20))
files := e.Group("/files", core.BodyLimit(200<<20))

Best practices

  • handler บาง: bind+validate → เรียก service → คืน response ตรรกะทางธุรกิจอยู่ใน service ที่รับ IContext เพื่อให้ job และ consumer เรียกโค้ดชุดเดียวกันได้
  • BindWithValidate ไม่ใช่ Bind แล้วเช็คเอง — การตรวจที่กระจายอยู่ใน handler คือ การตรวจที่ลืมได้
  • return error อย่า log เองerror handler แปลง IError เป็น response, log และรายงาน Sentry ให้แล้ว ทำเพิ่มคือหนึ่งเหตุการณ์กลายเป็นสองใบ
  • อย่าคืน model ตรงๆ เมื่อมันมีคอลัมน์ภายใน (hash, flag, ราคาทุน) — ประกาศ response type แยก แล้วให้ compiler เป็นคนกันข้อมูลรั่ว
  • GetPageOptionsWithAllowed ทุกครั้งที่คอลัมน์เรียงมาจาก client
  • route ที่รับไฟล์ตั้ง core.BodyLimit ของตัวเอง แทนการยกเพดานทั้ง server
  • status ให้ตรงความหมาย: 201 เมื่อสร้าง, 204 เมื่อไม่มี body, 409 เมื่อชนสถานะ ไม่ใช่ 400 สำหรับทุกอย่างที่ไม่ใช่ 200
  • goroutine ที่อยู่นานกว่า request ต้องใช้ context ของตัวเอง (ctx.WithContext(bg)) ไม่งั้นงานถูกยกเลิกพร้อม response

Maintained by Passakon Puttasuwan & Dev Core Team.