feat: add project-owned error catalogs and HTTP 200 failure contracts
This commit is contained in:
217
errors/catalog.go
Normal file
217
errors/catalog.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package apierrors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
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 }
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
c.Definitions[key] = d
|
||||
}
|
||||
return &Registry{catalog: c}, 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 {
|
||||
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: source.Cause}
|
||||
}
|
||||
}
|
||||
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}
|
||||
}
|
||||
}
|
||||
}
|
||||
copy := *source
|
||||
return ©
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user