Skip to content

kanata996/chix

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

84 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

chix

Go Reference CI Codecov

chix 是一个 chi-first、同时保持 net/http 兼容的轻量 HTTP 边界库,用来把 JSON API 的公开行为收敛为一套稳定、可预期的约定。

特性

  • 统一成功响应与错误响应的 JSON 结构
  • 集中映射、观测并写回显式边界错误,错误观测关联 request_id
  • 兼容 go-chi 与标准 http.Handler 中间件链
  • 兼容原生 net/http / ServeMux,不依赖第三方框架运行时
  • 不接管 router,不绑定项目结构,也不引入新的框架运行时
  • 提供请求体解码、query 解码和输入校验封装
  • 默认采用 fail-closed 策略,响应一旦开始写出就不再改写

适用场景

  • 你在使用 chi,想统一成功响应、错误响应和请求校验行为
  • 你希望 middleware、handler、service 继续沿用标准 net/http 风格
  • 你想把内部错误稳定地映射为公开错误,而不是在深层直接写 HTTP 响应

边界

chix 负责这些事:

  • 统一成功响应和显式边界错误的 JSON 契约
  • Error(...) / WriteError(...) 送进来的错误集中映射、观测和写回
  • 提供请求解码、查询参数解码和输入校验封装

chix 不负责这些事:

  • 接管或包装你的 router
  • 接管系统级 panic recovery
  • 提供 body / query / header / path 的全量绑定运行时
  • 承诺所有 500 都返回统一 JSON,panic 或外层 recoverer 的行为不属于 chix 契约

核心约定

  1. 在外层安装一次 HandleErrors(...)
  2. Decode* / Validate 处理请求输入
  3. Error(...) 把失败送进统一错误出口
  4. Respond / RespondWithMeta / RespondEmpty 显式写回成功结果
  • HandleErrors(...) 是统一错误处理的唯一中心
  • Error(...) 只负责“送错”,不负责立即写回
  • Respond* 只负责成功响应
  • panic 不属于 chix 的默认职责,应由外层 recoverer 处理
  • 如果响应已经开始写回,chix 不会为了“统一格式”去改写已经发出的结果

如果你只记四组名字,记住这些就够了:

  • HandleErrors
  • Error
  • Respond*
  • Decode* / Validate

安装

chix 当前要求 Go 1.24+

go get github.com/kanata996/chix

快速开始

下面是推荐的 chi 接法:外层 recoverer 负责 panic,chix 负责显式错误的统一处理。

package main

import (
	"errors"
	"log"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/kanata996/chix"
)

var errUserNotFound = errors.New("user not found")
const codeUserNotFound = "user_not_found"

func mapUserError(err error) *chix.HTTPError {
	if errors.Is(err, errUserNotFound) {
		return chix.NotFound(codeUserNotFound, "user not found")
	}
	return nil
}

func main() {
	r := chi.NewRouter()
	r.Use(middleware.Recoverer)
	r.Use(chix.HandleErrors())
	r.NotFound(chix.NotFoundHandler())
	r.MethodNotAllowed(chix.MethodNotAllowedHandler())

	r.Route("/users", func(r chi.Router) {
		r.Use(chix.UseErrorMappers(mapUserError))

		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			var query struct {
				ID string `query:"id"`
			}

			if chix.Error(r, chix.DecodeQuery(r, &query)) {
				return
			}
			if query.ID == "missing" {
				chix.Error(r, errUserNotFound)
				return
			}

			if err := chix.Respond(w, http.StatusOK, map[string]any{
				"id": query.ID,
			}); err != nil {
				chix.Error(r, err)
			}
		})
	})

	log.Fatal(http.ListenAndServe(":8080", r))
}

接入建议:

  • chi 项目把 router 级 recoverer 放在 HandleErrors(...) 外层,例如 middleware.Recoverer
  • 通常只在 router 的 composition root 安装一次 HandleErrors(...)
  • 各 feature 可在各自的 route group 上用 UseErrorMappers(...) 注入业务错误映射
  • handler 和普通 middleware 都使用 if chix.Error(r, err) { return }
  • chiNotFound / MethodNotAllowed 直接接 chix.NotFoundHandler() / chix.MethodNotAllowedHandler()
  • chix 生成的兜底 request id 只保证错误链路内稳定,不替代外层 access log / tracing 的 request id 策略
  • chix 支持设置自定义 request id,通过 chix.SetRequestID(...) 注入给 chix

