ginx

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 25, 2026 License: MIT Imports: 10 Imported by: 0

README

ginx

CI

github.com/gtkit/ginx 提供一组与业务无关的通用 gin 请求参数处理工具, 聚焦"gin 原生没有优雅解、又跨服务复用"的请求侧痛点:

  • body 只能读一次 —— 读取后回填,使后续 ShouldBind 仍可完整读取
  • 原始 body 获取 —— 读取、缓存并回填完整字节,webhook 验签后仍可绑定
  • 按 Content-Type 解析 body —— application/jsonapplication/x-www-form-urlencoded
  • 只绑 body、不混入 query —— 规避 gin form 模式把 URL query 并入绑定;支持基于缓存的重复绑定
  • 单值 header / query 校验 —— 去空、查重,防御 HTTP 参数污染
  • 泛型类型化取值 —— query / 路由参数 / body 字段 / header 直接取 int、bool 等类型
  • Content-Type 白名单 —— 解析与绑定前快速拒绝,便于返回 415
  • 请求 body 硬限长 —— 基于 http.MaxBytesReader,防御超大 body 撑爆内存

本包不包含任何中间件、响应封装或业务语义;中间件、统一响应等请放在各自的包中。

为什么需要这个包?

gin 的 ShouldBindc.Queryc.Param 等原生 API 在简单场景下足够好用,但在跨服务、生产级别的 Go 服务中,几个反复出现的痛点并没有原生解决:

body 是一次性资源。 gin 的 body 读取流在第一个消费者消费后即关闭,后续调用 ShouldBind 读到空 body。本包在读取后自动回填:ParseBodyRawBody 读取后回填完整字节并按请求缓存,可重复调用、彼此自由组合;而 BindBody 直接消费 body 流(一次性语义),其后不应再假定 body 可读,需要重复绑定(如 webhook 先验签再绑定)请用 BindBodyCached

输入来源的信任边界需要显式划分。 gin 的 ShouldBind 在 form 模式下会合并 URL query 和 body,这在大多数安全敏感场景是不期望的——攻击者可以在 URL 中注入参数覆盖 body 中的字段。BindBody 通过临时清空 RawQuery 严格执行"只信 body、不信 query"原则。

类型安全不应止步于字符串。 gin 的路由参数和 query 值都是字符串,业务代码中充斥着 strconv.Atoistrconv.ParseBool 等样板转换。本包通过 Go 1.18+ 泛型提供 Query[T]Param[T]Body[T]Header[T],将字符串到目标类型的转换收敛在包内,调用方只需声明目标类型。

HTTP 参数污染是真实攻击面。 攻击者可以通过发送重复的 header 或 query 参数绕过单值校验逻辑。SingleValueHeaderSingleValueQuery 对此提供了显式防御。

body 大小的防御应该是分层、可组合的。 本包同时提供软上限(MaxBodyBytes,仅约束本包解析行为)和硬上限(LimitRequestBody,约束整个请求生命周期),二者互补而非替代,使用者可以根据安全等级灵活选择。

总之,ginx 不做 gin 已经做好的事,只填补 gin 原生 API 中那些"每个生产服务都要手写一遍"的空白——而且要求填得对、填得稳、填得零成本。

安装

go get github.com/gtkit/ginx

要求 Go 1.26+。

API

