265 lines
7.2 KiB
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")
|
|
}
|