如果你继续使用标准库,也有 ServeMux 版本示例:

响应契约

chix 对外只定义三种由自身写出的响应形态:

  1. 带响应体的成功响应
  2. 带响应体的错误响应
  3. 不带响应体的空响应

成功响应:

{
  "data": {},
  "meta": {}
}

错误响应:

{
  "error": {
    "code": "invalid_request",
    "message": "request contains invalid fields",
    "details": []
  }
}

约束:

  • data 必须存在,且不能编码成 null
  • meta 没内容时可省略,如果存在,必须编码成 JSON object
  • error.code 必须是稳定机器码
  • chix 通过 errcode 子包公开了一组常见 code 常量,见下文;业务错误也可以继续使用你自己定义的稳定字符串常量
  • error.message 必须是可安全公开的文案
  • error.details 始终存在,没有内容时输出 []
  • Respond / RespondWithMeta 只能用于允许携带响应体的成功状态码
  • RespondEmpty 用于不带响应体的成功响应

这个契约只覆盖下面这些入口写出的响应:

  • Respond(...)
  • RespondWithMeta(...)
  • RespondEmpty(...)
  • HandleErrors(...)
  • WriteError(...)

如果请求因 panic 或 router / framework 级 recoverer 返回 500,是否带响应体、响应体长什么样,都不属于 chix 契约。

错误处理与映射

chix 的错误处理模型不是“在各处直接写错误响应”,而是“让错误在链路中短路返回,并在边界统一定型”。

核心 API:

type Option func(*config)

func HandleErrors(opts ...Option) func(http.Handler) http.Handler
func WriteError(w http.ResponseWriter, r *http.Request, err error, opts ...Option) bool
func Error(r *http.Request, err error) bool

func NotFoundHandler() http.HandlerFunc
func MethodNotAllowedHandler() http.HandlerFunc

使用约束:

  • Error(...) 依赖当前请求已经安装 HandleErrors(...)
  • 如果没有安装 middleware,却仍然需要一次性的立即写错,应显式使用 WriteError(...)
  • Error(...) 传入 nil 会返回 false
  • 如果在没有 HandleErrors(...) 的请求里调用 Error(...),这会被视为配置错误并触发 panic

公开错误类型:

type HTTPError struct {
	// unexported fields
}

func NewHTTPError(status int, code, message string, details ...any) *HTTPError
func BadRequest(code, message string, details ...any) *HTTPError
func Unauthorized(code, message string, details ...any) *HTTPError
func Forbidden(code, message string, details ...any) *HTTPError
func NotFound(code, message string, details ...any) *HTTPError
func MethodNotAllowed(code, message string, details ...any) *HTTPError
func Conflict(code, message string, details ...any) *HTTPError
func Gone(code, message string, details ...any) *HTTPError
func UnprocessableEntity(code, message string, details ...any) *HTTPError
func TooManyRequests(code, message string, details ...any) *HTTPError

func (e *HTTPError) Error() string
func (e *HTTPError) Status() int
func (e *HTTPError) Code() string
func (e *HTTPError) Message() string
func (e *HTTPError) Details() []any

常见公开 code 常量统一放在 errcode 子包:

import "github.com/kanata996/chix/errcode"
  • errcode 可复用的公共 code,主要覆盖协议/边界错误、请求解码与校验错误,以及少量跨业务的泛语义错误
  • 为避免过早把完整枚举承诺成稳定契约,README 不再列出全部常量,请以当前版本的 errcode 包定义为准
  • 这些常量只是便捷入口,NewHTTPError(...)NotFound(...) 等 helper 仍然接受任意自定义稳定字符串
  • 业务错误建议自己定义常量,只有像 resource_not_found 这类确实跨业务复用的语义,才值得回收到 errcode

