feat: add typed error message templates and stable replay snapshots
This commit is contained in:
@@ -110,6 +110,68 @@ service account-api {
|
||||
|
||||
生成路由的目录和响应由 `.api` 管理;YAML 或 App 默认策略不覆盖它。手写路由可在 main 用 `omni.WithErrors(apierrors.Catalog())` 并选择兼容的 `WithResponse`。新项目和重复生成都会自动绑定路由目录,无需手动修改 main;已有手写文件保持不动。
|
||||
|
||||
## 提示模板参数(v0.11.0-preview)
|
||||
|
||||
模板属于错误目录,业务只传具体值。参数用**有序列表**声明,顺序就是生成函数的参数顺序;每个参数均为必传,`example` 只用于文档。
|
||||
|
||||
```text
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"TextTooLong": {
|
||||
"key": "TEXT_TOO_LONG",
|
||||
"code": 400002,
|
||||
"message": "{label}不能超过{max}个字符",
|
||||
"params": [
|
||||
{"name": "label", "type": "string", "example": "企业名称"},
|
||||
{"name": "max", "type": "int", "example": 100}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
与数字码匹配的公共响应及路由声明见完整 [errorParams.api](../examples/contracts/errorParams.api)。生成后:
|
||||
|
||||
```go
|
||||
apierrors.TextTooLong("企业名称", 100) // Message:企业名称不能超过100个字符
|
||||
apierrors.TextTooLong("备注", 500) // Message:备注不能超过500个字符
|
||||
apierrors.UsernameInvalid() // 原有固定错误保持无参数
|
||||
```
|
||||
|
||||
生成函数在返回前完成解析,非 HTTP Service、任务或 CLI 同样能直接读取完整 Message。调用 `.WithCause(err)` 保留内部原因及 `errors.Is/As` 错误链,不自动把原因加入提示。每次返回新对象,不能把可变错误保存成共享单例。
|
||||
|
||||
运行时入口使用同一个解析器:
|
||||
|
||||
```go
|
||||
err := omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{
|
||||
"label": "企业名称",
|
||||
"max": 100,
|
||||
})
|
||||
// HTTP:将 err 返回,当前 App/路由目录负责解析。
|
||||
// 非 HTTP:使用项目自己的目录显式解析。
|
||||
registry := omnierrors.MustRegistry(apierrors.Catalog())
|
||||
resolved := registry.Resolve(err)
|
||||
fmt.Println(resolved.Message) // 企业名称不能超过100个字符
|
||||
```
|
||||
|
||||
Go 配置中的 `Definition.Params` 是 `[]omnierrors.Parameter`,JSON/YAML 使用相同的 `params` 数组。目录在初始化时复制并编译模板;`WithParams` 返回新错误并复制参数,之后修改原 map 不会改变该错误。重复解析、路由策略和 Context 缓冲克隆保留参数。
|
||||
|
||||
规则如下:
|
||||
|
||||
- 参数名使用 lowerCamelCase,并且不是 Go 关键字。顺序是调用 API 的一部分,重排或变更类型需要同步调整调用方。
|
||||
- 支持 `string`、`bool`、`int`、`int32`、`int64`、`float64`。运行时允许整数类型之间按声明范围归一化,拒绝将字符串或浮点数隐式转成整数;float64 要求有限数值。`int` 范围随 Go 目标平台变化,需要固定位宽时选 int32/int64。
|
||||
- 只接受标量,不接受 nil、切片、map、指针、任意对象或 Stringer。通过 JSON 构建运行时参数时使用 `Decoder.UseNumber()`,避免丢失大整数精度。
|
||||
- 模板使用 `{name}`;同一参数可重复出现。`{{` / `}}` 表示字面花括号。插入值不会再次解析,即使 label 本身包含 `{max}`,也只是普通文本。
|
||||
- **省略 params** 表示旧固定提示,字面花括号不变。不接受空 params 数组;重复参数、未声明占位符、未使用参数、不匹配的花括号、非法类型及错误的 example 会在目录加载/契约校验时失败。
|
||||
- 运行时缺参、多传参数、类型或范围错误转为系统错误兜底;不会将原始模板或参数值作为诊断发给客户端。内部 cause 中可通过 `errors.As` 获取 `*omnierrors.ParamError`,访问日志带 `errorParameter` 诊断,不自动记录参数值。
|
||||
- 项目可以配置固定的 INTERNAL_ERROR 兜底;若它自身也需要参数且无法解析,则回退框架固定 500,避免递归失败。
|
||||
- 模板错误的提示只放目录中;同标识再配置旧 `messages` 会被拒绝。响应字段、HTTP 状态、`data:null` 仍由项目策略决定,例如 `dataMode: always` 保留空 data。
|
||||
|
||||
OpenAPI 的 `x-omni-errors` 保留模板和有序参数定义,响应 examples 使用同一个解析器生成具体文案。未提供 example 时使用稳定样例(string 使用参数名,bool 使用 false,数值使用 1);这些样例不充当运行时默认值,也不是新增 HTTP 请求参数。
|
||||
|
||||
幂等重放保存当次**已解析的公开错误快照**,保留原响应与失败信息。目录更新后,仍在有效期内的重放不会被新文案覆盖;快照不持久化原始参数或内部 cause。整数业务码以不受 JSON 中间层浮点转换影响的方式保存。旧缓存仅保留已有错误标识,不能补推当时缺失的提示与业务码。共享存储的错误快照另受 MaxResponseBytes 限额约束,超限保留不可重放记录,避免重复执行业务。
|
||||
|
||||
## 特殊协议与失败信息
|
||||
|
||||
`response.WithErrors(catalog, encoder)` / `MustWithErrors` 可绑定路由专用策略,再通过 `HandleWithResponse` 注册。内置模板校验目录码类型与旧映射;自定义 Encoder 自行决定最终 HTTP 状态、媒体类型和字段结构,可将失败转换成第三方要求的文本/XML 回执。一个 Encoder 作用域不叠加不同目录;外层空目录或相同目录不会覆盖已有目录。
|
||||
@@ -123,11 +185,11 @@ Encoder 的 `response.Result` 包含逻辑 `Code`、对外 `BusinessCode`、公
|
||||
## 升级
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.11.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.0-preview
|
||||
go-omni api generate --dry-run
|
||||
go-omni api generate
|
||||
go test ./...
|
||||
```
|
||||
|
||||
先升级框架依赖,再用同版 CLI 生成;新生成代码最低依赖 v0.10.0-preview。原来的错误构造函数、旧 `error (...)`、Codes/Messages 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。
|
||||
先升级框架依赖,再用同版 CLI 生成;新生成代码最低依赖 v0.11.0-preview。原来的错误构造函数、旧 `error (...)`、Codes/Messages 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。
|
||||
|
||||
@@ -15,9 +15,10 @@ import (
|
||||
// the Catalog map key is the distinct logical identifier used by ByCode.
|
||||
// Nil Code retains legacy response code mapping (useful when migrating).
|
||||
type Definition struct {
|
||||
Status int `json:"status,omitempty"`
|
||||
Code any `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Code any `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Params []Parameter `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
@@ -98,6 +99,11 @@ func (c Catalog) Clone() Catalog {
|
||||
out := c
|
||||
out.Definitions = make(map[string]Definition, len(c.Definitions))
|
||||
for key, d := range c.Definitions {
|
||||
if d.Params != nil {
|
||||
params := make([]Parameter, len(d.Params))
|
||||
copy(params, d.Params)
|
||||
d.Params = params
|
||||
}
|
||||
out.Definitions[key] = d
|
||||
}
|
||||
return out
|
||||
@@ -105,7 +111,10 @@ func (c Catalog) Clone() Catalog {
|
||||
|
||||
// Registry is immutable after construction and may be shared across requests.
|
||||
// It has no process-global state. Every Resolve call returns a fresh error.
|
||||
type Registry struct{ catalog Catalog }
|
||||
type Registry struct {
|
||||
catalog Catalog
|
||||
messages map[string][]messagePart
|
||||
}
|
||||
|
||||
func NewRegistry(c Catalog) (*Registry, error) {
|
||||
c = c.Clone()
|
||||
@@ -115,6 +124,7 @@ func NewRegistry(c Catalog) (*Registry, error) {
|
||||
if !ValidErrorStatus(c.DefaultStatus) {
|
||||
return nil, fmt.Errorf("errors: invalid defaultStatus %d", c.DefaultStatus)
|
||||
}
|
||||
messages := map[string][]messagePart{}
|
||||
for key, d := range c.Definitions {
|
||||
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") {
|
||||
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
|
||||
@@ -135,9 +145,14 @@ func NewRegistry(c Catalog) (*Registry, error) {
|
||||
}
|
||||
d.Code = code
|
||||
}
|
||||
parts, err := compileMessage(&d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("errors: %s: %w", key, err)
|
||||
}
|
||||
messages[key] = parts
|
||||
c.Definitions[key] = d
|
||||
}
|
||||
return &Registry{catalog: c}, nil
|
||||
return &Registry{catalog: c, messages: messages}, nil
|
||||
}
|
||||
|
||||
// ValidErrorStatus permits final responses with a JSON body, including HTTP 200.
|
||||
@@ -171,23 +186,41 @@ func (r *Registry) Resolve(err error) *APIError {
|
||||
}
|
||||
var source *APIError
|
||||
if !stdErrors.As(err, &source) || source == nil {
|
||||
source = Internal(err)
|
||||
return r.internal(err)
|
||||
}
|
||||
if source.paramErr != nil {
|
||||
return r.internal(stdErrors.Join(source.paramErr, err))
|
||||
}
|
||||
if r != nil {
|
||||
if d, ok := r.catalog.Definitions[source.Code]; ok {
|
||||
return &APIError{Status: d.Status, Code: source.Code, Message: d.Message, BusinessCode: d.Code, Cause: source.Cause}
|
||||
message, paramErr := r.render(source.Code, d, source.params)
|
||||
if paramErr != nil {
|
||||
return r.internal(stdErrors.Join(paramErr, err))
|
||||
}
|
||||
out := source.Clone()
|
||||
out.Status, out.Code, out.Message, out.BusinessCode = d.Status, source.Code, message, d.Code
|
||||
out.reference = false
|
||||
return out
|
||||
}
|
||||
}
|
||||
if source.reference {
|
||||
source = Internal(err)
|
||||
if r != nil {
|
||||
if d, ok := r.catalog.Definitions[source.Code]; ok {
|
||||
return &APIError{Status: d.Status, Code: source.Code, Message: d.Message, BusinessCode: d.Code, Cause: err}
|
||||
return r.internal(err)
|
||||
}
|
||||
return source.Clone()
|
||||
}
|
||||
|
||||
// Internal fallback is deliberately bounded: a misconfigured parameterized
|
||||
// INTERNAL_ERROR cannot recursively fail while reporting a rendering problem.
|
||||
func (r *Registry) internal(cause error) *APIError {
|
||||
out := Internal(cause)
|
||||
if r != nil {
|
||||
if d, ok := r.catalog.Definitions[out.Code]; ok {
|
||||
if message, err := r.render(out.Code, d, nil); err == nil {
|
||||
out.Status, out.Message, out.BusinessCode = d.Status, message, d.Code
|
||||
}
|
||||
}
|
||||
}
|
||||
copy := *source
|
||||
return ©
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCatalogCode(value any) (any, error) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
)
|
||||
|
||||
// APIError is the public error shape used by handlers and middleware.
|
||||
// Cause is retained for logging and unwrapping, but is never serialized.
|
||||
// Cause is retained for logging and unwrapping; default response encoders and
|
||||
// replay snapshots never serialize it.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
@@ -14,6 +15,8 @@ type APIError struct {
|
||||
Cause error
|
||||
BusinessCode any // Optional public code resolved from a project catalog.
|
||||
reference bool
|
||||
params map[string]any
|
||||
paramErr error
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
|
||||
272
errors/params.go
Normal file
272
errors/params.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package apierrors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/token"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Parameter is ordered: declaration order is the generated function's argument
|
||||
// order. Example is documentation-only and never substitutes a missing value.
|
||||
type Parameter struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Example any `json:"example,omitempty"`
|
||||
}
|
||||
|
||||
type messagePart struct{ text, param string }
|
||||
|
||||
var parameterName = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
|
||||
|
||||
// ParamError describes a programming/configuration error without including the
|
||||
// supplied value. It remains available through errors.As on the internal cause.
|
||||
type ParamError struct{ Code, Param, Problem string }
|
||||
|
||||
func (e *ParamError) Error() string {
|
||||
return fmt.Sprintf("errors: %s parameter %q: %s", e.Code, e.Param, e.Problem)
|
||||
}
|
||||
|
||||
// WithParams returns a detached error. It never retains the caller's map or any
|
||||
// mutable parameter value. Catalog-specific validation happens during Resolve.
|
||||
func (e *APIError) WithParams(values map[string]any) *APIError {
|
||||
out := e.Clone()
|
||||
out.params = make(map[string]any, len(values))
|
||||
out.paramErr = nil
|
||||
for name, value := range values {
|
||||
scalar, err := copyScalar(value)
|
||||
if err != nil {
|
||||
out.paramErr = &ParamError{Code: out.Code, Param: name, Problem: "expected a scalar value"}
|
||||
continue
|
||||
}
|
||||
out.params[name] = scalar
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Clone detaches request-specific parameters; Cause remains an error chain.
|
||||
func (e *APIError) Clone() *APIError {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
out := *e
|
||||
out.params = make(map[string]any, len(e.params))
|
||||
for k, v := range e.params {
|
||||
out.params[k] = v
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func copyScalar(v any) (any, error) {
|
||||
if n, ok := v.(json.Number); ok {
|
||||
return n, nil
|
||||
}
|
||||
if v == nil {
|
||||
return nil, fmt.Errorf("nil scalar")
|
||||
}
|
||||
r := reflect.ValueOf(v)
|
||||
switch r.Kind() {
|
||||
case reflect.String:
|
||||
return r.String(), nil
|
||||
case reflect.Bool:
|
||||
return r.Bool(), nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return r.Int(), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return r.Uint(), nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return r.Float(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported scalar")
|
||||
}
|
||||
func paramValue(kind string, v any) (any, error) {
|
||||
value, err := copyScalar(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch kind {
|
||||
case "string":
|
||||
if s, ok := value.(string); ok {
|
||||
return s, nil
|
||||
}
|
||||
case "bool":
|
||||
if b, ok := value.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
case "int", "int32", "int64":
|
||||
var n int64
|
||||
switch x := value.(type) {
|
||||
case int64:
|
||||
n = x
|
||||
case uint64:
|
||||
if x > math.MaxInt64 {
|
||||
return nil, fmt.Errorf("integer overflow")
|
||||
}
|
||||
n = int64(x)
|
||||
case json.Number:
|
||||
n, err = x.Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected exact integer")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("expected %s", kind)
|
||||
}
|
||||
if (kind == "int32" || kind == "int" && strconv.IntSize == 32) && (n < math.MinInt32 || n > math.MaxInt32) {
|
||||
return nil, fmt.Errorf("%s overflow", kind)
|
||||
}
|
||||
return n, nil
|
||||
case "float64":
|
||||
var n float64
|
||||
switch x := value.(type) {
|
||||
case float64:
|
||||
n = x
|
||||
case json.Number:
|
||||
n, err = x.Float64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected finite float64")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("expected float64")
|
||||
}
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
return nil, fmt.Errorf("expected finite float64")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("expected %s", kind)
|
||||
}
|
||||
|
||||
func compileMessage(d *Definition) ([]messagePart, error) {
|
||||
if d.Params == nil {
|
||||
return []messagePart{{text: d.Message}}, nil
|
||||
}
|
||||
if len(d.Params) == 0 {
|
||||
return nil, fmt.Errorf("params must be nonempty; omit params for a fixed message")
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for i, p := range d.Params {
|
||||
if !parameterName.MatchString(p.Name) || token.Lookup(p.Name).IsKeyword() || declared[p.Name] {
|
||||
return nil, fmt.Errorf("invalid or duplicate parameter %q", p.Name)
|
||||
}
|
||||
switch p.Type {
|
||||
case "string", "bool", "int", "int32", "int64", "float64":
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported parameter type %q", p.Type)
|
||||
}
|
||||
declared[p.Name] = true
|
||||
if p.Example != nil {
|
||||
example, err := paramValue(p.Type, p.Example)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("example for %s: %w", p.Name, err)
|
||||
}
|
||||
d.Params[i].Example = example
|
||||
}
|
||||
}
|
||||
var parts []messagePart
|
||||
var literal strings.Builder
|
||||
used := map[string]bool{}
|
||||
flush := func() {
|
||||
if literal.Len() > 0 {
|
||||
parts = append(parts, messagePart{text: literal.String()})
|
||||
literal.Reset()
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(d.Message); {
|
||||
ch := d.Message[i]
|
||||
if ch != '{' && ch != '}' {
|
||||
literal.WriteByte(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i+1 < len(d.Message) && d.Message[i+1] == ch {
|
||||
literal.WriteByte(ch)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if ch == '}' {
|
||||
return nil, fmt.Errorf("unescaped closing brace")
|
||||
}
|
||||
end := strings.IndexByte(d.Message[i+1:], '}')
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("unclosed placeholder")
|
||||
}
|
||||
name := d.Message[i+1 : i+1+end]
|
||||
if !declared[name] {
|
||||
return nil, fmt.Errorf("undeclared placeholder %q", name)
|
||||
}
|
||||
flush()
|
||||
parts = append(parts, messagePart{param: name})
|
||||
used[name] = true
|
||||
i += end + 2
|
||||
}
|
||||
flush()
|
||||
for _, p := range d.Params {
|
||||
if !used[p.Name] {
|
||||
return nil, fmt.Errorf("unused parameter %q", p.Name)
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func (r *Registry) render(key string, d Definition, params map[string]any) (string, error) {
|
||||
values := make(map[string]any, len(d.Params))
|
||||
for _, p := range d.Params {
|
||||
v, ok := params[p.Name]
|
||||
if !ok {
|
||||
return "", &ParamError{key, p.Name, "missing value"}
|
||||
}
|
||||
normalized, err := paramValue(p.Type, v)
|
||||
if err != nil {
|
||||
return "", &ParamError{key, p.Name, err.Error()}
|
||||
}
|
||||
values[p.Name] = normalized
|
||||
}
|
||||
for name := range params {
|
||||
if _, ok := values[name]; !ok {
|
||||
return "", &ParamError{key, name, "not declared"}
|
||||
}
|
||||
}
|
||||
var result strings.Builder
|
||||
for _, part := range r.messages[key] {
|
||||
if part.param != "" {
|
||||
fmt.Fprint(&result, values[part.param])
|
||||
} else {
|
||||
result.WriteString(part.text)
|
||||
}
|
||||
}
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// Example resolves documentation values using the same path as runtime errors.
|
||||
// Missing examples receive deterministic samples, never runtime defaults.
|
||||
func (r *Registry) Example(key string) *APIError {
|
||||
if r == nil {
|
||||
return r.Resolve(ByCode(key))
|
||||
}
|
||||
d, ok := r.catalog.Definitions[key]
|
||||
if !ok {
|
||||
return r.Resolve(ByCode(key))
|
||||
}
|
||||
values := map[string]any{}
|
||||
for _, p := range d.Params {
|
||||
v := p.Example
|
||||
if v == nil {
|
||||
switch p.Type {
|
||||
case "string":
|
||||
v = p.Name
|
||||
case "bool":
|
||||
v = false
|
||||
case "float64":
|
||||
v = float64(1)
|
||||
default:
|
||||
v = int64(1)
|
||||
}
|
||||
}
|
||||
values[p.Name] = v
|
||||
}
|
||||
return r.Resolve(ByCode(key).WithParams(values))
|
||||
}
|
||||
160
errors/params_test.go
Normal file
160
errors/params_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package apierrors_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
func templateCatalog() apierrors.Catalog {
|
||||
return apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
|
||||
"TEXT_TOO_LONG": {Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}},
|
||||
"FIXED": {Code: 400003, Message: "literal {unchanged}"},
|
||||
}}
|
||||
}
|
||||
func TestMessageParamsIsolationAndResolution(t *testing.T) {
|
||||
catalog := templateCatalog()
|
||||
r := apierrors.MustRegistry(catalog)
|
||||
catalog.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool"
|
||||
r.Catalog().Definitions["TEXT_TOO_LONG"].Params[0].Name = "changed"
|
||||
private := errors.New("private cause")
|
||||
values := map[string]any{"label": "企业名称", "max": 100}
|
||||
reference := apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(private)
|
||||
values["label"] = "changed"
|
||||
first := r.Resolve(fmt.Errorf("wrapped: %w", reference))
|
||||
for n := 0; n < 3; n++ {
|
||||
first = r.Resolve(first)
|
||||
if first.Message != "企业名称不能超过100个字符" || first.Status != 200 || first.BusinessCode != int64(400002) || !errors.Is(first, private) {
|
||||
t.Fatal(first)
|
||||
}
|
||||
}
|
||||
second := r.Resolve(first.WithParams(map[string]any{"label": "备注", "max": 500}))
|
||||
if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" {
|
||||
t.Fatal(first, second)
|
||||
}
|
||||
if r.Example("TEXT_TOO_LONG").Message != first.Message {
|
||||
t.Fatal("example and runtime differ")
|
||||
}
|
||||
if r.Resolve(apierrors.ByCode("FIXED")).Message != "literal {unchanged}" {
|
||||
t.Fatal("legacy braces changed")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 80; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
label, max := "企业名称", 100
|
||||
if n%2 == 1 {
|
||||
label, max = "备注", 500
|
||||
}
|
||||
got := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": label, "max": max}).WithCause(private))
|
||||
if got.Message != fmt.Sprintf("%s不能超过%d个字符", label, max) || !errors.Is(got, private) {
|
||||
t.Error(got)
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
func TestInvalidTemplateParamsFailClosed(t *testing.T) {
|
||||
r := apierrors.MustRegistry(templateCatalog())
|
||||
cause := errors.New("private")
|
||||
for _, values := range []map[string]any{
|
||||
nil, {"label": "x"}, {"label": "x", "max": "100"}, {"label": 100, "max": 100},
|
||||
{"label": "x", "max": 100.0}, {"label": "x", "max": 100, "extra": "private-value"},
|
||||
{"label": "x", "max": nil}, {"label": map[string]any{"secret": "private-value"}, "max": 100},
|
||||
} {
|
||||
result := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(cause))
|
||||
var diagnostic *apierrors.ParamError
|
||||
if result.Code != "INTERNAL_ERROR" || result.Status != 500 || strings.Contains(result.Message, "{") || !errors.As(result, &diagnostic) || !errors.Is(result, cause) {
|
||||
t.Fatal(result)
|
||||
}
|
||||
if strings.Contains(diagnostic.Error(), "private-value") {
|
||||
t.Fatal("diagnostic leaked value")
|
||||
}
|
||||
}
|
||||
// A parameterized fallback cannot recurse or expose its template.
|
||||
c := templateCatalog()
|
||||
c.Definitions["INTERNAL_ERROR"] = c.Definitions["TEXT_TOO_LONG"]
|
||||
if result := apierrors.MustRegistry(c).Resolve(errors.New("private")); result.Message != "internal server error" || result.Status != 500 {
|
||||
t.Fatal(result)
|
||||
}
|
||||
for _, change := range []func(*apierrors.Definition){
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{missing}" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{max" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{max}}" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Name = "label" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Name = "type" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Type = "interface{}" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Example = "100" },
|
||||
func(d *apierrors.Definition) { d.Params = []apierrors.Parameter{} },
|
||||
} {
|
||||
c := templateCatalog()
|
||||
d := c.Definitions["TEXT_TOO_LONG"]
|
||||
change(&d)
|
||||
c.Definitions["TEXT_TOO_LONG"] = d
|
||||
if _, err := apierrors.NewRegistry(c); err == nil {
|
||||
t.Fatal("accepted invalid template", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestParamTypesEscapingAndSnapshot(t *testing.T) {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"T": {Code: "t", Message: "{{literal}} {value} {value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}}}}
|
||||
r := apierrors.MustRegistry(c)
|
||||
got := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": "{other}"}))
|
||||
if got.Message != "{literal} {other} {other}" {
|
||||
t.Fatal("recursive substitution", got)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
kind string
|
||||
good, bad any
|
||||
want string
|
||||
}{
|
||||
{"int32", int64(math.MaxInt32), int64(math.MaxInt32) + 1, "2147483647"},
|
||||
{"int64", json.Number("9223372036854775807"), json.Number("9223372036854775808"), "9223372036854775807"},
|
||||
{"bool", true, "true", "true"}, {"float64", float64(1.25), math.Inf(1), "1.25"},
|
||||
} {
|
||||
d := c.Definitions["T"]
|
||||
d.Params = []apierrors.Parameter{{Name: "value", Type: test.kind}}
|
||||
c.Definitions["T"] = d
|
||||
r = apierrors.MustRegistry(c)
|
||||
if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.good})); x.Message != "{literal} "+test.want+" "+test.want {
|
||||
t.Fatal(test.kind, x)
|
||||
}
|
||||
if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.bad})); x.Status != 500 {
|
||||
t.Fatal(test.kind, x)
|
||||
}
|
||||
}
|
||||
original := apierrors.New(200, "T", "complete").WithCause(errors.New("private"))
|
||||
original.BusinessCode = int64(math.MaxInt64)
|
||||
raw, err := json.Marshal(original.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "private") || strings.Contains(string(raw), "params") {
|
||||
t.Fatal(string(raw))
|
||||
}
|
||||
// Simulate a store rewriting its envelope with ordinary JSON numbers.
|
||||
var storage map[string]any
|
||||
if err = json.Unmarshal(raw, &storage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err = json.Marshal(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot apierrors.Snapshot
|
||||
if err = json.Unmarshal(raw, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := snapshot.ErrorValue()
|
||||
if restored.Cause != nil || restored.Message != "complete" || restored.BusinessCode != int64(math.MaxInt64) {
|
||||
t.Fatal(restored)
|
||||
}
|
||||
}
|
||||
71
errors/snapshot.go
Normal file
71
errors/snapshot.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package apierrors
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Snapshot contains only the resolved public error, suitable for replay
|
||||
// metadata. Parameters and internal causes are deliberately excluded.
|
||||
type Snapshot struct {
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
}
|
||||
|
||||
// Encode the public code as opaque JSON text so shared stores (including Redis
|
||||
// Lua/cjson) cannot round an int64 while rewriting their record envelopes.
|
||||
func (s Snapshot) MarshalJSON() ([]byte, error) {
|
||||
var encoded string
|
||||
if s.BusinessCode != nil {
|
||||
code, err := normalizeCatalogCode(s.BusinessCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := json.Marshal(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded = string(raw)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
BusinessCodeJSON string `json:"businessCodeJson,omitempty"`
|
||||
}{s.Status, s.Code, s.Message, encoded})
|
||||
}
|
||||
func (s *Snapshot) UnmarshalJSON(data []byte) error {
|
||||
type plain Snapshot
|
||||
var out struct {
|
||||
plain
|
||||
BusinessCodeJSON string `json:"businessCodeJson,omitempty"`
|
||||
}
|
||||
if err := DecodeCatalogJSON(data, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if out.BusinessCodeJSON != "" {
|
||||
if err := DecodeCatalogJSON([]byte(out.BusinessCodeJSON), &out.BusinessCode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if out.BusinessCode != nil {
|
||||
code, err := normalizeCatalogCode(out.BusinessCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.BusinessCode = code
|
||||
}
|
||||
*s = Snapshot(out.plain)
|
||||
return nil
|
||||
}
|
||||
func (e *APIError) Snapshot() *Snapshot {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return &Snapshot{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode}
|
||||
}
|
||||
func (s *Snapshot) ErrorValue() *APIError {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &APIError{Status: s.Status, Code: s.Code, Message: s.Message, BusinessCode: s.BusinessCode}
|
||||
}
|
||||
Reference in New Issue
Block a user