3 Commits

Author SHA1 Message Date
55c038f1b8 chore: bump go directive from 1.25 to 1.26 2026-05-12 02:06:45 +00:00
c6ff8d0a3f feat(xerrors)!: promote to v1.0.0 — add Unauthorized and PermissionDenied constructors
Add Unauthorized and PermissionDenied convenience constructors to complete the
set of the five most-used error codes (InvalidInput, NotFound, Internal,
Unauthorized, PermissionDenied). All roadmap items from v0.9.0 resolved.
API committed as stable.
2026-05-11 17:49:52 -06:00
5381bccbf7 feat: add WithPlatformCode for domain-level error identity (v0.10.0) (#2)
feat: add WithPlatformCode for domain-level error identity

Adds an optional PlatformCode field to *Err, decoupled from the
transport-level Code. Code drives HTTP/gRPC status mapping;
PlatformCode is a stable domain identifier for consuming applications
(e.g. a frontend performing i18n) to map errors to localised messages.

Platform codes are optional — errors without a user-actionable meaning
(500s, infrastructure failures, auth rejections) carry none.

Fully backwards-compatible: no existing signatures or JSON output changed
for errors without a platform code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Reviewed-on: #2
Reviewed-by: Rene Nochebuena <rene@noreply.nochebuena.dev>
Co-authored-by: Claude Code <claude@nochebuena.dev>
Co-committed-by: Claude Code <claude@nochebuena.dev>
2026-03-25 16:42:15 -06:00
4 changed files with 174 additions and 14 deletions

View File

@@ -5,6 +5,40 @@ All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] — 2026-05-08
### Added
- `Unauthorized(msg string, args ...any) *Err` — convenience constructor for
`ErrUnauthorized`; completes the set of the five most-used codes alongside
`InvalidInput`, `NotFound`, `Internal`, and `PermissionDenied`
- `PermissionDenied(msg string, args ...any) *Err` — convenience constructor for
`ErrPermissionDenied`
### Unchanged
All existing API (`Code`, `Err`, `New`, `Wrap`, `InvalidInput`, `NotFound`,
`Internal`, `WithContext`, `WithError`, `WithPlatformCode`, `ErrorCode`,
`ErrorContext`, `MarshalJSON`) is API-compatible with v0.10.0.
[1.0.0]: https://code.nochebuena.dev/go/xerrors/releases/tag/v1.0.0
## [0.10.0] - 2026-03-25
### Added
- `(*Err).WithPlatformCode(code string) *Err` — chainable builder method to attach a platform-level error code; returns the receiver for chaining
- `(*Err).PlatformCode() string` — getter that returns the platform code, or an empty string if none was set
- `platformCode` field in `MarshalJSON` output — serialised as `"platformCode"` and omitted when empty (`omitempty`)
### Design Notes
- Platform codes operate at the domain/system layer and are intentionally decoupled from the transport-level `Code`. `Code` drives HTTP status mapping and gRPC status codes; `PlatformCode` is a stable semantic identifier for the consuming application (e.g. a frontend performing i18n).
- Platform codes are **optional**. Errors that do not have a user-actionable meaning (500 internal errors, infrastructure failures, authentication rejections) should not carry one.
- No existing behaviour changed — `Code`, `Error`, `Unwrap`, `WithContext`, `MarshalJSON` (for errors without a platform code) are all backwards-compatible.
[0.10.0]: https://code.nochebuena.dev/go/xerrors/compare/v0.9.0...v0.10.0
## [0.9.0] - 2026-03-18
### Added

2
go.mod
View File

@@ -1,3 +1,3 @@
module code.nochebuena.dev/go/xerrors
go 1.25
go 1.26

View File

@@ -13,18 +13,22 @@ import (
// interfaces that logz uses internally to enrich log records — without either
// package importing the other.
//
// Use the builder methods [Err.WithContext] and [Err.WithError] to attach
// additional information after construction:
// Use the builder methods [Err.WithContext], [Err.WithError], and
// [Err.WithPlatformCode] to attach additional information after construction:
//
// err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
// WithContext("field", "email").
// WithContext("rule", "required").
// WithError(cause)
//
// err := xerrors.New(xerrors.ErrNotFound, "employee not found").
// WithPlatformCode("EMPLOYEE_NOT_FOUND")
type Err struct {
code Code
message string
err error
fields map[string]any
code Code
message string
err error
fields map[string]any
platformCode string
}
// New creates an Err with the given code and message. No cause is set.
@@ -63,6 +67,18 @@ func Internal(msg string, args ...any) *Err {
return New(ErrInternal, fmt.Sprintf(msg, args...))
}
// Unauthorized creates an Err with [ErrUnauthorized] code.
// msg is formatted with args using [fmt.Sprintf] rules.
func Unauthorized(msg string, args ...any) *Err {
return New(ErrUnauthorized, fmt.Sprintf(msg, args...))
}
// PermissionDenied creates an Err with [ErrPermissionDenied] code.
// msg is formatted with args using [fmt.Sprintf] rules.
func PermissionDenied(msg string, args ...any) *Err {
return New(ErrPermissionDenied, fmt.Sprintf(msg, args...))
}
// WithContext adds a key-value pair to the error's context fields and returns
// the receiver for chaining. Calling it multiple times with the same key
// overwrites the previous value.
@@ -80,6 +96,27 @@ func (e *Err) WithError(err error) *Err {
return e
}
// WithPlatformCode sets a platform-level error code and returns the receiver
// for chaining. Platform codes are domain-specific identifiers (e.g.
// "EMPLOYEE_NOT_FOUND") that operate independently of the transport-level
// [Code]. They are intended for consuming applications — such as a frontend —
// that need to map errors to localised user-facing messages without relying on
// the generic transport code.
//
// Platform codes are optional. Errors that do not have a user-actionable
// meaning (e.g. 500 internal errors, infrastructure failures) should not carry
// one; the consuming application renders a generic fallback in those cases.
func (e *Err) WithPlatformCode(code string) *Err {
e.platformCode = code
return e
}
// PlatformCode returns the platform-level error code, or an empty string if
// none was set.
func (e *Err) PlatformCode() string {
return e.platformCode
}
// Error implements the error interface.
// Format: "INVALID_ARGUMENT: username is required → original cause"
func (e *Err) Error() string {
@@ -151,15 +188,18 @@ func (e *Err) ErrorContext() map[string]any {
}
// MarshalJSON implements [json.Marshaler].
// Output: {"code":"NOT_FOUND","message":"user not found","fields":{"id":"42"}}
// Output: {"code":"NOT_FOUND","platformCode":"EMPLOYEE_NOT_FOUND","message":"...","fields":{...}}
// platformCode and fields are omitted when empty.
func (e *Err) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]any `json:"fields,omitempty"`
Code string `json:"code"`
PlatformCode string `json:"platformCode,omitempty"`
Message string `json:"message"`
Fields map[string]any `json:"fields,omitempty"`
}{
Code: string(e.code),
Message: e.message,
Fields: e.fields,
Code: string(e.code),
PlatformCode: e.platformCode,
Message: e.message,
Fields: e.fields,
})
}

View File

@@ -70,6 +70,26 @@ func TestConvenienceConstructors(t *testing.T) {
t.Errorf("unexpected message: %s", err.message)
}
})
t.Run("Unauthorized", func(t *testing.T) {
err := Unauthorized("token expired for %s", "uid1")
if err.code != ErrUnauthorized {
t.Errorf("expected code %s, got %s", ErrUnauthorized, err.code)
}
if err.message != "token expired for uid1" {
t.Errorf("unexpected message: %s", err.message)
}
})
t.Run("PermissionDenied", func(t *testing.T) {
err := PermissionDenied("role %s cannot delete", "viewer")
if err.code != ErrPermissionDenied {
t.Errorf("expected code %s, got %s", ErrPermissionDenied, err.code)
}
if err.message != "role viewer cannot delete" {
t.Errorf("unexpected message: %s", err.message)
}
})
}
func TestErr_Error(t *testing.T) {
@@ -243,6 +263,72 @@ func TestErr_DuckTyping_ErrorContext_Nil(t *testing.T) {
_ = err.ErrorContext()
}
func TestErr_WithPlatformCode(t *testing.T) {
t.Run("set and get", func(t *testing.T) {
err := New(ErrNotFound, "employee not found").
WithPlatformCode("EMPLOYEE_NOT_FOUND")
if err.PlatformCode() != "EMPLOYEE_NOT_FOUND" {
t.Errorf("PlatformCode() = %q, want EMPLOYEE_NOT_FOUND", err.PlatformCode())
}
})
t.Run("empty by default", func(t *testing.T) {
err := New(ErrNotFound, "something not found")
if err.PlatformCode() != "" {
t.Errorf("PlatformCode() = %q, want empty string", err.PlatformCode())
}
})
t.Run("chaining with WithContext", func(t *testing.T) {
err := New(ErrAlreadyExists, "email taken").
WithPlatformCode("EMAIL_ALREADY_EXISTS").
WithContext("field", "email")
if err.PlatformCode() != "EMAIL_ALREADY_EXISTS" {
t.Errorf("PlatformCode() = %q, want EMAIL_ALREADY_EXISTS", err.PlatformCode())
}
if err.Fields()["field"] != "email" {
t.Errorf("Fields()[field] = %v, want email", err.Fields()["field"])
}
})
t.Run("chaining preserves transport code", func(t *testing.T) {
err := New(ErrPermissionDenied, "protected").
WithPlatformCode("ROLE_SYSTEM_PROTECTED")
if err.Code() != ErrPermissionDenied {
t.Errorf("Code() = %s, want %s", err.Code(), ErrPermissionDenied)
}
})
}
func TestErr_MarshalJSON_PlatformCode(t *testing.T) {
t.Run("included when set", func(t *testing.T) {
err := New(ErrNotFound, "employee not found").
WithPlatformCode("EMPLOYEE_NOT_FOUND")
b, jsonErr := json.Marshal(err)
if jsonErr != nil {
t.Fatalf("MarshalJSON error: %v", jsonErr)
}
var out map[string]any
if jsonErr = json.Unmarshal(b, &out); jsonErr != nil {
t.Fatalf("unmarshal error: %v", jsonErr)
}
if out["platformCode"] != "EMPLOYEE_NOT_FOUND" {
t.Errorf("json platformCode = %v, want EMPLOYEE_NOT_FOUND", out["platformCode"])
}
})
t.Run("omitted when not set", func(t *testing.T) {
err := New(ErrInternal, "unexpected error")
b, jsonErr := json.Marshal(err)
if jsonErr != nil {
t.Fatalf("MarshalJSON error: %v", jsonErr)
}
if strings.Contains(string(b), "platformCode") {
t.Errorf("json should omit platformCode when empty, got: %s", b)
}
})
}
func TestCode_Description(t *testing.T) {
tests := []struct {
code Code