diff --git a/README.md b/README.md index 0fcded7..0aafdc6 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,20 @@ req, err := httpsuite.ParseRequest[*CreateUserRequest]( ) ``` +### Problem type URLs + +Set the base URL and extra error type paths once at startup; `GetProblemTypeURL` and the problem helpers pick them up. `ParseOptions.Problems` still overrides per call. + +```go +cfg := httpsuite.NewProblemConfig() +cfg.BaseURL = "https://api.example.com" +cfg.ErrorTypePaths["unauthorized"] = "/errors/unauthorized" +httpsuite.SetProblemConfig(cfg) + +// restore the built-in default +httpsuite.SetProblemConfig(httpsuite.NewProblemConfig()) +``` + ## Direct helpers ```go diff --git a/problem_config.go b/problem_config.go index da92f53..0425e8f 100644 --- a/problem_config.go +++ b/problem_config.go @@ -1,8 +1,14 @@ package httpsuite -import "strings" +import ( + "strings" + "sync" +) -var defaultProblemConfig = NewProblemConfig() +var ( + defaultProblemConfigMu sync.RWMutex + defaultProblemConfig = NewProblemConfig() +) // ProblemConfig controls how problem type URLs are generated. type ProblemConfig struct { @@ -24,9 +30,25 @@ func NewProblemConfig() ProblemConfig { // DefaultProblemConfig returns a copy of the package default config. func DefaultProblemConfig() ProblemConfig { + defaultProblemConfigMu.RLock() + defer defaultProblemConfigMu.RUnlock() return defaultProblemConfig.Clone() } +// SetProblemConfig replaces the package-level default config used by +// GetProblemTypeURL, DefaultProblemConfig and the helpers that resolve problem +// types. The config is copied, so later changes to cfg do not affect the +// default. A per-call ParseOptions.Problems still overrides it. +// +// Call it once at startup. To restore the built-in default, call +// SetProblemConfig(NewProblemConfig()). +func SetProblemConfig(cfg ProblemConfig) { + clone := cfg.Clone() + defaultProblemConfigMu.Lock() + defer defaultProblemConfigMu.Unlock() + defaultProblemConfig = clone +} + func mergeProblemConfig(config *ProblemConfig) ProblemConfig { merged := DefaultProblemConfig() if config == nil { @@ -71,6 +93,8 @@ func (c ProblemConfig) TypeURL(errorType string) string { // GetProblemTypeURL returns the default problem type URL for a known error type. func GetProblemTypeURL(errorType string) string { + defaultProblemConfigMu.RLock() + defer defaultProblemConfigMu.RUnlock() return defaultProblemConfig.TypeURL(errorType) } diff --git a/problem_config_test.go b/problem_config_test.go index d1add4c..b07db17 100644 --- a/problem_config_test.go +++ b/problem_config_test.go @@ -1,6 +1,96 @@ package httpsuite -import "testing" +import ( + "sync" + "testing" +) + +func restoreProblemConfig(t *testing.T) { + t.Helper() + SetProblemConfig(NewProblemConfig()) + t.Cleanup(func() { SetProblemConfig(NewProblemConfig()) }) +} + +func TestSetProblemConfig(t *testing.T) { + restoreProblemConfig(t) + + cfg := NewProblemConfig() + cfg.BaseURL = "https://api.example.com/" + cfg.ErrorTypePaths["unauthorized"] = "errors/unauthorized" + SetProblemConfig(cfg) + + if got, want := GetProblemTypeURL("unauthorized"), "https://api.example.com/errors/unauthorized"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + if got := DefaultProblemConfig().TypeURL("not_found_error"); got != "https://api.example.com/errors/not-found" { + t.Fatalf("unexpected default type URL %q", got) + } + if got := NewBadRequestProblem("x").Type; got != "https://api.example.com/errors/bad-request" { + t.Fatalf("helper did not use configured default, got %q", got) + } +} + +func TestSetProblemConfigCopiesInput(t *testing.T) { + restoreProblemConfig(t) + + cfg := NewProblemConfig() + SetProblemConfig(cfg) + cfg.ErrorTypePaths["validation_error"] = "/mutated" + + if got := GetProblemTypeURL("validation_error"); got != "/errors/validation-error" { + t.Fatalf("expected default to be isolated from caller map, got %q", got) + } +} + +func TestParseOptionsProblemsOverrideSetProblemConfig(t *testing.T) { + restoreProblemConfig(t) + + cfg := NewProblemConfig() + cfg.BaseURL = "https://global.example.com" + SetProblemConfig(cfg) + + merged := mergeProblemConfig(&ProblemConfig{BaseURL: "https://local.example.com"}) + if got, want := merged.TypeURL("validation_error"), "https://local.example.com/errors/validation-error"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + merged = mergeProblemConfig(nil) + if got, want := merged.TypeURL("validation_error"), "https://global.example.com/errors/validation-error"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestSetProblemConfigRestore(t *testing.T) { + restoreProblemConfig(t) + + SetProblemConfig(ProblemConfig{BaseURL: "https://api.example.com"}) + if got := GetProblemTypeURL("validation_error"); got != BlankURL { + t.Fatalf("expected %q, got %q", BlankURL, got) + } + + SetProblemConfig(NewProblemConfig()) + if got := GetProblemTypeURL("validation_error"); got != "/errors/validation-error" { + t.Fatalf("expected built-in default, got %q", got) + } +} + +func TestSetProblemConfigConcurrent(t *testing.T) { + restoreProblemConfig(t) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(2) + go func() { + defer wg.Done() + SetProblemConfig(NewProblemConfig()) + }() + go func() { + defer wg.Done() + _ = GetProblemTypeURL("validation_error") + _ = DefaultProblemConfig() + }() + } + wg.Wait() +} func TestProblemConfigTypeURL(t *testing.T) { t.Parallel()