feat: add typed error message templates and stable replay snapshots
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user