约束:

  • 4xx 表达客户端侧失败
  • 5xx 表达服务端侧失败
  • NewHTTPError(...) 会把非法状态码保守规范化为 500
  • 常见 4xx 可以直接用 helper:BadRequest(...)Unauthorized(...)Forbidden(...)NotFound(...)MethodNotAllowed(...)Conflict(...)Gone(...)UnprocessableEntity(...)TooManyRequests(...)
  • code / message 为空时,会按状态码族补默认值
  • 未识别错误默认回退为 500 internal_error

错误映射与观测:

type ErrorMapper func(err error) *HTTPError

func WithErrorMappers(mappers ...ErrorMapper) Option
func UseErrorMappers(mappers ...ErrorMapper) func(http.Handler) http.Handler

type ErrorReport struct {
	Request         *http.Request
	Error           error
	PublicError     *HTTPError
	Stage           string
	RequestID       string
	ResponseStarted bool
}

type ErrorReporter func(ErrorReport)

func WithErrorReporter(reporter ErrorReporter) Option
func SetRequestID(r *http.Request, id string) *http.Request
  • ErrorReport.Stage 是内部观测字段,当前稳定值包括 decodevalidateroutingprocessingwrite_response
  • ErrorReport.RequestID 表示当前错误观测实际使用的 request id;优先使用 SetRequestID(...) 注入的值
  • 如果调用方没有显式设置 request id,chix 会在第一次发送错误观测时自动生成一个,并在同一次错误处理链路里复用
  • processing 表示请求已进入业务处理链,覆盖 handler、service、repository 这段内部处理范围
  • 当错误响应在序列化或写回阶段再次失败时,chix 会额外发送一条 Stage == "write_response" 的内部观测,并保守回退为 500 internal_error
  • 默认 stderr logger 会记录 5xx,并追加当前 goroutine 的 stack;401/403 会记录为安全审计日志;普通 4xx 默认不单独记错误日志
  • WithErrorReporter(nil) 会关闭 chix 的错误观测

RequestID 接入建议:

  • 默认不需要额外安装 request id middleware;缺失时 chix 会在错误路径内部自动生成并复用 request id
  • 如果项目已经有 request id 机制,可在应用层取出该值,再通过 chix.SetRequestID(...) 注入给 chix
  • chi 项目复用 middleware.RequestID 的接法见文末“复用 chi 的 Request ID”
  • net/http 项目可在自己的 middleware 里生成 request id,然后把返回的 *http.Request 继续向下传递
  • 不建议让 chix 根包直接依赖某个框架的 request id context 约定
  • RequestID 不是 TraceID,两者不应混用

推荐做法:

  • 把内部业务错误通过 ErrorMapper 映射成公开边界错误
  • 在启动期通过 WithErrorMappers(...) 注册全局 mapper
  • 通过 UseErrorMappers(...) 给 feature / route group 注入局部 mapper
  • 更内层的 UseErrorMappers(...) 优先级高于更外层的 UseErrorMappers(...)
  • UseErrorMappers(...) 的优先级高于 WithErrorMappers(...)
  • 把鉴权失败、限流、参数错误这类边界错误优先写成 chix.Unauthorized(...)chix.TooManyRequests(...)chix.BadRequest(...) 这类 helper;不常见状态再回退到 chix.NewHTTPError(...)

在 feature-first 项目里,更推荐让 feature 在挂载路由时就注入 mapper,而不是在 composition root 里手动维护一长串集中注册。

成功响应 API

func Respond(w http.ResponseWriter, status int, data any) error
func RespondWithMeta(w http.ResponseWriter, status int, data any, meta any) error
func RespondEmpty(w http.ResponseWriter, status int) error
  • Respond(...) 返回 {"data": ...}
  • RespondWithMeta(...) 返回 {"data": ..., "meta": ...}
  • RespondEmpty(...) 只写状态码,不写响应体
  • RespondWithMeta(...)meta 如果存在,必须编码成 JSON object

请求解码与校验

根包的请求输入 helper 是对 reqx 子包的一层轻量封装。推荐应用边界代码优先使用 chix.Decode* / chix.Validate,这样可以把依赖面收敛在 chix 根包。

公开 API:

