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