├── go.sum ├── go.mod ├── doc.go ├── LICENSE ├── .github └── workflows │ └── coverage.yml ├── README.md ├── problem.go └── problem_test.go /go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module schneider.vip/problem 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package problem implements RFC7807 `application/problem+json` and 3 | `application/problem+xml` using the functional options paradigm. 4 | 5 | # Features 6 | 7 | - Compatible with `application/problem+json`. 8 | - Inspired by https://github.com/zalando/problem. 9 | - RFC link https://tools.ietf.org/html/rfc7807. 10 | - A Problem implements the Error interface and can be compared with errors.Is(). 11 | - Wrap an error to a Problem. 12 | - `application/problem+xml` is also supported using `xml.Unmarshal` and `xml.Marshal`. 13 | - Auto-Title based on StatusCode with `problem.Of(statusCode)`. 14 | 15 | # Installation 16 | 17 | To install the package, run: 18 | 19 | go get -u schneider.vip/problem 20 | */ 21 | package problem 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matthias Schneider 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Go # The name of the workflow that will appear on Github 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | jobs: 12 | 13 | build: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, windows-latest] 18 | go: [1.16, 1.17] 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v2 24 | with: 25 | go-version: ${{ matrix.go }} 26 | 27 | - name: Build 28 | run: go install 29 | 30 | - name: Test 31 | run: | 32 | go test -v -cover ./... -coverprofile coverage.out -coverpkg ./... 33 | go tool cover -func coverage.out -o coverage.out # Replaces coverage.out with the analysis of coverage.out 34 | 35 | - name: Go Coverage Badge 36 | uses: tj-actions/coverage-badge-go@v1 37 | if: ${{ runner.os == 'Linux' && matrix.go == '1.17' }} # Runs this on only one of the ci builds. 38 | with: 39 | green: 80 40 | filename: coverage.out 41 | 42 | - uses: stefanzweifel/git-auto-commit-action@v4 43 | id: auto-commit-action 44 | with: 45 | commit_message: Apply Code Coverage Badge 46 | skip_fetch: true 47 | skip_checkout: true 48 | file_pattern: ./README.md 49 | 50 | - name: Push Changes 51 | if: steps.auto-commit-action.outputs.changes_detected == 'true' 52 | uses: ad-m/github-push-action@master 53 | with: 54 | github_token: ${{ github.token }} 55 | branch: ${{ github.ref }} 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # problem 2 | ![Coverage](https://img.shields.io/badge/Coverage-100.0%25-brightgreen) 3 | [![PkgGoDev](https://pkg.go.dev/badge/schneider.vip/problem)](https://pkg.go.dev/schneider.vip/problem) 4 | [![Go Report Card](https://goreportcard.com/badge/schneider.vip/problem)](https://goreportcard.com/report/schneider.vip/problem) 5 | 6 | A golang library that implements `application/problem+json` and `application/problem+xml` 7 | 8 | 9 | 10 | ## Features 11 | 12 | * compatible with `application/problem+json` 13 | * inspired by https://github.com/zalando/problem 14 | * RFC link https://tools.ietf.org/html/rfc7807 15 | * a Problem implements the Error interface and can be compared with errors.Is() 16 | * Wrap an error to a Problem 17 | * `application/problem+xml` is also supported using `xml.Unmarshal` and `xml.Marshal` 18 | * Auto-Title based on StatusCode with `problem.Of(statusCode)` 19 | 20 | ## Install 21 | 22 | ```bash 23 | go get -u schneider.vip/problem 24 | ``` 25 | 26 | ## Usage 27 | 28 | ```go 29 | problem.New(problem.Title("Not Found"), problem.Status(404)).JSONString() 30 | ``` 31 | 32 | Will produce this: 33 | 34 | ```json 35 | { 36 | "status": 404, 37 | "title": "Not Found" 38 | } 39 | ``` 40 | 41 | You can also autofill the title based on the StatusCode: 42 | 43 | ```go 44 | problem.Of(404) 45 | ``` 46 | 47 | Will produce the same problem as above! 48 | 49 | You can also append some more options: 50 | 51 | ```go 52 | p := problem.Of(http.StatusNotFound) 53 | p.Append(problem.Detail("some more details")) 54 | 55 | // Use the Marshaler interface to get the problem json as []byte 56 | jsonBytes, err := json.Marshal(p) 57 | 58 | // or simpler (ignores the error) 59 | jsonBytes = p.JSON() 60 | 61 | ``` 62 | 63 | Custom key/values are also supported: 64 | 65 | ```go 66 | problem.New(problem.Title("Not Found"), problem.Custom("key", "value")) 67 | ``` 68 | 69 | To write the Problem directly to a http.ResponseWriter: 70 | 71 | ```go 72 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 73 | problem.New( 74 | problem.Type("https://example.com/404"), 75 | problem.Status(404), 76 | ).WriteTo(w) 77 | }) 78 | ``` 79 | 80 | Create a Problem from an existing error 81 | 82 | ```go 83 | _, err := ioutil.ReadFile("non-existing") 84 | if err != nil { 85 | p := problem.New( 86 | problem.Wrap(err), 87 | problem.Title("Internal Error"), 88 | problem.Status(404), 89 | ) 90 | if !errors.Is(p, os.ErrNotExist) { 91 | t.Fatalf("expected not existing error") 92 | } 93 | } 94 | ``` 95 | 96 | ### [Gin](https://github.com/gin-gonic/gin) Framework 97 | If you are using gin you can simply reply the problem to the client: 98 | 99 | ```go 100 | func(c *gin.Context) { 101 | problem.New( 102 | problem.Title("houston! we have a problem"), 103 | problem.Status(http.StatusNotFound), 104 | ).WriteTo(c.Writer) 105 | } 106 | ``` 107 | 108 | ### [Echo](https://github.com/labstack/echo) Framework 109 | If you are using echo you can use the following error handler to handle Problems and return them to client. 110 | 111 | ```go 112 | func ProblemHandler(err error, c echo.Context) { 113 | if prb, ok := err.(*problem.Problem); ok { 114 | if !c.Response().Committed { 115 | if c.Request().Method == http.MethodHead { 116 | prb.WriteHeaderTo(c.Response()) 117 | } else if _, err := prb.WriteTo(c.Response()); err != nil { 118 | c.Logger().Error(err) 119 | } 120 | } 121 | } else { 122 | c.Echo().DefaultHTTPErrorHandler(err, c) 123 | } 124 | } 125 | 126 | ... 127 | // e is an instance of echo.Echo 128 | e.HTTPErrorHandler = ProblemHandler 129 | ``` 130 | -------------------------------------------------------------------------------- /problem.go: -------------------------------------------------------------------------------- 1 | package problem 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | ) 10 | 11 | const ( 12 | // ContentTypeJSON https://tools.ietf.org/html/rfc7807#section-6.1 13 | ContentTypeJSON = "application/problem+json" 14 | // ContentTypeXML https://tools.ietf.org/html/rfc7807#section-6.2 15 | ContentTypeXML = "application/problem+xml" 16 | ) 17 | 18 | // An Option configures a Problem using the functional options paradigm 19 | // popularized by Rob Pike. 20 | type Option interface { 21 | apply(*Problem) 22 | } 23 | 24 | type optionFunc func(*Problem) 25 | 26 | func (f optionFunc) apply(problem *Problem) { f(problem) } 27 | 28 | // Problem is an RFC7807 error and can be compared with errors.Is() 29 | type Problem struct { 30 | data map[string]interface{} 31 | reason error 32 | } 33 | 34 | // JSON returns the Problem as json bytes 35 | func (p Problem) JSON() []byte { 36 | b, _ := p.MarshalJSON() 37 | return b 38 | } 39 | 40 | // XML returns the Problem as json bytes 41 | func (p Problem) XML() []byte { 42 | b, _ := xml.Marshal(p) 43 | return b 44 | } 45 | 46 | // UnmarshalJSON implements the json.Unmarshaler interface 47 | func (p Problem) UnmarshalJSON(b []byte) error { 48 | return json.Unmarshal(b, &p.data) 49 | } 50 | 51 | // MarshalJSON implements the json.Marshaler interface 52 | func (p Problem) MarshalJSON() ([]byte, error) { 53 | return json.Marshal(&p.data) 54 | } 55 | 56 | // UnmarshalXML implements the xml.Unmarshaler interface 57 | func (p *Problem) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { 58 | lastElem := "" 59 | for { 60 | if tok, err := d.Token(); err == nil { 61 | switch t := tok.(type) { 62 | case xml.StartElement: 63 | if t.Name.Space != "urn:ietf:rfc:7807" { 64 | return fmt.Errorf("Expected namespace urn:ietf:rfc:7807") 65 | } 66 | lastElem = t.Name.Local 67 | case xml.CharData: 68 | if lastElem != "" { 69 | if lastElem == "status" { 70 | i, err := strconv.Atoi(string(t)) 71 | if err != nil { 72 | return err 73 | } 74 | p = p.Append(Status(i)) 75 | } else { 76 | p = p.Append(Custom(lastElem, string(t))) 77 | } 78 | } 79 | } 80 | } else { 81 | break 82 | } 83 | } 84 | return nil 85 | } 86 | 87 | // MarshalXML implements the xml.Marshaler interface 88 | func (p Problem) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 89 | start.Name = xml.Name{Local: "problem"} 90 | start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "xmlns"}, Value: "urn:ietf:rfc:7807"}) 91 | tokens := []xml.Token{start} 92 | for k, v := range p.data { 93 | v := fmt.Sprintf("%v", v) 94 | t := xml.StartElement{Name: xml.Name{Space: "", Local: k}} 95 | tokens = append(tokens, t, xml.CharData(v), xml.EndElement{Name: t.Name}) 96 | } 97 | tokens = append(tokens, xml.EndElement{Name: start.Name}) 98 | for _, t := range tokens { 99 | e.EncodeToken(t) 100 | } 101 | return e.Flush() 102 | } 103 | 104 | // XMLString returns the Problem as xml 105 | func (p Problem) XMLString() string { 106 | return string(p.XML()) 107 | } 108 | 109 | // JSONString returns the Problem as json string 110 | func (p Problem) JSONString() string { 111 | return string(p.JSON()) 112 | } 113 | 114 | // Error implements the error interface, so a Problem can be used as an error 115 | func (p Problem) Error() string { 116 | return p.JSONString() 117 | } 118 | 119 | // Is compares Problem.Error() with err.Error() 120 | func (p Problem) Is(err error) bool { 121 | return p.Error() == err.Error() 122 | } 123 | 124 | // Unwrap returns the result of calling the Unwrap method on err, if err implements Unwrap. 125 | // Otherwise, Unwrap returns nil. 126 | func (p Problem) Unwrap() error { 127 | return p.reason 128 | } 129 | 130 | // WriteHeaderTo writes the HTTP headers for the JSON Problem ContentType and the 131 | // problem's HTTP statuscode. This is suitable for responding to HEAD requests. 132 | func (p Problem) WriteHeaderTo(w http.ResponseWriter) { 133 | w.Header().Set("Content-Type", ContentTypeJSON) 134 | if statuscode, ok := p.data["status"]; ok { 135 | if statusint, ok := statuscode.(int); ok { 136 | w.WriteHeader(statusint) 137 | } 138 | } 139 | } 140 | 141 | // WriteTo writes the JSON Problem to an HTTP Response Writer using the correct 142 | // Content-Type and the problem's HTTP statuscode 143 | func (p Problem) WriteTo(w http.ResponseWriter) (int, error) { 144 | p.WriteHeaderTo(w) 145 | return w.Write(p.JSON()) 146 | } 147 | 148 | // WriteXMLHeaderTo writes the HTTP headers for the XML Problem ContentType and the 149 | // problem's HTTP statuscode. This is suitable for responding to HEAD requests. 150 | func (p Problem) WriteXMLHeaderTo(w http.ResponseWriter) { 151 | w.Header().Set("Content-Type", ContentTypeXML) 152 | if statuscode, ok := p.data["status"]; ok { 153 | if statusint, ok := statuscode.(int); ok { 154 | w.WriteHeader(statusint) 155 | } 156 | } 157 | } 158 | 159 | // WriteXMLTo writes the XML Problem to an HTTP Response Writer using the correct 160 | // Content-Type and the problem's HTTP statuscode 161 | func (p Problem) WriteXMLTo(w http.ResponseWriter) (int, error) { 162 | p.WriteXMLHeaderTo(w) 163 | return w.Write(p.XML()) 164 | } 165 | 166 | // New generates a new Problem 167 | func New(opts ...Option) *Problem { 168 | problem := &Problem{} 169 | problem.data = make(map[string]interface{}) 170 | for _, opt := range opts { 171 | opt.apply(problem) 172 | } 173 | return problem 174 | } 175 | 176 | // Of creates a Problem based on StatusCode with Title automatically set 177 | func Of(statusCode int) *Problem { 178 | return New(Status(statusCode), Title(http.StatusText(statusCode))) 179 | } 180 | 181 | // Append an Option to a existing Problem 182 | func (p *Problem) Append(opts ...Option) *Problem { 183 | for _, opt := range opts { 184 | opt.apply(p) 185 | } 186 | return p 187 | } 188 | 189 | // Wrap an error to the Problem 190 | func Wrap(err error) Option { 191 | return optionFunc(func(problem *Problem) { 192 | problem.reason = err 193 | problem.data["reason"] = err.Error() 194 | }) 195 | } 196 | 197 | // WrapSilent wraps an error inside of the Problem without placing the wrapped 198 | // error into the problem's JSON body. Useful for cases where the underlying 199 | // error needs to be preserved but not transmitted to the user. 200 | func WrapSilent(err error) Option { 201 | return optionFunc(func(problem *Problem) { 202 | problem.reason = err 203 | }) 204 | } 205 | 206 | // Type sets the type URI (typically, with the "http" or "https" scheme) that identifies the problem type. 207 | // When dereferenced, it SHOULD provide human-readable documentation for the problem type 208 | func Type(uri string) Option { 209 | return optionFunc(func(problem *Problem) { 210 | problem.data["type"] = uri 211 | }) 212 | } 213 | 214 | // Title sets a title that appropriately describes it (think short) 215 | // Written in english and readable for engineers (usually not suited for 216 | // non technical stakeholders and not localized); example: Service Unavailable 217 | func Title(title string) Option { 218 | return optionFunc(func(problem *Problem) { 219 | problem.data["title"] = title 220 | }) 221 | } 222 | 223 | // Titlef sets a title using a format string that appropriately describes it (think short) 224 | // Written in english and readable for engineers (usually not suited for 225 | // non technical stakeholders and not localized); example: Service Unavailable 226 | func Titlef(format string, values ...interface{}) Option { 227 | return Title(fmt.Sprintf(format, values...)) 228 | } 229 | 230 | // Status sets the HTTP status code generated by the origin server for this 231 | // occurrence of the problem. 232 | func Status(status int) Option { 233 | return optionFunc(func(problem *Problem) { 234 | problem.data["status"] = status 235 | }) 236 | } 237 | 238 | // Detail A human readable explanation specific to this occurrence of the problem. 239 | func Detail(detail string) Option { 240 | return optionFunc(func(problem *Problem) { 241 | problem.data["detail"] = detail 242 | }) 243 | } 244 | 245 | // Detailf A human readable explanation using a format string specific to this occurrence of the problem. 246 | func Detailf(format string, values ...interface{}) Option { 247 | return Detail(fmt.Sprintf(format, values...)) 248 | } 249 | 250 | // Instance an absolute URI that identifies the specific occurrence of the 251 | // problem. 252 | func Instance(uri string) Option { 253 | return optionFunc(func(problem *Problem) { 254 | problem.data["instance"] = uri 255 | }) 256 | } 257 | 258 | // Instance an absolute URI using a format string that identifies the specific occurrence of the 259 | // problem. 260 | func Instancef(format string, values ...interface{}) Option { 261 | return Instance(fmt.Sprintf(format, values...)) 262 | } 263 | 264 | // Custom sets a custom key value 265 | func Custom(key string, value interface{}) Option { 266 | return optionFunc(func(problem *Problem) { 267 | problem.data[key] = value 268 | }) 269 | } 270 | -------------------------------------------------------------------------------- /problem_test.go: -------------------------------------------------------------------------------- 1 | package problem_test 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "errors" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "strings" 12 | "testing" 13 | 14 | "schneider.vip/problem" 15 | ) 16 | 17 | func TestProblem(t *testing.T) { 18 | p := problem.New(problem.Title("titlestring"), problem.Custom("x", "value")) 19 | 20 | if p.JSONString() != `{"title":"titlestring","x":"value"}` { 21 | t.Fatalf("unexpected reply") 22 | } 23 | b, err := json.Marshal(p) 24 | if err != nil { 25 | t.Fatalf("unexpected json marshal is fine: %v", err) 26 | } 27 | if string(b) != `{"title":"titlestring","x":"value"}` { 28 | t.Fatalf("unexpected reply") 29 | } 30 | 31 | p = problem.New(problem.Title("titlestring"), problem.Status(404), problem.Custom("x", "value")) 32 | str := p.JSONString() 33 | if str != `{"status":404,"title":"titlestring","x":"value"}` { 34 | t.Fatalf("unexpected reply: %s", str) 35 | } 36 | 37 | p.Append(problem.Detail("some more details"), problem.Instance("https://example.com/details")) 38 | str = p.JSONString() 39 | expected := `{"detail":"some more details","instance":"https://example.com/details","status":404,"title":"titlestring","x":"value"}` 40 | if str != expected { 41 | t.Fatalf("unexpected reply: \ngot: %s\nexpected: %s", str, expected) 42 | } 43 | 44 | p = problem.Of(http.StatusAccepted) 45 | str = p.JSONString() 46 | if str != `{"status":202,"title":"Accepted"}` { 47 | t.Fatalf("unexpected reply: %s", str) 48 | } 49 | } 50 | 51 | func TestProblemHTTP(t *testing.T) { 52 | p := problem.New(problem.Title("titlestring"), problem.Status(404), problem.Custom("x", "value")) 53 | p.Append(problem.Detail("some more details"), problem.Instance("https://example.com/details")) 54 | 55 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 | p.Append(problem.Type("https://example.com/404")) 57 | if r.Method == "HEAD" { 58 | p.WriteHeaderTo(w) 59 | } else { 60 | p.WriteTo(w) 61 | } 62 | })) 63 | defer ts.Close() 64 | 65 | // Try GET request 66 | res, err := http.Get(ts.URL) 67 | if err != nil { 68 | t.Fatalf("%v", err) 69 | } 70 | bodyBytes, err := ioutil.ReadAll(res.Body) 71 | res.Body.Close() 72 | if err != nil { 73 | t.Fatalf("%v", err) 74 | } 75 | 76 | if res.StatusCode != http.StatusNotFound { 77 | t.Fatalf("unexpected statuscode: %d expected 404", res.StatusCode) 78 | } 79 | if res.Header.Get("Content-Type") != problem.ContentTypeJSON { 80 | t.Fatalf("unexpected ContentType %s", res.Header.Get("Content-Type")) 81 | } 82 | 83 | if string(bodyBytes) != `{"detail":"some more details","instance":"https://example.com/details","status":404,"title":"titlestring","type":"https://example.com/404","x":"value"}` { 84 | t.Fatalf("unexpected reply: %s", bodyBytes) 85 | } 86 | 87 | // Try HEAD request 88 | res, err = http.Head(ts.URL) 89 | if err != nil { 90 | t.Fatalf("%v", err) 91 | } 92 | bodyBytes, err = ioutil.ReadAll(res.Body) 93 | res.Body.Close() 94 | if err != nil { 95 | t.Fatalf("%v", err) 96 | } 97 | if len(bodyBytes) != 0 { 98 | t.Fatal("expected empty body") 99 | } 100 | 101 | if res.StatusCode != http.StatusNotFound { 102 | t.Fatalf("unexpected statuscode: %d expected 404", res.StatusCode) 103 | } 104 | if res.Header.Get("Content-Type") != problem.ContentTypeJSON { 105 | t.Fatalf("unexpected ContentType %s", res.Header.Get("Content-Type")) 106 | } 107 | } 108 | 109 | func TestXMLProblem(t *testing.T) { 110 | p := problem.New(problem.Status(404)) 111 | xmlstr := p.XMLString() 112 | expected := `404` 113 | if xmlstr != expected { 114 | t.Fatalf("unexpected reply: \ngot: %s\nexpected: %s", xmlstr, expected) 115 | } 116 | 117 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 118 | p.Append(problem.Type("https://example.com/404")) 119 | if r.Method == "HEAD" { 120 | p.WriteXMLHeaderTo(w) 121 | } else { 122 | p.WriteXMLTo(w) 123 | } 124 | })) 125 | defer ts.Close() 126 | 127 | // Try GET request 128 | res, err := http.Get(ts.URL) 129 | if err != nil { 130 | t.Fatalf("%v", err) 131 | } 132 | bodyBytes, err := ioutil.ReadAll(res.Body) 133 | res.Body.Close() 134 | if err != nil { 135 | t.Fatalf("%v", err) 136 | } 137 | 138 | if res.StatusCode != http.StatusNotFound { 139 | t.Fatalf("unexpected statuscode: %d expected 404", res.StatusCode) 140 | } 141 | if res.Header.Get("Content-Type") != problem.ContentTypeXML { 142 | t.Fatalf("unexpected ContentType %s", res.Header.Get("Content-Type")) 143 | } 144 | 145 | if string(bodyBytes) != `404https://example.com/404` && string(bodyBytes) != `https://example.com/404404` { 146 | t.Fatalf("unexpected reply: %s", bodyBytes) 147 | } 148 | 149 | // Try HEAD request 150 | res, err = http.Head(ts.URL) 151 | if err != nil { 152 | t.Fatalf("%v", err) 153 | } 154 | bodyBytes, err = ioutil.ReadAll(res.Body) 155 | res.Body.Close() 156 | if err != nil { 157 | t.Fatalf("%v", err) 158 | } 159 | if len(bodyBytes) != 0 { 160 | t.Fatal("expected empty body") 161 | } 162 | 163 | if res.StatusCode != http.StatusNotFound { 164 | t.Fatalf("unexpected statuscode: %d expected 404", res.StatusCode) 165 | } 166 | if res.Header.Get("Content-Type") != problem.ContentTypeXML { 167 | t.Fatalf("unexpected Content-Type %s", res.Header.Get("Content-Type")) 168 | } 169 | } 170 | 171 | func TestMarshalUnmarshal(t *testing.T) { 172 | p := problem.New(problem.Status(500), problem.Title("Strange")) 173 | 174 | newProblem := problem.New() 175 | err := json.Unmarshal(p.JSON(), &newProblem) 176 | if err != nil { 177 | t.Fatalf("no error expected in unmarshal") 178 | } 179 | if p.Error() != newProblem.Error() { 180 | t.Fatalf("expected equal problems, got %s - %s", p.Error(), newProblem.Error()) 181 | } 182 | } 183 | 184 | func TestXMLMarshalUnmarshal(t *testing.T) { 185 | p := problem.New(problem.Status(500), problem.Title("StrangeXML")) 186 | 187 | xmlProblem, err := xml.Marshal(&p) 188 | if err != nil { 189 | t.Fatalf("no error expected in Marshal: %s", err.Error()) 190 | } 191 | if string(xmlProblem) != `500StrangeXML` && 192 | string(xmlProblem) != `StrangeXML500` { 193 | t.Fatalf("not expected xml output: %s", string(xmlProblem)) 194 | } 195 | 196 | newProblem := problem.New() 197 | err = xml.Unmarshal(xmlProblem, &newProblem) 198 | if err != nil { 199 | t.Fatalf("no error expected in Unmarshal: %s", err.Error()) 200 | } 201 | 202 | if newProblem.JSONString() != `{"status":500,"title":"StrangeXML"}` && newProblem.JSONString() != `{"title":"StrangeXML","status":500}` { 203 | t.Fatalf(`Expected {"status":500,"title":"StrangeXML"} got: %s`, newProblem.JSONString()) 204 | } 205 | 206 | wrongProblem := []byte(`123xxx`) 207 | err = xml.Unmarshal(wrongProblem, &newProblem) 208 | if err == nil { 209 | t.Fatalf("Expected an error in Unmarshal") 210 | } 211 | 212 | wrongProblem = []byte(`abcStrangeXML`) 213 | err = xml.Unmarshal(wrongProblem, &newProblem) 214 | if err == nil { 215 | t.Fatalf("Expected an error in Unmarshal: status is not an int") 216 | } 217 | } 218 | 219 | func TestErrors(t *testing.T) { 220 | var knownProblem = problem.New(problem.Status(404), problem.Title("Go 1.13 Error")) 221 | 222 | var responseFromExternalService = http.Response{ 223 | StatusCode: 404, 224 | Header: map[string][]string{ 225 | "Content-Type": {"application/problem+json"}, 226 | }, 227 | Body: ioutil.NopCloser(strings.NewReader(`{"status":404,"title":"Go 1.13 Error"}`)), 228 | } 229 | // useless here but if you copy paste dont forget: 230 | defer responseFromExternalService.Body.Close() 231 | 232 | if responseFromExternalService.Header.Get("Content-Type") == problem.ContentTypeJSON { 233 | problemDecoder := json.NewDecoder(responseFromExternalService.Body) 234 | 235 | problemFromExternalService := problem.New() 236 | problemDecoder.Decode(&problemFromExternalService) 237 | 238 | if !errors.Is(problemFromExternalService, knownProblem) { 239 | t.Fatalf("Expected the same problem! %v, %v", problemFromExternalService, knownProblem) 240 | } 241 | } 242 | } 243 | 244 | func TestNestedErrors(t *testing.T) { 245 | rootProblem := problem.New(problem.Status(404), problem.Title("Root Problem")) 246 | p := problem.New(problem.Wrap(rootProblem), problem.Title("high level error msg")) 247 | 248 | unwrappedProblem := errors.Unwrap(p) 249 | if !errors.Is(unwrappedProblem, rootProblem) { 250 | t.Fatalf("Expected the same problem! %v, %v", unwrappedProblem, rootProblem) 251 | } 252 | 253 | if errors.Unwrap(unwrappedProblem) != nil { 254 | t.Fatalf("Expected unwrappedProblem has no reason") 255 | } 256 | // See wrapped error in 'reason' 257 | if p.JSONString() != `{"reason":"{\"status\":404,\"title\":\"Root Problem\"}","title":"high level error msg"}` { 258 | t.Fatalf("Unexpected contents %s in problem", p.JSONString()) 259 | } 260 | 261 | p = problem.New(problem.WrapSilent(rootProblem), problem.Title("high level error msg")) 262 | // We should not see a "reason" here 263 | if p.JSONString() != `{"title":"high level error msg"}` { 264 | t.Fatalf("Unexpected contents %s in problem", p.JSONString()) 265 | } 266 | } 267 | 268 | func TestOsErrorInProblem(t *testing.T) { 269 | _, err := ioutil.ReadFile("non-existing") 270 | if err != nil { 271 | p := problem.New(problem.Wrap(err), problem.Title("Internal Error"), problem.Status(404)) 272 | if !errors.Is(p, os.ErrNotExist) { 273 | t.Fatalf("problem contains os.ErrNotExist") 274 | } 275 | 276 | if errors.Is(p, os.ErrPermission) { 277 | t.Fatalf("should not be a permission problem") 278 | } 279 | 280 | var o *os.PathError 281 | if !errors.As(p, &o) { 282 | t.Fatalf("expected error is in PathError") 283 | } 284 | 285 | newErr := errors.New("New Error") 286 | p = problem.New(problem.Wrap(newErr), problem.Title("NewProblem")) 287 | 288 | if !errors.Is(p, newErr) { 289 | t.Fatalf("problem should contain newErr") 290 | } 291 | 292 | } 293 | } 294 | 295 | func TestTitlef(t *testing.T) { 296 | expected := "{\"title\":\"this is a test\"}" 297 | toTest := problem.New(problem.Titlef("this is a %s", "test")).JSONString() 298 | 299 | if !strings.Contains(expected, toTest) { 300 | t.Fatalf("expected problem %s to match %s", toTest, expected) 301 | } 302 | } 303 | 304 | func TestDetailf(t *testing.T) { 305 | expected := "{\"detail\":\"this is a test\"}" 306 | toTest := problem.New(problem.Detailf("this is a %s", "test")).JSONString() 307 | 308 | if !strings.Contains(expected, toTest) { 309 | t.Fatalf("expected problem %s to match %s", toTest, expected) 310 | } 311 | } 312 | 313 | func TestInstancef(t *testing.T) { 314 | expected := "{\"instance\":\"this is a test\"}" 315 | toTest := problem.New(problem.Instancef("this is a %s", "test")).JSONString() 316 | 317 | if !strings.Contains(expected, toTest) { 318 | t.Fatalf("expected problem %s to match %s", toTest, expected) 319 | } 320 | } 321 | --------------------------------------------------------------------------------