type DecodeOption = reqx.DecodeOption
type QueryOption = reqx.QueryOption
type Violation = reqx.Violation
type ValidateFunc[T any] func(*T) []Violation

func WithMaxBodyBytes(limit int64) DecodeOption
func AllowUnknownFields() DecodeOption
func AllowEmptyBody() DecodeOption
func AllowUnknownQueryFields() QueryOption

func DecodeJSON[T any](r *http.Request, dst *T, opts ...DecodeOption) error
func DecodeAndValidateJSON[T any](r *http.Request, dst *T, fn ValidateFunc[T], opts ...DecodeOption) error
func DecodeQuery[T any](r *http.Request, dst *T, opts ...QueryOption) error
func DecodeAndValidateQuery[T any](r *http.Request, dst *T, fn ValidateFunc[T], opts ...QueryOption) error
func Validate[T any](dst *T, fn ValidateFunc[T]) error

这些 helper 会把请求解码和输入校验错误适配到 chix 的统一错误响应中。

它们的职责边界是:

  • 负责 JSON 请求体解码
  • 负责 URL query 解码
  • 负责把 []Violation 归一化成稳定的 422 invalid_request

它们不负责:

  • 拥有 router
  • 接管完整响应生命周期
  • path param / header / form 的全量 binding runtime
  • 业务规则建模本身

如果你明确想直接依赖更窄的子包,也可以直接使用 reqxreqx.Problem 仍然可以通过 Error(...) / WriteError(...) 归一化到 chix 的统一公开错误契约中。

示例

推荐先看 chi 示例,再根据自己的项目接法选择 net/http 版本:

两个目录都是独立 Go module,可以直接运行:

cd _examples/chi
go test ./...
go run .

cd ../nethttp
go test ./...
go run .

仓库根目录常用命令:

make test
make test-cover
make test-race
make bench
make ci

复用 chi 的 Request ID

chix 默认会在错误链路里惰性生成并复用 request id。只有当你希望 ErrorReport.RequestIDchi/middleware.RequestID、access log 或网关日志对齐时,才需要显式桥接。

func bindChiRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if reqID := middleware.GetReqID(r.Context()); reqID != "" {
			r = chix.SetRequestID(r, reqID)
		}
		next.ServeHTTP(w, r)
	})
}

func main() {
	r := chi.NewRouter()
	r.Use(middleware.RequestID)
	r.Use(bindChiRequestID)
	r.Use(middleware.Recoverer)
	r.Use(chix.HandleErrors())
}

说明:

  • chi 负责生成或传播 request id
  • bridge middleware 负责把应用最终采用的值显式注入给 chix
  • chix 根包因此不需要依赖 chi 的 context 协议

已知限制

  • Error(...) 依赖当前请求已经安装 HandleErrors(...);如果没有安装 middleware 仍然调用,会按配置错误处理并触发 panic
  • Respond(...) / RespondWithMeta(...) 的成功载荷仍然要求可被标准库 encoding/json 正常编码;错误响应里的 details 如果混入不可编码值,会在内部观测中记录并降级为 details: [],而不是把整个公开错误改写成 500
  • reqx.DecodeJSON(...) 当前会在受 WithMaxBodyBytes(...) 约束下先完整读取请求体,再执行 JSON decode;这适合常规 JSON API 请求,但不是面向超大 body 或流式 decode 的运行时
  • Respond(...) / RespondWithMeta(...) 是一次性 envelope writer,不提供流式响应能力
  • SetRequestID(...) 只负责把 request id 桥接进 chix 的错误观测链路,不替代外层 access log、trace 或分布式链路追踪策略

兼容性

本项目当前的公开兼容边界由本文档中描述的两类内容构成:

  • 根包公开 API
  • chix 自己写出的 HTTP 可观察行为

版本策略:

  • v1.0.0 之前,minor release 仍可能包含破坏性调整,但会在 CHANGELOG 中明确标注
  • v1.0.0 之后,破坏根包公开 API 或 HTTP 契约的变更应只出现在新的 major version

许可证

MIT

About

A lightweight chi-first HTTP boundary layer for Go with consistent JSON responses and error handling.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors