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") }