From 2576cb48aa60c01d9dc36c6c13340e8c4ad4ae97 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Wed, 30 Sep 2026 19:02:19 -0500 Subject: [PATCH] fix(problem)!: adding a problem code no longer changes an API's OpenAPI types --- README.md | 5 +++- problem/fields.go | 2 +- problem/humaproblem/humaproblem.go | 35 ------------------------- problem/humaproblem/humaproblem_test.go | 26 ++++-------------- problem/problem.go | 6 +++-- 5 files changed, 14 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 089c6f5..111a364 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,13 @@ cfg.Transformers = append(cfg.Transformers, humaproblem.Report(func(ctx context. } })) // ... huma.Register(...) -humaproblem.Document(api, problems) mux.Handle("/problems/", problem.Handler(problems)) ``` +The OpenAPI document types `code` as an open string, not an enum: new codes +are not a breaking change, and clients fall back to the status for codes they +don't know. The catalog at `/problems/` lists them. + huma's request validation then becomes a validation problem whose entries name the rule each field failed (`required`, `too_long`, `below_minimum`, ...). diff --git a/problem/fields.go b/problem/fields.go index 3ecac4b..f031167 100644 --- a/problem/fields.go +++ b/problem/fields.go @@ -12,7 +12,7 @@ import ( // parameter instead. type FieldError struct { Type string `json:"type" format:"uri-reference" doc:"The rule's problem type."` - Code Code `json:"code" doc:"Stable name of the rule, for clients to show a localized message."` + Code Code `json:"code" doc:"Stable name of the rule, for clients to show a localized message. New codes can appear: fall back to a generic message for one you don't know."` Detail string `json:"detail,omitempty" doc:"English explanation, for developers and logs."` Pointer string `json:"pointer,omitempty" doc:"JSON Pointer to the invalid body field, as a URI fragment such as #/items/0/name."` Parameter string `json:"parameter,omitempty" doc:"Name of the invalid request parameter."` diff --git a/problem/humaproblem/humaproblem.go b/problem/humaproblem/humaproblem.go index 3d5380d..58e190b 100644 --- a/problem/humaproblem/humaproblem.go +++ b/problem/humaproblem/humaproblem.go @@ -8,7 +8,6 @@ import ( "errors" "net/http" "regexp" - "slices" "strconv" "strings" "sync" @@ -309,37 +308,3 @@ func classify(msg string) classification { } return classification{rule: problem.Invalid} } - -// Document lists every code the API can return in api's OpenAPI document: -// on the Problem schema, validation and the registries' types; on -// FieldError, the shared rules and the registries' types, since a field can -// fail an application's own check. Call it after registering operations. -func Document(api huma.API, registries ...*problem.Registry) { - var app []*problem.Type - for _, r := range registries { - app = append(app, r.Types()...) - } - schemas := api.OpenAPI().Components.Schemas.Map() - if s := schemas["Problem"]; s != nil { - enumerate(s.Properties["code"], append([]*problem.Type{problem.Validation}, app...)) - } - if s := schemas["FieldError"]; s != nil { - enumerate(s.Properties["code"], append(slices.Clone(problem.Rules), app...)) - } -} - -func enumerate(s *huma.Schema, types []*problem.Type) { - if s == nil { - return - } - s.Enum = make([]any, len(types)) - titles := make(map[string]string, len(types)) - for i, t := range types { - s.Enum[i] = string(t.Code) - titles[string(t.Code)] = t.Title - } - if s.Extensions == nil { - s.Extensions = map[string]any{} - } - s.Extensions["x-enumDescriptions"] = titles -} diff --git a/problem/humaproblem/humaproblem_test.go b/problem/humaproblem/humaproblem_test.go index ea3ce8c..ade11b7 100644 --- a/problem/humaproblem/humaproblem_test.go +++ b/problem/humaproblem/humaproblem_test.go @@ -64,7 +64,6 @@ func newAPIWith(t *testing.T, opts humaproblem.Options, transformers ...huma.Tra } return nil, nil }) - humaproblem.Document(api, registry) return api } @@ -205,18 +204,18 @@ func TestMalformedBodyIsBlank(t *testing.T) { } } -func TestDocumentListsCodes(t *testing.T) { +func TestSchemaLeavesCodesOpen(t *testing.T) { api := newAPI(t) schemas := api.OpenAPI().Components.Schemas.Map() prob, fe := schemas["Problem"], schemas["FieldError"] if prob == nil || fe == nil { t.Fatalf("schemas: %v", keys(schemas)) } - if !contains(prob.Properties["code"].Enum, "validation", "name_taken") || contains(prob.Properties["code"].Enum, "too_long") { - t.Errorf("Problem.code enum = %v", prob.Properties["code"].Enum) + if e := prob.Properties["code"].Enum; e != nil { + t.Errorf("Problem.code enum = %v", e) } - if !contains(fe.Properties["code"].Enum, "too_long", "required", "name_taken") { - t.Errorf("FieldError.code enum = %v", fe.Properties["code"].Enum) + if e := fe.Properties["code"].Enum; e != nil { + t.Errorf("FieldError.code enum = %v", e) } op := api.OpenAPI().Paths["/items"].Post if _, ok := op.Responses["default"].Content[problem.MediaType]; !ok { @@ -224,21 +223,6 @@ func TestDocumentListsCodes(t *testing.T) { } } -func contains(enum []any, want ...string) bool { - for _, w := range want { - found := false - for _, e := range enum { - if e == w { - found = true - } - } - if !found { - return false - } - } - return true -} - func keys[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/problem/problem.go b/problem/problem.go index d39b7c5..2738e53 100644 --- a/problem/problem.go +++ b/problem/problem.go @@ -21,7 +21,9 @@ const MediaType = "application/problem+json" const Blank = "about:blank" // Code is a problem type's stable, machine-readable name. Clients key their -// messages on it, so a code is part of the API contract: never rename one. +// messages on it, so a code is part of the API contract: never rename one. The +// set is open: a client that meets a code it doesn't know falls back to the +// one for the status. type Code string // The codes clients derive from the status of an about:blank problem. They are @@ -74,7 +76,7 @@ type Problem struct { //nolint:errname // RFC 9457 calls it a problem details ob Status int `json:"status,omitempty" doc:"The HTTP status code."` Detail string `json:"detail,omitempty" doc:"English explanation of this occurrence, for developers and logs."` Instance string `json:"instance,omitempty" format:"uri-reference" doc:"Identifies this occurrence of the problem."` - Code Code `json:"code,omitempty" doc:"Stable name of the problem type, for clients to show a localized message. Absent for about:blank: derive it from the status."` + Code Code `json:"code,omitempty" doc:"Stable name of the problem type, for clients to show a localized message. New codes can appear: fall back to the status for one you don't know. Absent for about:blank: derive it from the status."` Params map[string]any `json:"params,omitempty" doc:"Values the code's localized message shows."` Errors []*FieldError `json:"errors,omitempty" doc:"For a validation problem, each invalid field and the rule it failed."`