函数 / 类型 说明
ParseBody(c) BodySources 解析并按请求缓存 body,返回 Available / JSON / Form / Err
BodyString(c, field) string 从 body 取字段并转字符串(JSON 标量或 form 值)
BindBody(c, obj) error 只把 body 绑定到 obj,不信任 query
RawBody(c) ([]byte, error) 读取完整原始 body,缓存并回填(webhook 验签)
BindBodyCached(c, obj) error 基于缓存字节绑定,同一请求可重复调用
RequireContentType(c, types...) error Content-Type 白名单校验
SingleValueHeader(c, key) (string, error) 读取并校验单值 header
SingleValueQuery(c, key) (string, error) 读取并校验单值 query(防参数污染)
Query[T](c, key, def) T / Param[T](c, key, def) T 泛型类型化取值,缺失/非法回退默认值(宽松)
QueryStrict[T](c, key) (T, error) 严格类型化取单值 query,区分缺失/重复/非法,便于返回 400
QuerySlice[T](c, key) []T 重复 query 参数的类型化切片提取(如 ?id=1&id=2),无效值静默跳过
QuerySliceStrict[T](c, key) ([]T, error) 严格切片提取,遇任一非法值(含空值)报错,不静默跳过
Body[T](c, field, def) T 从 body(JSON / Form)取字段并类型化,通过 ParseBody 缓存
Header[T](c, key, def) T 读取单值 header 并类型化,重复/非法回退默认值
ContextValue[T](c, key) (T, bool) 从 gin.Context 键值存储中读取类型化值(中间件注入),类型不匹配返回 false
LimitRequestBody(c, maxBytes) 为 body 设置硬上限(http.MaxBytesReader
IsRequestBodyTooLarge(err) bool 判断 err 是否因 body 超过硬上限产生
MaxBodyBytes ParseBody 的解析软上限(8 KiB)
ContentTypeJSON / ContentTypeForm body Content-Type 常量,供 RequireContentType 等使用
ErrDuplicateQuery / ErrQueryMissing / ErrInvalidQueryValue QueryStrict / QuerySliceStrict 哨兵错误
ErrDuplicateHeader / ErrInvalidHeaderValue / ErrInvalidBindContext header / 绑定哨兵错误
ErrNoBody / ErrBodyTooLarge / ErrUnsupportedContentType / ErrMalformedBody ParseBody 失败原因哨兵错误(经 BodySources.Err 透出)

用法

解析 body(多来源、按请求缓存)
src := ginx.ParseBody(c)
if !src.Available {
    // 失败原因可判定:ErrNoBody / ErrBodyTooLarge / ErrUnsupportedContentType / ErrMalformedBody
    if errors.Is(src.Err, ginx.ErrBodyTooLarge) {
        // body 超过 MaxBodyBytes 软上限
    }
    return
}
name, _ := src.JSON["name"].(string) // JSON body(数字为 json.Number,原文不丢精度)
page := src.Form.Get("page")         // form body
只绑 body + 硬限长
ginx.LimitRequestBody(c, 1<<20) // 1 MiB 硬上限

var req CreateOrderReq
if err := ginx.BindBody(c, &req); err != nil {
    if ginx.IsRequestBodyTooLarge(err) {
        c.AbortWithStatus(http.StatusRequestEntityTooLarge) // 413
        return
    }
    c.AbortWithStatus(http.StatusBadRequest)
    return
}
webhook 验签:原始 body + 重复绑定
ginx.LimitRequestBody(c, 1<<20) // RawBody 不设软上限,务必配合硬限长

raw, err := ginx.RawBody(c) // 完整原始字节,读后回填并缓存
if err != nil { /* ... */ }
verifySignature(raw, c.GetHeader("X-Signature"))

var notify PayNotify
_ = ginx.BindBodyCached(c, &notify) // 可重复绑定,每次都是完整 body
类型化取参数
page := ginx.Query(c, "page", 1)        // ?page=3 -> 3;缺失/非法 -> 1
dry  := ginx.Query(c, "dry_run", false)
id   := ginx.Param(c, "id", int64(0))   // 路由 /user/:id

uid, err := ginx.SingleValueQuery(c, "uid") // ?uid=1&uid=2 -> ErrDuplicateQuery

Query[T] 是宽松便利函数:同名参数出现多个值(?id=1&id=2)时静默取第一个,缺失/非法回退默认值,不报错。 需要严格区分缺失/重复/非法、或防御 HTTP 参数污染时,请改用 QueryStrictSingleValueQuery

需要明确报错(区分缺失 / 重复 / 非法,便于返回 400)时用严格版:

id, err := ginx.QueryStrict[int64](c, "id")
switch {
case errors.Is(err, ginx.ErrQueryMissing):      // 参数缺失
case errors.Is(err, ginx.ErrDuplicateQuery):    // ?id=1&id=2 参数污染
case errors.Is(err, ginx.ErrInvalidQueryValue): // 无法解析为 int64
}

ids, err := ginx.QuerySliceStrict[int64](c, "id") // ?id=1&id=abc -> ErrInvalidQueryValue(不静默跳过)
重复 query 参数的类型化切片
ids := ginx.QuerySlice[int64](c, "id")    // ?id=1&id=2&id=3 -> []int64{1,2,3}
names := ginx.QuerySlice[string](c, "name") // ?name=a&name=b -> ["a","b"]

解析失败的值静默跳过(如 ?id=1&id=abc&id=3 -> [1, 3])。

从 Context 取类型化值
// 中间件中注入
c.Set("user_id", uid)
c.Set("role", "admin")

// handler 中安全提取
userID, ok := ginx.ContextValue[int64](c, "user_id")
if !ok {
    // 未登录或类型异常
}
role, _ := ginx.ContextValue[string](c, "role")

消除逐次类型断言的样板代码,key 不存在或类型不匹配时返回 (zero, false)。

Content-Type 白名单
if err := ginx.RequireContentType(c, ginx.ContentTypeJSON); err != nil {
    c.AbortWithStatus(http.StatusUnsupportedMediaType) // 415
    return
}
单值 header
token, err := ginx.SingleValueHeader(c, "X-Token")
switch {
case errors.Is(err, ginx.ErrDuplicateHeader):
    // 同名头出现多个值
case errors.Is(err, ginx.ErrInvalidHeaderValue):
    // 值含逗号等非法形式
}
类型化取 body 字段
count := ginx.Body(c, "count", 0)           // {"count":3} → 3;form 同理
name  := ginx.Body(c, "name", "")            // {"name":"alice"} → "alice"
ratio := ginx.Body(c, "ratio", 0.0)          // {"ratio":1.5} → 1.5
ok    := ginx.Body(c, "ok", false)           // {"ok":true} → true

缺失、类型不匹配、body 不可解析时一律回退默认值,不 panic。 通过 ParseBody 缓存,与 BodyString 混用也只解析一次 body。

类型化取 header
page := ginx.Header(c, "X-Page", int64(0))  // X-Page: 42 → 42;缺失/重复 → 0
flag := ginx.Header(c, "X-Flag", false)      // X-Flag: true → true

继承 SingleValueHeader 的校验语义(去空/查重/防逗号),异常时回退默认值。 需要严格校验时请使用 SingleValueHeader

软限长 vs 硬限长

  • MaxBodyBytes(软):仅约束 ParseBody 自身读取/解析的长度,超过则返回 Available=false不影响下游 ShouldBind
  • LimitRequestBody(硬):包装 c.Request.Body,对整个请求生命周期内的任何读取生效,超限即返回 *http.MaxBytesError

二者互补:ParseBody 用于"轻量探取 body 中的字段",LimitRequestBody 用于"防御性地封顶请求体"。

使用约束

  • 并发:与 gin.Context 一致,本包函数会替换 c.Request.Body、临时修改 URL.RawQuery,仅限在处理该请求的 handler goroutine 内调用
  • multipart/form-data 不支持:multipart 通常携带文件、体积大,与按 MaxBodyBytes 轻量探取的定位冲突
  • BindBody 一次性语义:绑定会消费 body 流,同一请求内二次调用读到空 body;如需先探取字段再绑定,先 ParseBody(会回填 body)再 BindBody
  • JSON 数字:以 json.Number 原文承载,BodyString 对任意大小的整数不丢精度(如雪花 ID)

Documentation

Overview

Package ginx 提供与业务无关的通用 gin 请求参数处理能力:解决 gin 请求 body 只能读取一次的痛点 (读取后回填,使后续 ShouldBind 仍可读)、按 Content-Type 解析 body、单值 header 校验、只绑 body 不混入 query,以及用 http.MaxBytesReader 对 body 设硬上限等。

这些函数不含任何业务语义,按请求在 gin.Context 上缓存解析结果,适合作为各服务的通用请求处理底座。

支持的 body 类型为 application/json 与 application/x-www-form-urlencoded;不支持 multipart/form-data(通常携带文件、体积大,与按 MaxBodyBytes 轻量探取的定位冲突)。

并发安全:与 gin.Context 本身一致。本包函数会替换 c.Request.Body、临时修改 URL.RawQuery, 必须在处理该请求的 handler goroutine 内调用,不得跨 goroutine 并发操作同一个 Context。

Index

Examples

Constants

View Source
const (
	// ContentTypeJSON 即 application/json。
	ContentTypeJSON = "application/json"
	// ContentTypeForm 即 application/x-www-form-urlencoded。
	ContentTypeForm = "application/x-www-form-urlencoded"
)

本包支持解析的两种 body Content-Type,供调用方传给 RequireContentType 等,避免手写字符串。

View Source
const MaxBodyBytes = 8 * 1024

MaxBodyBytes 是 ParseBody 允许读取并解析的请求 body 软上限,超过则视为不可解析、返回空结果。 它只约束本包的解析行为;如需对整个请求生命周期施加硬上限,请用 LimitRequestBody。

View Source
const Version = "v1.2.0"

Version 是 ginx 的当前版本号。

Variables

View Source
var (
	// ErrDuplicateHeader 表示同一 header 出现了多个非空值。
	ErrDuplicateHeader = errors.New("header provided multiple times")
	// ErrInvalidHeaderValue 表示 header 的值非法(如含逗号的多值形式)。
	ErrInvalidHeaderValue = errors.New("header contains invalid value")
	// ErrInvalidBindContext 表示 BindBody 收到的 context / Request / URL 为 nil,无法绑定。
	ErrInvalidBindContext = errors.New("invalid bind body context")
	// ErrNoBody 表示请求没有可解析的 body:context 或 body 为 nil,或方法为 GET/HEAD。
	ErrNoBody = errors.New("request has no parsable body")
	// ErrBodyTooLarge 表示请求 body 长度超过 MaxBodyBytes 软上限,ParseBody 拒绝解析。
	ErrBodyTooLarge = errors.New("request body exceeds MaxBodyBytes")
	// ErrUnsupportedContentType 表示 Content-Type 不在 ParseBody 支持的类型范围内。
	ErrUnsupportedContentType = errors.New("unsupported content type")
	// ErrMalformedBody 表示 body 语法非法:JSON 语法错误、JSON 尾部存在多余数据或 form 编码非法,
	// 底层解析错误可经 errors.As / Unwrap 获取。
	ErrMalformedBody = errors.New("malformed request body")
)
View Source
var (
	// ErrDuplicateQuery 表示同名 query 参数出现了多个非空值(HTTP 参数污染)。
	ErrDuplicateQuery = errors.New("query parameter provided multiple times")
	// ErrQueryMissing 表示严格取值时 query 参数缺失或去空白后为空。
	ErrQueryMissing = errors.New("query parameter missing")
	// ErrInvalidQueryValue 表示严格取值时 query 值无法解析为目标类型,错误中附带该值。
	ErrInvalidQueryValue = errors.New("query parameter has invalid value")
)

Functions

func BindBody

func BindBody(c *gin.Context, obj any) error

BindBody 仅将请求 body 绑定到 obj。绑定前临时清空 URL.RawQuery,避免 gin 在 form 模式下把 query 参数一并并入绑定结果——确保只信任 body、不信任 query;绑定结束后恢复 RawQuery。 context、Request 或 URL 为 nil 时返回 ErrInvalidBindContext。

绑定会消费 body 流(一次性语义):同一请求内第二次调用将读到空 body。如需先探取字段再绑定, 先调用 ParseBody(它会回填 body)、再调用本函数。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", `{"token":"body-token"}`)
	c.Request.URL.RawQuery = "token=query-token" // query 不会被并入绑定结果

	var req struct {
		Token string `json:"token" form:"token"`
	}
	if err := ginx.BindBody(c, &req); err != nil {
		fmt.Println("bind:", err)
		return
	}
	fmt.Println(req.Token)
}
Output:
body-token

func BindBodyCached

func BindBodyCached(c *gin.Context, obj any) error

BindBodyCached 基于 RawBody 缓存的原始字节将 body 绑定到 obj:同一请求内可重复调用,每次绑定 都读到完整 body,并继承 BindBody 的只信 body、不信 query 语义。首次调用会完整读取并缓存 body, 长度防御同样依赖 LimitRequestBody。context、Request 或 URL 为 nil 时返回 ErrInvalidBindContext。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", `{"token":"t1","scene":"s1"}`)

	var a struct {
		Token string `json:"token"`
	}
	var b struct {
		Scene string `json:"scene"`
	}
	_ = ginx.BindBodyCached(c, &a) // 同一请求可重复绑定
	_ = ginx.BindBodyCached(c, &b)
	fmt.Println(a.Token, b.Scene)
}
Output:
t1 s1

func Body added in v1.1.2

func Body[T Scalar](c *gin.Context, field string, def T) T

Body 从请求 body 中读取指定 field 并解析为 T。按 JSON → Form 的顺序查找 field。 值缺失、类型不匹配、body 不可解析(含 GET/HEAD、Content-Type 不匹配等)时返回 def, 不 panic、不返回错误。需要严格校验时请先 ParseBody 再自行处理。

本函数通过 ParseBody 缓存,同一请求内多次调用或与 BodyString 混用只解析一次 body。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", `{"count":3}`)

	fmt.Println(ginx.Body(c, "count", 0))
}
Output:
3

