Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions errs/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"
}
```

Expand Down Expand Up @@ -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 |
Expand All @@ -212,4 +215,3 @@ Returns a `422 Unprocessable Entity` with the missing field names.
## License

See [LICENSE](../LICENSE) in the repository root.

17 changes: 14 additions & 3 deletions errs/errs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package errs
import (
"bytes"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if bytes.Buffer field 'b' or bytes package is used anywhere in errs package
echo "=== Checking usage of bytes.Buffer field 'b' ==="
rg -n '\.b\.' --type=go errs/

echo "=== Checking usage of bytes package functions ==="
rg -n 'bytes\.' --type=go errs/

echo "=== Checking if 'b' field is accessed ==="
ast-grep --pattern 'e.b.$_($$$)'

Repository: omniaura/go-kit

Length of output: 244


Remove unused bytes import and b bytes.Buffer field.

The bytes package (line 4) and b bytes.Buffer field (line 26) are no longer used after Abort() was updated to use json.Marshal() directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@errs/errs.go` at line 4, Remove the unused bytes package import and the
unused struct field: delete the "bytes" import entry and remove the `b
bytes.Buffer` field declaration (the `b` field) from the relevant error response
struct so the code no longer references an unused Buffer after `Abort()` was
changed to use `json.Marshal()` directly; search for the struct that defines `b
bytes.Buffer` and remove that field and the import.

"context"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -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())
}
Expand All @@ -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
Expand Down
46 changes: 15 additions & 31 deletions errs/json.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
91 changes: 91 additions & 0 deletions errs/json_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading