├── 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 | 
3 | [](https://pkg.go.dev/schneider.vip/problem)
4 | [](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 |
--------------------------------------------------------------------------------