From 7b7164de328cfda475c71a994ca1b1fa078a6d49 Mon Sep 17 00:00:00 2001 From: Peyton-Spencer Date: Fri, 3 Apr 2026 13:09:40 -0400 Subject: [PATCH] errs: conform HTTP responses to problem+json --- errs/README.md | 18 +++++----- errs/errs.go | 17 +++++++-- errs/json.go | 46 ++++++++---------------- errs/json_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 42 deletions(-) create mode 100644 errs/json_test.go diff --git a/errs/README.md b/errs/README.md index 5fd54b7..f353b27 100644 --- a/errs/README.md +++ b/errs/README.md @@ -1,6 +1,6 @@ # errs -A structured error handling package for Go HTTP services with built-in zerolog integration, JSON serialization, and the abort pattern. +A structured error handling package for Go HTTP services with built-in zerolog integration, RFC 7807 problem details responses, and the abort pattern. ## Installation @@ -12,7 +12,7 @@ go get github.com/omniaura/go-kit/errs - **Error Factories** — Define reusable error types with HTTP status codes and messages - **Zerolog Integration** — Automatic structured logging with configurable log levels -- **JSON Responses** — Errors serialize to clean JSON for API responses +- **Problem Details Responses** — Errors serialize as `application/problem+json` for API responses - **Method Chaining** — Fluent API for annotating errors with context - **Abort Pattern** — One-liner error handling in HTTP handlers - **Error Matching** — `Is`/`Not` methods compatible with Go's error handling idioms @@ -72,7 +72,7 @@ func HandleUpdateUser(w http.ResponseWriter, r *http.Request) { } ``` -`Abort` writes the JSON error response, logs via zerolog, and returns `true` if the error is non-nil. +`Abort` writes the problem details response with `Content-Type: application/problem+json`, logs via zerolog, and returns `true` if the error is non-nil. #### Aborting Any Error @@ -147,14 +147,16 @@ err := someExternalLibrary() e := errs.AsError(ctx, err) // Wraps as Unknown (500) if not already an *errs.Error ``` -### JSON Response Format +### Problem Details Response Format -Errors serialize to JSON automatically: +Errors serialize to RFC 7807 problem details automatically: ```json { - "message": "resource not found: record does not exist", - "status": 404 + "type": "about:blank", + "title": "Not Found", + "status": 404, + "detail": "resource not found: record does not exist" } ``` @@ -198,6 +200,7 @@ Returns a `422 Unprocessable Entity` with the missing field names. | `Log(f func(*zerolog.Event)) *Error` | Add custom log fields | | `Abort(w http.ResponseWriter) bool` | Write response & log; returns true if error exists | | `Message() string` | Get the full error message | +| `Title() string` | Get the problem details title | | `Error() string` | Implements `error` interface | | `Is(err error) bool` | Check if errors match | | `Not(err error) bool` | Check if errors don't match | @@ -212,4 +215,3 @@ Returns a `422 Unprocessable Entity` with the missing field names. ## License See [LICENSE](../LICENSE) in the repository root. - diff --git a/errs/errs.go b/errs/errs.go index 2c91d3d..166bda1 100644 --- a/errs/errs.go +++ b/errs/errs.go @@ -3,6 +3,7 @@ package errs import ( "bytes" "context" + "encoding/json" "errors" "fmt" "net/http" @@ -73,6 +74,14 @@ func (e *Error) Message() string { return b.String() } +func (e *Error) Title() string { + title := http.StatusText(int(e.Status)) + if title != "" { + return title + } + return e.message.h.Value() +} + func (e *Error) Error() string { return fmt.Sprintf("status code: %d, message: %s", e.Status, e.Message()) } @@ -97,10 +106,12 @@ func (e *Error) Abort(w http.ResponseWriter) bool { if e == nil { return false } + w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(int(e.Status)) - var buf bytes.Buffer - e.marshalJSONBuffer(&buf) - buf.WriteTo(w) + data, err := json.Marshal(e) + if err == nil { + _, _ = w.Write(data) + } msg := "request aborted: " + e.message.h.Value() e.event.Int("status", int(e.Status)).Msg(msg) return true diff --git a/errs/json.go b/errs/json.go index 9c26113..31afbb3 100644 --- a/errs/json.go +++ b/errs/json.go @@ -1,48 +1,32 @@ package errs import ( - "bytes" "encoding/json" - "strconv" "unique" ) -func (e *Error) marshalJSONBuffer(buf *bytes.Buffer) { - const json1 = `{"message":"` - const json2 = `","status":` - const json3 = `}` - msg := e.Message() - msglen := len(msg) - for i := range e.rspAnnotations { - msglen += len(e.rspAnnotations[i]) + 2 - } - status := strconv.Itoa(int(e.Status)) - buf.Grow(len(json1) + msglen + len(json2) + len(status) + len(json3)) - buf.WriteString(json1) - buf.WriteString(msg) - for i := range e.rspAnnotations { - buf.WriteString(": ") - buf.WriteString(e.rspAnnotations[i]) +type problemJSON struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail,omitempty"` +} + +func (e *Error) problemJSON() problemJSON { + return problemJSON{ + Type: "about:blank", + Title: e.Title(), + Status: int(e.Status), + Detail: e.Message(), } - buf.WriteString(json2) - buf.WriteString(status) - buf.WriteString(json3) } func (e *Error) MarshalJSON() ([]byte, error) { - var buf bytes.Buffer - e.marshalJSONBuffer(&buf) - return buf.Bytes(), nil + return json.Marshal(e.problemJSON()) } func (e errorMessage) MarshalJSON() ([]byte, error) { - str := e.h.Value() - var buf bytes.Buffer - buf.Grow(len(str) + 2) - buf.WriteString(`"`) - buf.WriteString(str) - buf.WriteString(`"`) - return buf.Bytes(), nil + return json.Marshal(e.h.Value()) } func (e *errorMessage) UnmarshalJSON(data []byte) error { diff --git a/errs/json_test.go b/errs/json_test.go new file mode 100644 index 0000000..718e74a --- /dev/null +++ b/errs/json_test.go @@ -0,0 +1,91 @@ +package errs + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestErrorMarshalJSON_ProblemJSON(t *testing.T) { + t.Parallel() + + err := NewFactory(http.StatusBadRequest, "invalid request"). + New(context.Background()). + Err(errors.New(`bad "value"`)) + + data, marshalErr := json.Marshal(err) + if marshalErr != nil { + t.Fatalf("MarshalJSON() error = %v", marshalErr) + } + + var got map[string]any + if unmarshalErr := json.Unmarshal(data, &got); unmarshalErr != nil { + t.Fatalf("json.Unmarshal() error = %v", unmarshalErr) + } + + if got["type"] != "about:blank" { + t.Fatalf("type = %v, want about:blank", got["type"]) + } + if got["title"] != http.StatusText(http.StatusBadRequest) { + t.Fatalf("title = %v, want %q", got["title"], http.StatusText(http.StatusBadRequest)) + } + if got["detail"] != `invalid request: bad "value"` { + t.Fatalf("detail = %v, want %q", got["detail"], `invalid request: bad "value"`) + } + if got["status"] != float64(http.StatusBadRequest) { + t.Fatalf("status = %v, want %d", got["status"], http.StatusBadRequest) + } +} + +func TestErrorAbort_WritesProblemJSONResponse(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + err := NewFactory(http.StatusNotFound, "resource not found"). + New(context.Background()). + Err(errors.New("record does not exist")) + + if !err.Abort(recorder) { + t.Fatal("Abort() = false, want true") + } + + response := recorder.Result() + if response.StatusCode != http.StatusNotFound { + t.Fatalf("status code = %d, want %d", response.StatusCode, http.StatusNotFound) + } + if got := response.Header.Get("Content-Type"); got != "application/problem+json" { + t.Fatalf("Content-Type = %q, want %q", got, "application/problem+json") + } + + var body map[string]any + if decodeErr := json.NewDecoder(response.Body).Decode(&body); decodeErr != nil { + t.Fatalf("Decode() error = %v", decodeErr) + } + + if body["title"] != http.StatusText(http.StatusNotFound) { + t.Fatalf("title = %v, want %q", body["title"], http.StatusText(http.StatusNotFound)) + } + if body["detail"] != "resource not found: record does not exist" { + t.Fatalf("detail = %v, want %q", body["detail"], "resource not found: record does not exist") + } +} + +func TestNilErrorAbort_DoesNothing(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + var err *Error + + if err.Abort(recorder) { + t.Fatal("Abort() = true, want false") + } + if recorder.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d", recorder.Code, http.StatusOK) + } + if recorder.Body.Len() != 0 { + t.Fatalf("body length = %d, want 0", recorder.Body.Len()) + } +}