func BodyString

func BodyString(c *gin.Context, field string) string

BodyString 从请求 body 中读取指定字段并转为字符串:JSON body 支持 string/bool/number/Stringer 等标量类型,form body 取对应键值;body 不可解析时返回空串。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/x-www-form-urlencoded", "token=abc123")

	fmt.Println(ginx.BodyString(c, "token"))
}
Output:
abc123

func ContextValue added in v1.1.2

func ContextValue[T any](c *gin.Context, key string) (val T, ok bool)

ContextValue 从 gin.Context 的键值存储中读取 key 关联的值并以类型 T 返回。 常用于中间件注入的值(如用户 ID、角色等);key 不存在或类型不匹配时返回 (zero, false)。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", "{}")
	c.Set("user_id", int64(42))

	uid, ok := ginx.ContextValue[int64](c, "user_id")
	fmt.Println(uid, ok)
}
Output:
42 true
func Header[T Scalar](c *gin.Context, key string, def T) T

Header 读取单值 header 并解析为 T。去空白后忽略空值,值含逗号视为非法、多个非空值视为重复, 均返回 def。需要严格校验时请使用 SingleValueHeader。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", "{}")
	c.Request.Header.Set("X-Page", "42")

	fmt.Println(ginx.Header(c, "X-Page", int64(0)))
}
Output:
42

