Files
go-omni/errors/catalog.go

265 lines
7.2 KiB
Go

package apierrors
import (
"bytes"
"encoding/json"
stdErrors "errors"
"fmt"
"io"
"math"
"reflect"
"strings"
"unicode"
"unicode/utf8"
)
// Definition belongs to the project. Code is the public string/integer code;
// 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"`
Params []Parameter `json:"params,omitempty"`
Translation *Translation `json:"translation,omitempty"`
}
type Catalog struct {
DefaultStatus int `json:"defaultStatus,omitempty"`
Definitions map[string]Definition `json:"definitions"`
}
// UnmarshalJSON preserves int64 precision and rejects ambiguous duplicate keys.
func (c *Catalog) UnmarshalJSON(data []byte) error {
type plain Catalog
value := plain(c.Clone())
if err := DecodeCatalogJSON(data, &value); err != nil {
return err
}
*c = Catalog(value)
return nil
}
// DecodeCatalogJSON strictly decodes catalog metadata without float conversion.
// It is also used by the API compiler so runtime and contracts agree.
func DecodeCatalogJSON(data []byte, dst any) error {
scan := json.NewDecoder(bytes.NewReader(data))
scan.UseNumber()
var read func() error
read = func() error {
token, err := scan.Token()
if err != nil {
return err
}
if d, ok := token.(json.Delim); ok {
switch d {
case '{':
seen := map[string]bool{}
for scan.More() {
k, err := scan.Token()
if err != nil {
return err
}
key, ok := k.(string)
if !ok {
return fmt.Errorf("errors: object key required")
}
if seen[key] {
return fmt.Errorf("errors: duplicate key %q", key)
}
seen[key] = true
if err := read(); err != nil {
return err
}
}
case '[':
for scan.More() {
if err := read(); err != nil {
return err
}
}
default:
return fmt.Errorf("errors: invalid JSON delimiter")
}
_, err = scan.Token()
return err
}
return nil
}
if err := read(); err != nil {
return err
}
if _, err := scan.Token(); err != io.EOF {
return fmt.Errorf("errors: one JSON value required")
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
func (c Catalog) Clone() Catalog {
out := c
out.Definitions = make(map[string]Definition, len(c.Definitions))
for key, d := range c.Definitions {
if d.Translation != nil {
ref := *d.Translation
d.Translation = &ref
}
if d.Params != nil {
params := make([]Parameter, len(d.Params))
copy(params, d.Params)
d.Params = params
}
out.Definitions[key] = d
}
return out
}
// 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
messages map[string][]messagePart
}
func NewRegistry(c Catalog) (*Registry, error) {
c = c.Clone()
if c.DefaultStatus == 0 {
c.DefaultStatus = 400
}
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 d.Translation != nil {
for _, value := range []string{d.Translation.Namespace, d.Translation.Key} {
if value == "" || len(value) > 256 || !utf8.ValidString(value) || strings.TrimSpace(value) != value || strings.IndexFunc(value, unicode.IsControl) >= 0 {
return nil, fmt.Errorf("errors: %s has invalid translation reference", key)
}
}
}
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") {
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
}
if d.Status == 0 {
d.Status = c.DefaultStatus
}
if !ValidErrorStatus(d.Status) {
return nil, fmt.Errorf("errors: %s has invalid status %d", key, d.Status)
}
if strings.TrimSpace(d.Message) == "" {
return nil, fmt.Errorf("errors: %s requires a public message", key)
}
if d.Code != nil {
code, err := normalizeCatalogCode(d.Code)
if err != nil {
return nil, fmt.Errorf("errors: %s code: %w", key, err)
}
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, messages: messages}, nil
}
// ValidErrorStatus permits final responses with a JSON body, including HTTP 200.
func ValidErrorStatus(status int) bool {
return status >= 200 && status <= 599 && status != 204 && status != 205 && status != 304
}
func MustRegistry(c Catalog) *Registry {
r, err := NewRegistry(c)
if err != nil {
panic(err)
}
return r
}
func (c Catalog) Validate() error { _, err := NewRegistry(c); return err }
func (r *Registry) Catalog() Catalog {
if r == nil {
return Catalog{}
}
return r.catalog.Clone()
}
// ByCode identifies an error without coupling business code to its HTTP policy.
// Resolution occurs in the application's/request's registry. An unknown reference
// becomes an internal error; its identifier and cause stay available to logs.
func ByCode(key string) *APIError { return &APIError{Code: key, reference: true} }
func (r *Registry) Resolve(err error) *APIError {
if err == nil {
return nil
}
var source *APIError
if !stdErrors.As(err, &source) || source == nil {
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 {
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 {
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
}
}
}
return out
}
func normalizeCatalogCode(value any) (any, error) {
if v, ok := value.(string); ok {
if v == "" {
return nil, fmt.Errorf("must be nonempty")
}
return v, nil
}
if v, ok := value.(json.Number); ok {
n, err := v.Int64()
if err != nil {
return nil, fmt.Errorf("must be an exact int64")
}
return n, nil
}
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int(), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if v.Uint() <= math.MaxInt64 {
return int64(v.Uint()), nil
}
}
return nil, fmt.Errorf("must be a string or signed 64-bit integer")
}