From 869215cfb5f295b89e77638039ea4a986cf8948e Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 22:41:22 -0400 Subject: [PATCH 01/13] switch from travis to gha --- .github/workflows/test.yml | 24 ++++++++++++++++++++++++ .travis.yml | 15 --------------- 2 files changed, 24 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/test.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f761f01 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + strategy: + matrix: + go-version: [1.22.x, 1.23.x, 1.24.x] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + + - name: Test + run: make test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6b23c5f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: go -go: - - 1.4 - - 1.5 - - 1.6 - - 1.7 - - 1.8 - - 1.9 - - 1.10 - - 1.11 - - 1.12 - - tip - -script: - - make test From aff4c095908d3a1e464f4403f3a4c17e8394f799 Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 22:41:37 -0400 Subject: [PATCH 02/13] update Makefile --- Makefile | 57 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index e2cc9a8..e102139 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,50 @@ -PACKAGE=github.com/moogar0880/problems +############################################################################### +# GNU Make Variables +############################################################################### +export MAKEFLAGS += --warn-undefined-variables +export SHELL := bash +export .SHELLFLAGS := -eu -o pipefail -c +export .DEFAULT_GOAL := all +.DELETE_ON_ERROR: +.SUFFIXES: + +############################################################################### +# Location Variables +############################################################################### +export PROJECT_ROOT=$(shell pwd) + +############################################################################### +# Go Variables +############################################################################### +GO_MODULE_NAME=github.com/moogar0880/problems +GO_COVERAGE_FILE=cover.out +GO_TEST_OPTS=-coverprofile $(GO_COVERAGE_FILE) +GO_TEST_PKGS=./... .PHONY: all test coverage style all: test -build: - go build -v $(PACKAGE) +.PHONY: godoc +godoc: + docker run \ + --rm \ + --detach \ + --entrypoint bash \ + --expose "6060" \ + --name "godoc" \ + --publish "6060:6060" \ + --volume $(PROJECT_ROOT):/go/src/$(GO_MODULE_NAME) \ + --workdir /go/src/$(GO_MODULE_NAME) \ + golang:latest \ + -c "go install golang.org/x/tools/cmd/godoc@latest && godoc -http=:6060" || true + @open http://localhost:6060 ### Testing -test: build - go test -v $(PACKAGE) - -coverage: - go test -v -cover -coverprofile=coverage.out $(PACKAGE) - -### Style and Linting -lint: - go vet $(PACKAGE) && goimports . +.PHONY: test +test: + @go test $(GO_TEST_OPTS) $(GO_TEST_PKGS) -# modify source code if style offences are found -style: - go vet $(PACKAGE) && goimports -e -l -w . +.PHONY: test/coverage +test/coverage: test + @go tool cover -html=cover.out From 82987848abae84f56462f965e58a9f33f98c7f2a Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:23:30 -0400 Subject: [PATCH 03/13] update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2910bc0..e752e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,4 @@ _testmain.go *.prof # Coverage reports -coverage.out +cover.out From 47f3f9841fa94d9b11e712a1c2292ba2cf086d01 Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:24:13 -0400 Subject: [PATCH 04/13] more Makefile updates --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index e102139..09063fc 100644 --- a/Makefile +++ b/Makefile @@ -21,9 +21,9 @@ GO_COVERAGE_FILE=cover.out GO_TEST_OPTS=-coverprofile $(GO_COVERAGE_FILE) GO_TEST_PKGS=./... -.PHONY: all test coverage style - -all: test +.PHONY: clean +clean: + @rm $(GO_COVERAGE_FILE) .PHONY: godoc godoc: From c029ad064a5bf2288786a448016eb7538ecfc7ba Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:24:28 -0400 Subject: [PATCH 05/13] update go.mod to 1.24 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 4ac353f..02d83fb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/moogar0880/problems -go 1.12 +go 1.24 From 63f41a089eed0d488862d0d869cef4ab757cb284 Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:24:57 -0400 Subject: [PATCH 06/13] carry invalid type string in error --- errors.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/errors.go b/errors.go index f216a67..ca1ab9d 100644 --- a/errors.go +++ b/errors.go @@ -12,14 +12,16 @@ var ErrTitleMustBeSet = fmt.Errorf("%s: problem title must be set", errPrefix) // valid URI when it is validated. The inner Err will contain the error // returned from attempting to parse the invalid URI. type ErrInvalidProblemType struct { - Err error + Err error + Value string } // NewErrInvalidProblemType returns a new ErrInvalidProblemType instance which // wraps the provided error. -func NewErrInvalidProblemType(e error) error { +func NewErrInvalidProblemType(value string, e error) error { return &ErrInvalidProblemType{ - Err: e, + Err: e, + Value: value, } } From c8b3876a21817ee8e84596a3e4132cf8a93b4067 Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:25:07 -0400 Subject: [PATCH 07/13] update documentation and examples --- doc.go | 26 +++++++++++++++-------- doc_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/doc.go b/doc.go index 484e392..50d36a7 100644 --- a/doc.go +++ b/doc.go @@ -1,13 +1,21 @@ -// Package problems provides an RFC 7807 (https://tools.ietf.org/html/rfc7807) -// compliant implementation of HTTP problem details. Which are defined as a -// means to carry machine-readable details of errors in an HTTP response to -// avoid the need to define new error response formats for HTTP APIs. +// Package problems provides an RFC-9457 (https://tools.ietf.org/html/rfc9457) +// and RFC 7807 (https://tools.ietf.org/html/rfc7807) compliant implementation +// of HTTP problem details. Which are defined as a means to carry +// machine-readable details of errors in an HTTP response to avoid the need to +// define new error response formats for HTTP APIs. // // The problem details specification was designed to allow for schema -// extensions. Because of this the exposed Problem interface only enforces the -// required Type and Title fields be set appropriately. +// extensions. There are two possible ways to create problem extensions: // -// Additionally, this library also ships with default http.HandlerFunc's capable -// of writing problems to http.ResponseWriter's in either of the two standard -// media formats JSON and XML. +// 1. You can embed a problem in your extension problem type. +// 2. You can use the ExtendedProblem to leverage the existing types in this +// library. +// +// See the examples for references on how to use either of these extension +// mechanisms. +// +// Additionally, this library also ships with default http.HandlerFunc +// implementations which are capable of writing problems to a +// http.ResponseWriter in either of the two standard media formats, JSON and +// XML. package problems diff --git a/doc_test.go b/doc_test.go index 82a8e40..fb12f6a 100644 --- a/doc_test.go +++ b/doc_test.go @@ -3,6 +3,7 @@ package problems_test import ( "encoding/json" "fmt" + "net/http" "github.com/moogar0880/problems" ) @@ -30,3 +31,62 @@ func ExampleNewStatusProblem_detailed() { // "detail": "The item you've requested either does not exist or has been deleted." // } } + +func ExampleExtendedProblem() { + type CreditProblemExt struct { + Balance float64 `json:"balance"` + Accounts []string `json:"accounts"` + } + problem := problems.NewExt[CreditProblemExt](). + WithStatus(http.StatusForbidden). + WithDetail("You do not have sufficient funds to complete this transaction."). + WithExtension(CreditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }) + b, _ := json.MarshalIndent(problem, "", " ") + fmt.Println(string(b)) + // Output: { + // "type": "about:blank", + // "title": "Forbidden", + // "status": 403, + // "detail": "You do not have sufficient funds to complete this transaction.", + // "extensions": { + // "balance": 30, + // "accounts": [ + // "/account/12345", + // "/account/67890" + // ] + // } + // } +} + +func ExampleExtendedProblem_embedding() { + type CreditProblem struct { + problems.Problem + + Balance float64 `json:"balance"` + Accounts []string `json:"accounts"` + } + problem := &CreditProblem{ + Problem: *problems.New(). + WithStatus(http.StatusForbidden). + WithDetail("You do not have sufficient funds to complete this transaction."), + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + } + + b, _ := json.MarshalIndent(problem, "", " ") + fmt.Println(string(b)) + // Output: { + // "type": "about:blank", + // "title": "Forbidden", + // "status": 403, + // "detail": "You do not have sufficient funds to complete this transaction.", + // "balance": 30, + // "accounts": [ + // "/account/12345", + // "/account/67890" + // ] + // } +} From b3a6074369347e1ea3a17ff2187c6c7beebb0ede Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:25:23 -0400 Subject: [PATCH 08/13] add explicit extension types --- ext.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++ ext_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 ext.go create mode 100644 ext_test.go diff --git a/ext.go b/ext.go new file mode 100644 index 0000000..c873da5 --- /dev/null +++ b/ext.go @@ -0,0 +1,75 @@ +package problems + +import ( + "net/http" +) + +// An ExtendedProblem extends the Problem type with a new field, Extensions, +// of type T. +type ExtendedProblem[T any] struct { + Problem + + // Extensions allows for Problem type definitions to extend the standard + // problem details object with additional members that are specific to that + // problem type. + Extensions T `json:"extensions,omitempty" xml:"extensions,omitempty"` +} + +// NewExt returns a new ExtendedProblem with all the same default values +// as applied by a call to New. +func NewExt[T any]() *ExtendedProblem[T] { + return &ExtendedProblem[T]{ + Problem: *New(), + } +} + +// Extend allows you to convert a standard Problem instance to an +// ExtendedProblem with the provided extension data. +func Extend[T any](p *Problem, ext T) *ExtendedProblem[T] { + return &ExtendedProblem[T]{ + Problem: *p, + Extensions: ext, + } +} + +// WithType sets the type field to the provided string. +func (p *ExtendedProblem[T]) WithType(typ string) *ExtendedProblem[T] { + p.Type = typ + return p +} + +// WithTitle sets the title field to the provided string. +func (p *ExtendedProblem[T]) WithTitle(title string) *ExtendedProblem[T] { + p.Title = title + return p +} + +// WithStatus sets the status field to the provided int. +// +// If no title is set then this call will also set the title to the return +// value of http.StatusText for the provided status code. +func (p *ExtendedProblem[T]) WithStatus(status int) *ExtendedProblem[T] { + p.Status = status + if p.Title == "" { + p.Title = http.StatusText(status) + } + return p +} + +// WithDetail sets the detail message to the provided string. +func (p *ExtendedProblem[T]) WithDetail(detail string) *ExtendedProblem[T] { + p.Detail = detail + return p +} + +// WithInstance sets the instance uri to the provided string. +func (p *ExtendedProblem[T]) WithInstance(instance string) *ExtendedProblem[T] { + p.Instance = instance + return p +} + +// WithExtension sets the extensions value to the provided extension of type T. +func (p *ExtendedProblem[T]) WithExtension(ext T) *ExtendedProblem[T] { + p.Extensions = ext + return p +} diff --git a/ext_test.go b/ext_test.go new file mode 100644 index 0000000..975014d --- /dev/null +++ b/ext_test.go @@ -0,0 +1,86 @@ +package problems + +import ( + "encoding/json" + "net/http" + "testing" +) + +type creditProblemExt struct { + Balance float64 `json:"balance"` + Accounts []string `json:"accounts"` +} + +type creditProblem struct { + Problem + + Balance float64 `json:"balance"` + Accounts []string `json:"accounts"` +} + +var unAuthDetails = "you are unauthorized to access this resource" + +func TestExtend(t *testing.T) { + problem := Extend[creditProblemExt]( + New(). + WithStatus(http.StatusUnauthorized). + WithDetail(unAuthDetails), + creditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }, + ) + + if _, err := problem.Validate(); err != nil { + t.Errorf("problem is not valid: %s", err) + } + + data, err := json.Marshal(problem) + if err != nil { + t.Errorf("failed to marshal extended problem as json: %s", err) + } + + expect := `{"type":"about:blank","title":"Unauthorized","status":401,"detail":"you are unauthorized to access this resource","extensions":{"balance":30,"accounts":["/account/12345","/account/67890"]}}` + + if string(data) != expect { + t.Errorf("extended problem does not match expectation:\ngot\n%s\nwant\n%s", string(data), expect) + } +} + +func TestExtensionViaEmbedding(t *testing.T) { + problem := &creditProblem{ + Problem: *New(). + WithStatus(http.StatusUnauthorized). + WithDetail(unAuthDetails), + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + } + + if _, err := problem.Validate(); err != nil { + t.Errorf("problem is not valid: %s", err) + } + + data, err := json.Marshal(problem) + if err != nil { + t.Errorf("failed to marshal extended problem as json: %s", err) + } + + expect := `{"type":"about:blank","title":"Unauthorized","status":401,"detail":"you are unauthorized to access this resource","balance":30,"accounts":["/account/12345","/account/67890"]}` + + if string(data) != expect { + t.Errorf("extended problem does not match expectation:\ngot\n%s\nwant\n%s", string(data), expect) + } +} + +func TestExtension(t *testing.T) { + problem := NewExt[creditProblemExt](). + WithType("https://example.com"). + WithTitle("This is a custom title"). + WithStatus(http.StatusBadRequest). + WithDetail("Here are some details"). + WithInstance("https://example.com/errors/150") + + if _, err := problem.Validate(); err != nil { + t.Errorf("extended problem is not valid but should be: %s", err) + } +} From ea01c44b4334df0211657ce41b72f74f4ebd9fb5 Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:25:51 -0400 Subject: [PATCH 09/13] various web fixes and updates --- web.go | 45 +++++++++++-------------------- web_test.go | 76 +++-------------------------------------------------- 2 files changed, 20 insertions(+), 101 deletions(-) diff --git a/web.go b/web.go index 1242f74..c49c103 100644 --- a/web.go +++ b/web.go @@ -6,44 +6,31 @@ import ( "net/http" ) -// ProblemHandler returns an http.HandlerFunc which writes a provided problem -// to an http.ResponseWriter as JSON -func ProblemHandler(p Problem) http.HandlerFunc { +// ProblemHandler returns a http.HandlerFunc which writes a provided problem +// to a http.ResponseWriter as JSON with the status code. +func ProblemHandler(p *Problem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", ProblemMediaType) - _ = json.NewEncoder(w).Encode(p) - } -} - -// XMLProblemHandler returns an http.HandlerFunc which writes a provided problem -// to an http.ResponseWriter as XML -func XMLProblemHandler(p Problem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", ProblemMediaTypeXML) - _ = xml.NewEncoder(w).Encode(p) - } -} - -// StatusProblemHandler returns an http.HandlerFunc which writes a provided -// problem to an http.ResponseWriter as JSON with the status code -func StatusProblemHandler(p StatusProblem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", ProblemMediaType) - if p.ProblemStatus() != 0 { - w.WriteHeader(p.ProblemStatus()) + if p.Status != 0 { + w.WriteHeader(p.Status) } _ = json.NewEncoder(w).Encode(p) } } -// XMLStatusProblemHandler returns an http.HandlerFunc which writes a provided -// problem to an http.ResponseWriter as XML with the status code -func XMLStatusProblemHandler(p StatusProblem) http.HandlerFunc { +// XMLProblemHandler returns a http.HandlerFunc which writes a provided problem +// to a http.ResponseWriter as XML with the status code. +func XMLProblemHandler(p *Problem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", ProblemMediaTypeXML) - if p.ProblemStatus() != 0 { - w.WriteHeader(p.ProblemStatus()) + if p.Status != 0 { + w.WriteHeader(p.Status) } - _ = xml.NewEncoder(w).Encode(p) + _ = xml.NewEncoder(w).Encode(struct { + XMLName xml.Name `xml:"urn:ietf:rfc:7807 problem"` + Problem + }{ + Problem: *p, + }) } } diff --git a/web_test.go b/web_test.go index e4e284f..7bf94e5 100644 --- a/web_test.go +++ b/web_test.go @@ -27,13 +27,8 @@ func getResponse(uri string, server *httptest.Server) (*http.Response, error) { return res, nil } -type MyDecoder interface { - Decode(v interface{}) error -} - func TestJSONProblems(t *testing.T) { - notFound := NewStatusProblem(http.StatusNotFound) - notFound.Detail = "That thing doesn't exist." + notFound := NewDetailedProblem(http.StatusNotFound, "That thing doesn't exist.") server := testServer(ProblemHandler(notFound)) defer server.Close() @@ -43,42 +38,11 @@ func TestJSONProblems(t *testing.T) { t.Error(err) } - var response DefaultProblem - err = json.NewDecoder(w.Body).Decode(&response) - if err != nil { - t.Error(err) - } - - if response.Status != notFound.Status { - t.Errorf("Expected response Status to be %d, but got %d", notFound.Status, response.Status) - } - - if response.Title != notFound.Title { - t.Errorf("Expected response Title to be %q, but got %q", notFound.Title, response.Title) - } - - if response.Detail != notFound.Detail { - t.Errorf("Expected response Detail to be %q, but got %q", notFound.Detail, response.Detail) - } -} - -func TestJSONStatusProblems(t *testing.T) { - notFound := NewStatusProblem(http.StatusNotFound) - notFound.Detail = "That thing doesn't exist." - - server := testServer(StatusProblemHandler(notFound)) - defer server.Close() - - w, err := getResponse("/", server) - if err != nil { - t.Error(err) - } - if w.StatusCode != notFound.Status { t.Errorf("Expected HTTP status code to be %d, got %d", notFound.Status, w.StatusCode) } - var response DefaultProblem + var response Problem err = json.NewDecoder(w.Body).Decode(&response) if err != nil { t.Error(err) @@ -98,8 +62,7 @@ func TestJSONStatusProblems(t *testing.T) { } func TestXMLProblems(t *testing.T) { - notFound := NewStatusProblem(http.StatusNotFound) - notFound.Detail = "That thing doesn't exist." + notFound := NewDetailedProblem(http.StatusNotFound, "That thing doesn't exist.") server := testServer(XMLProblemHandler(notFound)) defer server.Close() @@ -109,42 +72,11 @@ func TestXMLProblems(t *testing.T) { t.Error(err) } - var response DefaultProblem - err = xml.NewDecoder(w.Body).Decode(&response) - if err != nil { - t.Error(err) - } - - if response.Status != notFound.Status { - t.Errorf("Expected response Status to be %d, but got %d", notFound.Status, response.Status) - } - - if response.Title != notFound.Title { - t.Errorf("Expected response Title to be %q, but got %q", notFound.Title, response.Title) - } - - if response.Detail != notFound.Detail { - t.Errorf("Expected response Detail to be %q, but got %q", notFound.Detail, response.Detail) - } -} - -func TestXMLStatusProblems(t *testing.T) { - notFound := NewStatusProblem(404) - notFound.Detail = "That thing doesn't exist." - - server := testServer(XMLStatusProblemHandler(notFound)) - defer server.Close() - - w, err := getResponse("/", server) - if err != nil { - t.Error(err) - } - if w.StatusCode != notFound.Status { t.Errorf("Expected HTTP status code to be %d, got %d", notFound.Status, w.StatusCode) } - var response DefaultProblem + var response Problem err = xml.NewDecoder(w.Body).Decode(&response) if err != nil { t.Error(err) From 76dbb5cf21cd599a3e97e4d7859eb12cfd6c765d Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:26:22 -0400 Subject: [PATCH 10/13] adding problem validation logic + types --- validation.go | 150 +++++++++++++++++++++++++++++++++++++++++++++ validation_test.go | 142 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 validation.go create mode 100644 validation_test.go diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..4555ffe --- /dev/null +++ b/validation.go @@ -0,0 +1,150 @@ +package problems + +import ( + "encoding/json" + "net/url" +) + +// A ValidProblem is a sealed variant of Problem which is guaranteed to have +// valid fields. +// +// Instances of ValidProblem can be created by using the Validate method from +// the Problem type. +type ValidProblem struct { + // Type contains a URI that identifies the problem type. This URI will, + // ideally, contain human-readable documentation for the problem when + // de-referenced. + typ *url.URL + + // Title is a short, human-readable summary of the problem type. This title + // SHOULD NOT change from occurrence to occurrence of the problem, except + // for purposes of localization. + title string + + // The HTTP status code for this occurrence of the problem. + status int + + // A human-readable explanation specific to this occurrence of the problem. + detail string + + // A URI that identifies the specific occurrence of the problem. This URI + // may or may not yield further information if de-referenced. + instance string +} + +// Validate validates the content of the Problem instance. If the Problem is +// invalid, as defined by RFC-9457, then an error explaining the validation +// error is returned. Otherwise, a sealed ValidProblem instance is returned. +// +// See the documentation for ErrTitleMustBeSet and ErrInvalidProblemType for +// more information on the validation errors returned by this method. +func (p *Problem) Validate() (*ValidProblem, error) { + typ, err := validate(p.Type, p.Title) + if err != nil { + return nil, err + } + + return &ValidProblem{ + typ: typ, + title: p.Title, + status: p.Status, + detail: p.Detail, + instance: p.Instance, + }, nil +} + +// IntoProblem allows you to convert from a ValidProblem back into a Problem. +func (p *ValidProblem) IntoProblem() *Problem { + return &Problem{ + Type: p.typ.String(), + Title: p.title, + Status: p.status, + Detail: p.detail, + Instance: p.instance, + } +} + +// MarshalJSON implements the json.Marshaler interface and ensures that a +// ValidProblem is properly serialized into JSON. +func (p *ValidProblem) MarshalJSON() ([]byte, error) { + return json.Marshal(p.IntoProblem()) +} + +// A ValidExtendedProblem is a sealed variant of ExtendedProblem which is +// guaranteed to contain valid fields. +// +// Instances of ValidExtendedProblem can be created by using the Validate +// method on the ExtendedProblem type. +type ValidExtendedProblem[T any] struct { + ValidProblem + + // Extensions allows for Problem type definitions to extend the standard + // problem details object with additional members that are specific to that + // problem type. + extensions T +} + +// Validate validates the content of the ExtendedProblem instance. If the +// ExtendedProblem is invalid, as defined by RFC-9457, then an error explaining +// the validation error is returned. Otherwise, a sealed ValidProblem instance +// is returned. +// +// See the documentation for ErrTitleMustBeSet and ErrInvalidProblemType for +// more information on the validation errors returned by this method. +func (p *ExtendedProblem[T]) Validate() (*ValidExtendedProblem[T], error) { + typ, err := validate(p.Type, p.Title) + if err != nil { + return nil, err + } + + return &ValidExtendedProblem[T]{ + ValidProblem: ValidProblem{ + typ: typ, + title: p.Title, + status: p.Status, + detail: p.Detail, + instance: p.Instance, + }, + extensions: p.Extensions, + }, nil +} + +// IntoProblem allows you to convert from a ValidExtendedProblem back into a +// Problem. +func (p *ValidExtendedProblem[T]) IntoProblem() *Problem { + return &Problem{ + Type: p.typ.String(), + Title: p.title, + Status: p.status, + Detail: p.detail, + Instance: p.instance, + } +} + +// IntoExtendedProblem allows you to convert from a ValidExtendedProblem back +// into an ExtendedProblem. +func (p *ValidExtendedProblem[T]) IntoExtendedProblem() *ExtendedProblem[T] { + return &ExtendedProblem[T]{ + Problem: *p.IntoProblem(), + Extensions: p.extensions, + } +} + +// MarshalJSON implements the json.Marshaler interface and ensures that a +// ValidExtendedProblem is properly serialized into JSON. +func (p *ValidExtendedProblem[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(p.IntoExtendedProblem()) +} + +func validate(typ, title string) (*url.URL, error) { + if len(title) == 0 { + return nil, ErrTitleMustBeSet + } + + typURL, err := url.Parse(typ) + if err != nil { + return nil, NewErrInvalidProblemType(typ, err) + } + + return typURL, nil +} diff --git a/validation_test.go b/validation_test.go new file mode 100644 index 0000000..e65b47a --- /dev/null +++ b/validation_test.go @@ -0,0 +1,142 @@ +package problems + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" +) + +func TestExtendedProblem_Validate(t *testing.T) { + tests := []struct { + name string + problem ExtendedProblem[creditProblemExt] + }{ + { + name: "should fail to parse invalid problem type", + problem: ExtendedProblem[creditProblemExt]{ + Problem: Problem{ + Type: "::/", + Title: http.StatusText(http.StatusBadRequest), + }, + Extensions: creditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }, + }, + }, + { + name: "should fail to validate an empty problem title", + problem: ExtendedProblem[creditProblemExt]{ + Problem: Problem{ + Type: DefaultURL, + }, + Extensions: creditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.problem.Type, func(t *testing.T) { + _, err := test.problem.Validate() + if err == nil { + t.Errorf("problem is not valid but passed validation") + } + + if len(err.Error()) == 0 { + t.Errorf("problem is invalid and no error message was present") + } + }) + } +} + +func TestProblem_Validate(t *testing.T) { + tests := []struct { + name string + problem Problem + }{ + { + name: "should fail to parse invalid problem type", + problem: Problem{ + Type: "::/", + Title: http.StatusText(http.StatusBadRequest), + }, + }, + { + name: "should fail to validate an empty problem title", + problem: Problem{ + Type: DefaultURL, + }, + }, + } + + for _, test := range tests { + t.Run(test.problem.Type, func(t *testing.T) { + _, err := test.problem.Validate() + if err == nil { + t.Errorf("problem is not valid but passed validation") + } + + if len(err.Error()) == 0 { + t.Errorf("problem is invalid and no error message was present") + } + }) + } +} + +func TestValidExtendedProblem_MarshalJSON(t *testing.T) { + problem := NewExt[creditProblemExt](). + WithStatus(http.StatusUnauthorized). + WithDetail(unAuthDetails). + WithExtension(creditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }) + + valid, err := problem.Validate() + if err != nil { + t.Errorf("problem is not valid: %s", err) + } + + expect, err := json.Marshal(problem) + if err != nil { + t.Errorf("failed to marshal extended problem as json: %s", err) + } + + validated, err := json.Marshal(valid) + if err != nil { + t.Errorf("failed to marshal valid extended problem as json: %s", err) + } + + if !bytes.Equal(expect, validated) { + t.Errorf("extended problem does not match validated") + } +} + +func TestValidProblem_MarshalJSON(t *testing.T) { + problem := New(). + WithStatus(http.StatusUnauthorized). + WithDetail(unAuthDetails) + + valid, err := problem.Validate() + if err != nil { + t.Errorf("problem is not valid: %s", err) + } + + expect, err := json.Marshal(problem) + if err != nil { + t.Errorf("failed to marshal problem as json: %s", err) + } + + validated, err := json.Marshal(valid) + if err != nil { + t.Errorf("failed to marshal valid problem as json: %s", err) + } + + if !bytes.Equal(expect, validated) { + t.Errorf("problem does not match validated") + } +} From cf9bdd6b5817279d3762a88112183522736afdd1 Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Mon, 24 Mar 2025 23:26:36 -0400 Subject: [PATCH 11/13] updating problem definitions and usage --- README.md | 76 +++++++++++----- problem.go | 122 +++++++++++--------------- problem_test.go | 229 +++++++++++++++++++++++++++--------------------- 3 files changed, 233 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index 48d86c2..0c8e4f7 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,23 @@ # problems -Problems is an RFC-7807 compliant library for describing HTTP errors, written -purely in Go. For more information see [RFC-7807](https://tools.ietf.org/html/rfc7807). +Problems is an RFC-7807 and RFC-9457 compliant library for describing HTTP +errors. For more information see [RFC-9457](https://tools.ietf.org/html/rfc9457), +and it's predecessor [RFC-7807](https://tools.ietf.org/html/rfc7807). [![Build Status](https://travis-ci.org/moogar0880/problems.svg?branch=master)](https://travis-ci.org/moogar0880/problems) [![Go Report Card](https://goreportcard.com/badge/github.com/moogar0880/problems)](https://goreportcard.com/report/github.com/moogar0880/problems) [![GoDoc](https://godoc.org/github.com/moogar0880/problems?status.svg)](https://godoc.org/github.com/moogar0880/problems) ## Usage -The problems library exposes an assortment of interfaces, structs, and functions -for defining and using HTTP Problem detail resources. +The problems library exposes an assortment of types to aid HTTP service authors +in defining and using HTTP Problem detail resources. ### Predefined Errors You can define basic Problem details up front by using the `NewStatusProblem` function ```go +package main + import "github.com/moogar0880/problems" var ( @@ -39,6 +42,8 @@ Which, when served over HTTP as JSON will look like the following: New errors can also be created a head of time, or on the fly like so: ```go +package main + import "github.com/moogar0880/problems" func NoSuchUser() *problems.DefaultProblem { @@ -59,31 +64,60 @@ Which, when served over HTTP as JSON will look like the following: } ``` -### Expanded Errors +### Extended Errors The specification for these HTTP problems was designed to allow for arbitrary expansion of the problem resources. This can be accomplished through this -library by implementing the `Problem` interface: +library by either embedding the `Problem` struct in your extension type: ```go +package main + import "github.com/moogar0880/problems" type CreditProblem struct { - problems.DefaultProblem + problems.Problem + + Balance float64 `json:"balance"` + Accounts []string `json:"accounts"` +} +``` + +Which, when served over HTTP as JSON will look like the following: - Balance float64 - Accounts []string +```json +{ + "type": "about:blank", + "title": "Unauthorized", + "status": 401, + "balance": 30, + "accounts": ["/account/12345", "/account/67890"] } +``` + +Or by using the `problems.ExtendedProblem` type: + +```go +package main + +import ( + "net/http" + + "github.com/moogar0880/problems" +) -func (cp *CreditProblem) ProblemType() (*url.URL, error) { - u, err := url.Parse(cp.Type) - if err != nil { - return nil, err - } - return u, nil +type CreditProblemExt struct { + Balance float64 `json:"balance"` + Accounts []string `json:"accounts"` } -func (cp *CreditProblem) ProblemTitle() string { - return cp.Title +func main() { + problems.NewExt[CreditProblemExt](). + WithStatus(http.StatusForbidden). + WithDetail("Your account does not have sufficient funds to complete this transaction"). + WithExtension(CreditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }) } ``` @@ -93,9 +127,11 @@ Which, when served over HTTP as JSON will look like the following: { "type": "about:blank", "title": "Unauthorized", - "status": 401, - "balance": 30, - "accounts": ["/account/12345", "/account/67890"] + "status": 401, + "extensions": { + "balance": 30, + "accounts": ["/account/12345", "/account/67890"] + } } ``` diff --git a/problem.go b/problem.go index f2717f7..9dca606 100644 --- a/problem.go +++ b/problem.go @@ -1,9 +1,6 @@ package problems -import ( - "net/http" - "net/url" -) +import "net/http" const ( // ProblemMediaType is the default media type for a Problem response @@ -16,103 +13,84 @@ const ( DefaultURL = "about:blank" ) -// Problem is the interface describing an HTTP API problem. These "problem -// details" are designed to encompass a way to carry machine- readable details -// of errors in a HTTP response to avoid the need to define new error response -// formats for HTTP APIs. -type Problem interface { - ProblemType() (*url.URL, error) - ProblemTitle() string -} - -// StatusProblem is the interface describing a problem with an associated -// Status code. -type StatusProblem interface { - Problem - ProblemStatus() int -} - -// ValidateProblem ensures that the provided Problem implementation meets the -// Problem description requirements. Which means that the Type is a valid uri, -// and that the Title be a non-empty string. Should the provided Problem be in -// violation of either of these requirements, an error is returned. -func ValidateProblem(p Problem) error { - if _, err := p.ProblemType(); err != nil { - return NewErrInvalidProblemType(err) - } - - if p.ProblemTitle() == "" { - return ErrTitleMustBeSet - } - return nil -} - -// DefaultProblem is a default problem implementation. The Problem specification -// allows for arbitrary extensions to include new fields, in which case a new -// Problem implementation will likely be required. -type DefaultProblem struct { +// A Problem defines all the standard problem detail fields as defined by +// RFC-9457 and can easily be serialized to either JSON or XML. +// +// To add extensions to a Problem definition, see ExtendedProblem or consider +// embedding a Problem in your extension struct. +type Problem struct { // Type contains a URI that identifies the problem type. This URI will, // ideally, contain human-readable documentation for the problem when // de-referenced. - Type string `json:"type"` + Type string `json:"type" xml:"type"` // Title is a short, human-readable summary of the problem type. This title - // SHOULD NOT change from occurrence to occurrence of the problem, except for - // purposes of localization. - Title string `json:"title"` + // SHOULD NOT change from occurrence to occurrence of the problem, except + // for purposes of localization. + Title string `json:"title" xml:"title"` // The HTTP status code for this occurrence of the problem. - Status int `json:"status,omitempty"` + Status int `json:"status,omitempty" xml:"status,omitempty"` // A human-readable explanation specific to this occurrence of the problem. - Detail string `json:"detail,omitempty"` + Detail string `json:"detail,omitempty" xml:"detail,omitempty"` // A URI that identifies the specific occurrence of the problem. This URI // may or may not yield further information if de-referenced. - Instance string `json:"instance,omitempty"` + Instance string `json:"instance,omitempty" xml:"instance,omitempty"` } -// NewProblem returns a new instance of a DefaultProblem with the DefaultURL -// set as the problem Type. -func NewProblem() *DefaultProblem { - return &DefaultProblem{Type: DefaultURL} +// New returns a new Problem instance with the type field set to DefaultURL. +func New() *Problem { + return &Problem{Type: DefaultURL} } // NewStatusProblem will generate a default problem for the provided HTTP status // code. The Problem's Status field will be set to match the status argument, // and the Title will be set to the default Go status text for that code. -func NewStatusProblem(status int) *DefaultProblem { - p := NewProblem() - p.Title = http.StatusText(int(status)) - p.Status = status - return p +func NewStatusProblem(status int) *Problem { + return New().WithTitle(http.StatusText(status)).WithStatus(status) } // NewDetailedProblem returns a new DefaultProblem with a Detail string set for // a more detailed explanation of the problem being returned. -func NewDetailedProblem(status int, details string) *DefaultProblem { - p := NewStatusProblem(status) - p.Detail = details +func NewDetailedProblem(status int, details string) *Problem { + return NewStatusProblem(status).WithDetail(details) +} + +// WithType sets the type field to the provided string. +func (p *Problem) WithType(typ string) *Problem { + p.Type = typ + return p +} + +// WithTitle sets the title field to the provided string. +func (p *Problem) WithTitle(title string) *Problem { + p.Title = title return p } -// ProblemType returns the uri for the problem type being defined and an -// optional error if the specified Type is not a valid URI. -func (p *DefaultProblem) ProblemType() (*url.URL, error) { - u, err := url.Parse(p.Type) - if err != nil { - return nil, err +// WithStatus sets the status field to the provided int. +// +// If no title is set then this call will also set the title to the return +// value of http.StatusText for the provided status code. +func (p *Problem) WithStatus(status int) *Problem { + p.Status = status + if p.Title == "" { + p.Title = http.StatusText(status) } - return u, nil + + return p } -// ProblemTitle returns the unique title field for this Problem. -func (p *DefaultProblem) ProblemTitle() string { - return p.Title +// WithDetail sets the detail message to the provided string. +func (p *Problem) WithDetail(detail string) *Problem { + p.Detail = detail + return p } -// ProblemStatus allows the DefaultStatusProblem to implement the StatusProblem -// interface, returning the Status code for this problem. -func (p *DefaultProblem) ProblemStatus() int { - return p.Status +// WithInstance sets the instance uri to the provided string. +func (p *Problem) WithInstance(instance string) *Problem { + p.Instance = instance + return p } diff --git a/problem_test.go b/problem_test.go index 7d3d1a6..e4ea6b5 100644 --- a/problem_test.go +++ b/problem_test.go @@ -1,114 +1,139 @@ package problems import ( - "errors" + "encoding/json" "net/http" - "net/url" + "reflect" "testing" ) -var unAuthDetails = "you are unauthorized to access this resource" - -func TestDefaultProblem(t *testing.T) { - problem := NewDetailedProblem(http.StatusUnauthorized, unAuthDetails) - - typ, err := problem.ProblemType() - if err != nil { - t.Errorf("Unable to read problem type") - } - if typ != nil && typ.String() != problem.Type { - t.Errorf("Problem Types did not match") - } - - if problem.ProblemTitle() != problem.Title { - t.Errorf("Problem Titles did not match") +func TestProblem(t *testing.T) { + tests := []struct { + name string + problem *Problem + expect Problem + }{ + { + name: "should support problem with only title and status", + problem: NewStatusProblem(http.StatusNotFound), + expect: Problem{ + Type: DefaultURL, + Title: http.StatusText(http.StatusNotFound), + Status: http.StatusNotFound, + }, + }, + { + name: "should support problem with title, status, and details", + problem: NewDetailedProblem(http.StatusNotFound, "couldn't find it"), + expect: Problem{ + Type: DefaultURL, + Title: http.StatusText(http.StatusNotFound), + Status: http.StatusNotFound, + Detail: "couldn't find it", + }, + }, + { + name: "should default title to match status code", + problem: New(). + WithType("https://example.com"). + WithStatus(http.StatusBadRequest). + WithDetail("Here are some details"). + WithInstance("https://example.com/errors/150"), + expect: Problem{ + Type: "https://example.com", + Title: "Bad Request", + Status: http.StatusBadRequest, + Detail: "Here are some details", + Instance: "https://example.com/errors/150", + }, + }, + { + name: "should maintain custom title when setting status code", + problem: New(). + WithType("https://example.com"). + WithTitle("This is an example"). + WithStatus(http.StatusBadRequest). + WithDetail("Here are some details"). + WithInstance("https://example.com/errors/150"), + expect: Problem{ + Type: "https://example.com", + Title: "This is an example", + Status: http.StatusBadRequest, + Detail: "Here are some details", + Instance: "https://example.com/errors/150", + }, + }, } - err = ValidateProblem(problem) - if err != nil { - t.Errorf("problem is not valid") + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + data, err := json.Marshal(test.problem) + if err != nil { + t.Errorf("Error marshalling problem data: %s", err) + } + + var problem Problem + if err = json.Unmarshal(data, &problem); err != nil { + t.Errorf("Error unmarshalling problem data: %s", err) + } + + if !reflect.DeepEqual(problem, test.expect) { + t.Errorf("problems were not equal: wanted\n%#+v\n but got\n%#+v", test.expect, problem) + } + }) } } -type badProblemType struct{} - -func (p badProblemType) ProblemType() (*url.URL, error) { - return nil, errors.New("i am a bad problem type") -} - -func (p badProblemType) ProblemTitle() string { - return "something valid" -} - -type badProblemTitle struct{} - -func (p badProblemTitle) ProblemType() (*url.URL, error) { - return &url.URL{}, nil -} - -func (p badProblemTitle) ProblemTitle() string { - return "" -} - -func TestValidateProblem(t *testing.T) { - var err error - err = ValidateProblem(badProblemType{}) - if err == nil { - t.Error("Only valid URI's should be allowed as problem types") - } - - err = ValidateProblem(badProblemTitle{}) - if err == nil { - t.Errorf("Empty strings should not be allowed as problem titles") - } - - badURI := "::/" - err = ValidateProblem(&DefaultProblem{Type: badURI}) - if err == nil { - t.Errorf("%q was allowed as a valid URI", badURI) - } -} - -type creditProblem struct { - DefaultProblem - - Balance float64 `json:"balance"` - Accounts []string `json:"accounts"` -} - -func (cp *creditProblem) ProblemType() (*url.URL, error) { - u, err := url.Parse(cp.Type) - if err != nil { - return nil, err - } - return u, nil -} - -func (cp *creditProblem) ProblemTitle() string { - return cp.Title -} - -func TestCreditProblem(t *testing.T) { - problem := &creditProblem{ - DefaultProblem: *NewDetailedProblem(http.StatusUnauthorized, unAuthDetails), - Balance: 30, - Accounts: []string{"/account/12345", "/account/67890"}, - } - - typ, err := problem.ProblemType() - if err != nil { - t.Errorf("Unable to read problem type") - } - if typ != nil && typ.String() != problem.Type { - t.Errorf("Problem Types did not match") - } - - if problem.ProblemTitle() != problem.Title { - t.Errorf("Problem Titles did not match") - } - - err = ValidateProblem(problem) - if err != nil { - t.Errorf("problem is not valid") - } -} +// func TestDefaultProblem(t *testing.T) { +// problem := NewDetailedProblem(http.StatusUnauthorized, unAuthDetails) +// +// if _, err := problem.Validate(); err != nil { +// t.Errorf("problem is not valid") +// } +// } + +// func (cp *creditProblem) ProblemType() (*url.URL, error) { +// u, err := url.Parse(cp.Type) +// if err != nil { +// return nil, err +// } +// return u, nil +// } +// +// func (cp *creditProblem) ProblemTitle() string { +// return cp.Title +// } +// var unAuthDetails = "you are unauthorized to access this resource" + +// func TestCreditProblem(t *testing.T) { +// // problem := &creditProblem{ +// // DefaultProblem: *NewDetailedProblem(http.StatusUnauthorized, unAuthDetails), +// // Balance: 30, +// // Accounts: []string{"/account/12345", "/account/67890"}, +// // } +// +// problem := NewExt[creditProblemExt](). +// WithStatus(http.StatusUnauthorized). +// WithDetail(unAuthDetails). +// WithExtension(creditProblemExt{ +// Balance: 30, +// Accounts: []string{"/account/12345", "/account/67890"}, +// }) +// +// // typ, err := problem.ProblemType() +// // if err != nil { +// // t.Errorf("Unable to read problem type") +// // } +// // if typ != nil && typ.String() != problem.Type { +// // t.Errorf("Problem Types did not match") +// // } +// // +// // if problem.ProblemTitle() != problem.Title { +// // t.Errorf("Problem Titles did not match") +// // } +// // +// // err = ValidateProblem(problem) +// if _, err := problem.Validate(); err != nil { +// t.Errorf("problem is not valid: %s", err) +// } +// } From a9f59155ceec2bfd7c905a71642f7eff9f5a40b8 Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Wed, 26 Mar 2025 19:14:39 -0400 Subject: [PATCH 12/13] add ability to convert to/from errors and update examples --- ext.go | 28 ++++++++++++++++ ext_test.go | 78 ++++++++++++++++++++++++++++++++----------- problem.go | 30 ++++++++++++++++- problem_test.go | 88 +++++++++++++++++++------------------------------ 4 files changed, 149 insertions(+), 75 deletions(-) diff --git a/ext.go b/ext.go index c873da5..1a3d26b 100644 --- a/ext.go +++ b/ext.go @@ -1,6 +1,8 @@ package problems import ( + "encoding/json" + "fmt" "net/http" ) @@ -23,6 +25,12 @@ func NewExt[T any]() *ExtendedProblem[T] { } } +// ExtFromError returns a new ExtendedProblem instance which contains the +// string version of the provided error as the details of the problem. +func ExtFromError[T any](err error) *ExtendedProblem[T] { + return NewExt[T]().WithError(err) +} + // Extend allows you to convert a standard Problem instance to an // ExtendedProblem with the provided extension data. func Extend[T any](p *Problem, ext T) *ExtendedProblem[T] { @@ -62,6 +70,19 @@ func (p *ExtendedProblem[T]) WithDetail(detail string) *ExtendedProblem[T] { return p } +// WithDetailf behaves identically to WithDetail, but allows consumers to +// provide a format string and arguments which will be formatted internally. +func (p *ExtendedProblem[T]) WithDetailf(format string, args ...interface{}) *ExtendedProblem[T] { + p.Detail = fmt.Sprintf(format, args...) + return p +} + +// WithError sets the detail message to the provided error. +func (p *ExtendedProblem[T]) WithError(err error) *ExtendedProblem[T] { + p.Detail = err.Error() + return p +} + // WithInstance sets the instance uri to the provided string. func (p *ExtendedProblem[T]) WithInstance(instance string) *ExtendedProblem[T] { p.Instance = instance @@ -73,3 +94,10 @@ func (p *ExtendedProblem[T]) WithExtension(ext T) *ExtendedProblem[T] { p.Extensions = ext return p } + +// Error implements the error interface and allows a Problem to be used as a +// native error. +func (p *ExtendedProblem[T]) Error() string { + ext, _ := json.Marshal(p.Extensions) + return fmt.Sprintf("%s (%d) - %s - %s", p.Title, p.Status, p.Detail, string(ext)) +} diff --git a/ext_test.go b/ext_test.go index 975014d..0390bce 100644 --- a/ext_test.go +++ b/ext_test.go @@ -2,6 +2,7 @@ package problems import ( "encoding/json" + "errors" "net/http" "testing" ) @@ -21,29 +22,66 @@ type creditProblem struct { var unAuthDetails = "you are unauthorized to access this resource" func TestExtend(t *testing.T) { - problem := Extend[creditProblemExt]( - New(). - WithStatus(http.StatusUnauthorized). - WithDetail(unAuthDetails), - creditProblemExt{ - Balance: 30, - Accounts: []string{"/account/12345", "/account/67890"}, + tests := []struct { + name string + problem *ExtendedProblem[creditProblemExt] + expectJson string + }{ + { + name: "should render properly with Extend", + problem: Extend[creditProblemExt]( + New(). + WithStatus(http.StatusUnauthorized). + WithDetail(unAuthDetails), + creditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }, + ), + expectJson: `{"type":"about:blank","title":"Unauthorized","status":401,"detail":"you are unauthorized to access this resource","extensions":{"balance":30,"accounts":["/account/12345","/account/67890"]}}`, + }, + { + name: "should render properly with Extend", + problem: NewExt[creditProblemExt](). + WithStatus(http.StatusUnauthorized). + WithDetailf("account %d has insufficient funds", 12345). + WithExtension(creditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }), + expectJson: `{"type":"about:blank","title":"Unauthorized","status":401,"detail":"account 12345 has insufficient funds","extensions":{"balance":30,"accounts":["/account/12345","/account/67890"]}}`, + }, + { + name: "should render properly with Error", + problem: ExtFromError[creditProblemExt](errors.New("an error occurred")). + WithStatus(http.StatusUnauthorized). + WithExtension(creditProblemExt{ + Balance: 30, + Accounts: []string{"/account/12345", "/account/67890"}, + }), + expectJson: `{"type":"about:blank","title":"Unauthorized","status":401,"detail":"an error occurred","extensions":{"balance":30,"accounts":["/account/12345","/account/67890"]}}`, }, - ) - - if _, err := problem.Validate(); err != nil { - t.Errorf("problem is not valid: %s", err) - } - - data, err := json.Marshal(problem) - if err != nil { - t.Errorf("failed to marshal extended problem as json: %s", err) } - expect := `{"type":"about:blank","title":"Unauthorized","status":401,"detail":"you are unauthorized to access this resource","extensions":{"balance":30,"accounts":["/account/12345","/account/67890"]}}` - - if string(data) != expect { - t.Errorf("extended problem does not match expectation:\ngot\n%s\nwant\n%s", string(data), expect) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := test.problem.Validate(); err != nil { + t.Errorf("problem is not valid: %s", err) + } + + data, err := json.Marshal(test.problem) + if err != nil { + t.Errorf("failed to marshal extended problem as json: %s", err) + } + + if string(data) != test.expectJson { + t.Errorf("extended problem does not match expectation:\ngot\n%s\nwant\n%s", string(data), test.expectJson) + } + + if len(test.problem.Error()) == 0 { + t.Errorf("extended problem error message was empty") + } + }) } } diff --git a/problem.go b/problem.go index 9dca606..5445ca0 100644 --- a/problem.go +++ b/problem.go @@ -1,6 +1,9 @@ package problems -import "net/http" +import ( + "fmt" + "net/http" +) const ( // ProblemMediaType is the default media type for a Problem response @@ -45,6 +48,12 @@ func New() *Problem { return &Problem{Type: DefaultURL} } +// FromError returns a new Problem instance which contains the string version +// of the provided error as the details of the problem. +func FromError(err error) *Problem { + return New().WithError(err) +} + // NewStatusProblem will generate a default problem for the provided HTTP status // code. The Problem's Status field will be set to match the status argument, // and the Title will be set to the default Go status text for that code. @@ -89,8 +98,27 @@ func (p *Problem) WithDetail(detail string) *Problem { return p } +// WithDetailf behaves identically to WithDetail, but allows consumers to +// provide a format string and arguments which will be formatted internally. +func (p *Problem) WithDetailf(format string, args ...interface{}) *Problem { + p.Detail = fmt.Sprintf(format, args...) + return p +} + +// WithError sets the detail message to the provided error. +func (p *Problem) WithError(err error) *Problem { + p.Detail = err.Error() + return p +} + // WithInstance sets the instance uri to the provided string. func (p *Problem) WithInstance(instance string) *Problem { p.Instance = instance return p } + +// Error implements the error interface and allows a Problem to be used as a +// native error. +func (p *Problem) Error() string { + return fmt.Sprintf("%s (%d) - %s", p.Title, p.Status, p.Detail) +} diff --git a/problem_test.go b/problem_test.go index e4ea6b5..3c86c7c 100644 --- a/problem_test.go +++ b/problem_test.go @@ -2,6 +2,7 @@ package problems import ( "encoding/json" + "errors" "net/http" "reflect" "testing" @@ -63,6 +64,35 @@ func TestProblem(t *testing.T) { Instance: "https://example.com/errors/150", }, }, + { + name: "should format detail message", + problem: New(). + WithType("https://example.com"). + WithStatus(http.StatusBadRequest). + WithDetailf("%q is not a valid integer", "foo"). + WithInstance("https://example.com/errors/150"), + expect: Problem{ + Type: "https://example.com", + Title: "Bad Request", + Status: http.StatusBadRequest, + Detail: `"foo" is not a valid integer`, + Instance: "https://example.com/errors/150", + }, + }, + { + name: "should use error for detail message", + problem: FromError(errors.New("an error occurred")). + WithType("https://example.com"). + WithStatus(http.StatusBadRequest). + WithInstance("https://example.com/errors/150"), + expect: Problem{ + Type: "https://example.com", + Title: "Bad Request", + Status: http.StatusBadRequest, + Detail: "an error occurred", + Instance: "https://example.com/errors/150", + }, + }, } for _, test := range tests { @@ -80,60 +110,10 @@ func TestProblem(t *testing.T) { if !reflect.DeepEqual(problem, test.expect) { t.Errorf("problems were not equal: wanted\n%#+v\n but got\n%#+v", test.expect, problem) } + + if len(problem.Error()) == 0 { + t.Error("error message should be set but was empty") + } }) } } - -// func TestDefaultProblem(t *testing.T) { -// problem := NewDetailedProblem(http.StatusUnauthorized, unAuthDetails) -// -// if _, err := problem.Validate(); err != nil { -// t.Errorf("problem is not valid") -// } -// } - -// func (cp *creditProblem) ProblemType() (*url.URL, error) { -// u, err := url.Parse(cp.Type) -// if err != nil { -// return nil, err -// } -// return u, nil -// } -// -// func (cp *creditProblem) ProblemTitle() string { -// return cp.Title -// } -// var unAuthDetails = "you are unauthorized to access this resource" - -// func TestCreditProblem(t *testing.T) { -// // problem := &creditProblem{ -// // DefaultProblem: *NewDetailedProblem(http.StatusUnauthorized, unAuthDetails), -// // Balance: 30, -// // Accounts: []string{"/account/12345", "/account/67890"}, -// // } -// -// problem := NewExt[creditProblemExt](). -// WithStatus(http.StatusUnauthorized). -// WithDetail(unAuthDetails). -// WithExtension(creditProblemExt{ -// Balance: 30, -// Accounts: []string{"/account/12345", "/account/67890"}, -// }) -// -// // typ, err := problem.ProblemType() -// // if err != nil { -// // t.Errorf("Unable to read problem type") -// // } -// // if typ != nil && typ.String() != problem.Type { -// // t.Errorf("Problem Types did not match") -// // } -// // -// // if problem.ProblemTitle() != problem.Title { -// // t.Errorf("Problem Titles did not match") -// // } -// // -// // err = ValidateProblem(problem) -// if _, err := problem.Validate(); err != nil { -// t.Errorf("problem is not valid: %s", err) -// } -// } From 7fa1630bd40fab437b18a97dab6bd636ca798e8a Mon Sep 17 00:00:00 2001 From: moogar0880 Date: Wed, 26 Mar 2025 19:15:00 -0400 Subject: [PATCH 13/13] add additional examples --- doc_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/doc_test.go b/doc_test.go index fb12f6a..74e9c78 100644 --- a/doc_test.go +++ b/doc_test.go @@ -2,6 +2,7 @@ package problems_test import ( "encoding/json" + "errors" "fmt" "net/http" @@ -32,6 +33,22 @@ func ExampleNewStatusProblem_detailed() { // } } +func ExampleFromError() { + err := func() error { + // Some fallible function. + return errors.New("something bad happened") + }() + internalServerError := problems.FromError(err).WithStatus(http.StatusInternalServerError) + b, _ := json.MarshalIndent(internalServerError, "", " ") + fmt.Println(string(b)) + // Output: { + // "type": "about:blank", + // "title": "Internal Server Error", + // "status": 500, + // "detail": "something bad happened" + // } +} + func ExampleExtendedProblem() { type CreditProblemExt struct { Balance float64 `json:"balance"`