func IsRequestBodyTooLarge

func IsRequestBodyTooLarge(err error) bool

IsRequestBodyTooLarge 判断 err 链中是否含 *http.MaxBytesError,即请求 body 是否超过了 LimitRequestBody 设定的硬上限,便于上游据此返回 413 Request Entity Too Large。

func LimitRequestBody

func LimitRequestBody(c *gin.Context, maxBytes int64)

LimitRequestBody 用 http.MaxBytesReader 为请求 body 设置硬上限 maxBytes:调用后,任何对 body 的读取(包括下游 c.ShouldBind / BindBody)一旦累计超过 maxBytes,都会立即返回 *http.MaxBytesError 并停止继续读入内存,从根上防御超大 body 撑爆内存,必要时还会关闭连接。 这与软上限 MaxBodyBytes 不同:MaxBodyBytes 仅用于本包解析时按长度跳过、不约束下游读取,而本函数的 限制对整个请求生命周期生效。应在读取或绑定 body 之前调用(如路由进入处或 handler 开头)。 maxBytes <= 0 或无 body 时直接返回、不设限。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", `{"data":"`+strings.Repeat("a", 64)+`"}`)
	ginx.LimitRequestBody(c, 16) // 16 字节硬上限

	var req struct {
		Data string `json:"data"`
	}
	err := ginx.BindBody(c, &req)
	fmt.Println(ginx.IsRequestBodyTooLarge(err))
}
Output:
true

func Param

func Param[T Scalar](c *gin.Context, key string, def T) T

