Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, ...).

Expand Down
2 changes: 1 addition & 1 deletion problem/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
Expand Down
35 changes: 0 additions & 35 deletions problem/humaproblem/humaproblem.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"errors"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -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
}
26 changes: 5 additions & 21 deletions problem/humaproblem/humaproblem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ func newAPIWith(t *testing.T, opts humaproblem.Options, transformers ...huma.Tra
}
return nil, nil
})
humaproblem.Document(api, registry)
return api
}

Expand Down Expand Up @@ -205,40 +204,25 @@ 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 {
t.Errorf("default response is not %s", problem.MediaType)
}
}

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 {
Expand Down
6 changes: 4 additions & 2 deletions problem/problem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."`

Expand Down
Loading