Param 从路由参数(如 /user/:id 的 id)读取 key 并解析为 T,缺失与解析失败的回退语义同 Query。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", "{}")
	c.Params = gin.Params{{Key: "id", Value: "42"}} // 路由 /user/:id

	fmt.Println(ginx.Param(c, "id", int64(0)))
}
Output:
42

func Query

func Query[T Scalar](c *gin.Context, key string, def T) T

Query 从 query 参数读取 key 并解析为 T:值缺失、去空白后为空、或解析失败时返回 def, 不 panic、不返回错误。需要严格校验时请使用 gin 的 ShouldBindQuery 配合 binding tag。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", "{}")
	c.Request.URL.RawQuery = "page=3&size=abc"

	fmt.Println(ginx.Query(c, "page", 1), ginx.Query(c, "size", 20), ginx.Query(c, "dry_run", false))
}
Output:
3 20 false

func QuerySlice added in v1.1.2

func QuerySlice[T Scalar](c *gin.Context, key string) []T

QuerySlice 从 query 参数读取 key 的多个值并返回类型 T 的切片。 例如 ?id=1&id=2&id=3 可经 QuerySlice[int64](c, "id") 取得 []int64{1,2,3}。 非空字符串总是有效;非 string 类型的值中解析失败的条目被静默跳过。 key 不存在、context 为 nil 或无有效值时返回 nil。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", "{}")
	c.Request.URL.RawQuery = "id=1&id=2&id=3"

	ids := ginx.QuerySlice[int64](c, "id")
	fmt.Println(ids[0], ids[1], ids[2])
}
Output:
1 2 3

func QuerySliceStrict added in v1.2.0

func QuerySliceStrict[T Scalar](c *gin.Context, key string) ([]T, error)

QuerySliceStrict 严格版 QuerySlice:?id=1&id=2&id=3 取得 []T,遇到任一无法解析为 T 的值 (含去空白后为空的值)立即返回附带该值的 ErrInvalidQueryValue,不静默跳过。适合接口入参校验。 key 不存在、context 为 nil 时返回 (nil, nil),由调用方按 len 判断是否缺失。

func QueryStrict added in v1.2.0

func QueryStrict[T Scalar](c *gin.Context, key string) (T, error)

QueryStrict 严格读取并类型化单值 query 参数:复用 SingleValueQuery 的查重语义,同名参数出现 多个非空值时返回 ErrDuplicateQuery(防 HTTP 参数污染);缺失或去空白后为空返回 ErrQueryMissing; 解析为 T 失败返回附带原值的 ErrInvalidQueryValue。三种情形均可用 errors.Is 判定,便于上层返回 400。 需要宽松取值(缺失/非法静默回退默认值)时用 Query。

func RawBody

func RawBody(c *gin.Context) ([]byte, error)

RawBody 读取并返回完整的原始请求 body:读取后回填 body(后续 BindBody / ShouldBind / ParseBody 仍可完整读取),并按请求缓存,同一请求内重复调用不再读流。适合 webhook 验签等先取原始字节、 再绑定结构体的场景。

本函数不设长度上限:请配合 LimitRequestBody 使用,超过硬上限时返回的错误可用 IsRequestBodyTooLarge 判定。context、Request 或 body 为 nil 时返回 ErrNoBody; 读取失败时如实返回错误且不缓存。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	// webhook 验签场景:先取原始字节算签名,再绑定结构体,两者不互斥
	c := newPostContext("application/json", `{"event":"pay.success"}`)

	raw, err := ginx.RawBody(c)
	if err != nil {
		fmt.Println("read:", err)
		return
	}
	// verifySignature(raw, c.GetHeader("X-Signature")) ...

	var notify struct {
		Event string `json:"event"`
	}
	if err := ginx.BindBody(c, &notify); err != nil {
		fmt.Println("bind:", err)
		return
	}
	fmt.Println(len(raw) > 0, notify.Event)
}
Output:
true pay.success

func RequireContentType

func RequireContentType(c *gin.Context, types ...string) error

RequireContentType 校验请求的 Content-Type(忽略参数与大小写)是否在 types 白名单内:命中返回 nil;不命中返回可用 errors.Is 判定 ErrUnsupportedContentType 的错误并附实际类型,便于上游返回 415 Unsupported Media Type。types 为空时不做约束、返回 nil;context 或 Request 为 nil 视为 无 Content-Type。

Example
package main

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("text/plain", "hello")

	err := ginx.RequireContentType(c, "application/json", "application/x-www-form-urlencoded")
	fmt.Println(errors.Is(err, ginx.ErrUnsupportedContentType))
}
Output:
true

func SingleValueHeader

func SingleValueHeader(c *gin.Context, headerKey string) (string, error)

SingleValueHeader 读取并校验单值 header:去空白后忽略空值,值含逗号视为非法 (ErrInvalidHeaderValue),出现多个非空值视为重复(ErrDuplicateHeader)。 恰好一个非空值时返回该值,全部为空时返回空串且无错误。

Example
package main

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", "{}")
	c.Request.Header.Add("X-Token", "a")
	c.Request.Header.Add("X-Token", "b")

	_, err := ginx.SingleValueHeader(c, "X-Token")
	fmt.Println(errors.Is(err, ginx.ErrDuplicateHeader))
}
Output:
true

func SingleValueQuery

func SingleValueQuery(c *gin.Context, key string) (string, error)

SingleValueQuery 读取并校验单值 query 参数:去空白后忽略空值,出现多个非空值视为参数污染、 返回 ErrDuplicateQuery。恰好一个非空值时返回该值,全部为空时返回空串且无错误。query 值中的 逗号视为合法(逗号合并是 header 的语义,query 没有)。context、Request 或 URL 为 nil 时 返回空串且无错误。

Example
package main

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", "{}")
	c.Request.URL.RawQuery = "id=1&id=2" // HTTP 参数污染

	_, err := ginx.SingleValueQuery(c, "id")
	fmt.Println(errors.Is(err, ginx.ErrDuplicateQuery))
}
Output:
true

Types

type BodySources

type BodySources struct {
	Available bool
	JSON      map[string]any
	Form      url.Values
	Err       error
}

BodySources 承载一次请求 body 的解析结果:Available 表示是否解析成功,JSON 与 Form 分别对应 application/json 与 application/x-www-form-urlencoded 两种 body 的解析产物(另一种为 nil)。 JSON 中的数字以 json.Number 承载(UseNumber 解码),原样保留 body 中的字面量。

Err 在 Available 为 false 时说明失败原因,可用 errors.Is 判定 ErrNoBody、ErrBodyTooLarge、 ErrUnsupportedContentType、ErrMalformedBody(body 读取失败时为相应读取错误的包装); Available 为 true 时恒为 nil。结果按请求缓存,同一请求内重复调用返回相同的 Err。

func ParseBody

func ParseBody(c *gin.Context) (sources BodySources)

ParseBody 解析请求 body 并按请求缓存结果(同一请求内多次调用只读取/解析一次 body)。命中缓存直接 返回;否则经跳过判断(nil body、GET/HEAD、ContentLength 超 MaxBodyBytes)、限长读取并回填 body 后 按 Content-Type 解析。任一环节失败或不支持的类型均返回 Available 为 false 的结果, 失败原因经 Err 字段透出(见 BodySources)。

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("application/json", `{"name":"alice"}`)

	src := ginx.ParseBody(c)
	fmt.Println(src.Available, src.JSON["name"])
}
Output:
true alice
Example (FailureReason)
package main

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/gtkit/ginx"
)

// newPostContext 构造一个带 body 的 POST 请求测试上下文,仅用于 Example。
func newPostContext(contentType, body string) *gin.Context {
	gin.SetMode(gin.TestMode)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	c.Request = req
	return c
}

func main() {
	c := newPostContext("text/plain", "hello")

	src := ginx.ParseBody(c)
	fmt.Println(src.Available, errors.Is(src.Err, ginx.ErrUnsupportedContentType))
}
Output:
false true

type Scalar

type Scalar interface {
	string | int | int64 | uint64 | bool | float64
}

Scalar 是 Query 与 Param 支持的取值类型集合。约束使用精确类型(不带 ~)以保证零反射解析; 命名类型(如 type ID int64)请按底层类型取值后自行转换。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL