Skip to content
Merged
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
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@ module github.com/stacklok/toolhive-core
go 1.25.6

require go.uber.org/mock v0.6.0

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1 // indirect
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
67 changes: 67 additions & 0 deletions httperr/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

/*
Package httperr provides error types with HTTP status codes for API error handling.

This package allows errors to carry their intended HTTP response code through the
call stack, enabling centralized error handling in API handlers. The CodedError
type implements the standard error interface and supports error wrapping via
errors.Is() and errors.As().

# Basic Usage

Create errors with HTTP status codes:

// Create a new error with a status code
err := httperr.New("resource not found", http.StatusNotFound)

// Wrap an existing error with a status code
err := httperr.WithCode(err, http.StatusBadRequest)

# Extracting Status Codes

Extract the HTTP status code from an error chain:

code := httperr.Code(err)
// Returns the code if err contains a CodedError
// Returns http.StatusInternalServerError (500) if no CodedError found
// Returns http.StatusOK (200) if err is nil

# Error Wrapping

CodedError supports the standard Go error wrapping pattern:

sentinel := errors.New("database connection failed")
err := httperr.WithCode(sentinel, http.StatusServiceUnavailable)

// errors.Is works through the wrapper
if errors.Is(err, sentinel) {
// handle specific error
}

// errors.As can extract the CodedError
var coded *httperr.CodedError
if errors.As(err, &coded) {
log.Printf("HTTP %d: %s", coded.HTTPCode(), coded.Error())
}

# HTTP Handler Example

Use in an HTTP handler for centralized error responses:

func handleError(w http.ResponseWriter, err error) {
code := httperr.Code(err)
http.Error(w, err.Error(), code)
}

func myHandler(w http.ResponseWriter, r *http.Request) {
result, err := doSomething()
if err != nil {
handleError(w, err)
return
}
// ...
}
*/
package httperr
65 changes: 65 additions & 0 deletions httperr/httperr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package httperr provides error types with HTTP status codes for API error handling.
package httperr

import (
"errors"
"net/http"
)

// CodedError wraps an error with an HTTP status code.
// This allows errors to carry their intended HTTP response code through the call stack,
// enabling centralized error handling in API handlers.
type CodedError struct {
err error
code int
}

// Error implements the error interface.
func (e *CodedError) Error() string {
return e.err.Error()
}

// Unwrap returns the underlying error for errors.Is() and errors.As() compatibility.
func (e *CodedError) Unwrap() error {
return e.err
}

// HTTPCode returns the HTTP status code associated with this error.
func (e *CodedError) HTTPCode() int {
return e.code
}

// WithCode wraps an error with an HTTP status code.
// The returned error implements Unwrap() for use with errors.Is() and errors.As().
// If err is nil, WithCode returns nil.
func WithCode(err error, code int) error {
if err == nil {
return nil
}
return &CodedError{err: err, code: code}
}

// Code extracts the HTTP status code from an error.
// It unwraps the error chain looking for a CodedError.
// If no CodedError is found, it returns http.StatusInternalServerError (500).
func Code(err error) int {
if err == nil {
return http.StatusOK
}

var coded *CodedError
if errors.As(err, &coded) {
return coded.code
}

return http.StatusInternalServerError
}

// New creates a new error with the given message and HTTP status code.
// This is a convenience function equivalent to WithCode(errors.New(message), code).
func New(message string, code int) error {
return &CodedError{err: errors.New(message), code: code}
}
155 changes: 155 additions & 0 deletions httperr/httperr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package httperr

import (
"errors"
"fmt"
"net/http"
"testing"

"github.com/stretchr/testify/require"
)

func TestWithCode(t *testing.T) {
t.Parallel()

t.Run("wraps error with code", func(t *testing.T) {
t.Parallel()

baseErr := errors.New("test error")
err := WithCode(baseErr, http.StatusNotFound)

require.NotNil(t, err)

coded, ok := err.(*CodedError)
require.True(t, ok, "expected *CodedError, got %T", err)
require.Equal(t, http.StatusNotFound, coded.HTTPCode())
require.Equal(t, "test error", coded.Error())
})

t.Run("returns nil for nil error", func(t *testing.T) {
t.Parallel()

err := WithCode(nil, http.StatusNotFound)
require.Nil(t, err)
})
}

func TestCode(t *testing.T) {
t.Parallel()

t.Run("extracts code from CodedError", func(t *testing.T) {
t.Parallel()

err := WithCode(errors.New("not found"), http.StatusNotFound)
code := Code(err)
require.Equal(t, http.StatusNotFound, code)
})

t.Run("returns 500 for error without code", func(t *testing.T) {
t.Parallel()

err := errors.New("plain error")
code := Code(err)
require.Equal(t, http.StatusInternalServerError, code)
})

t.Run("returns 200 for nil error", func(t *testing.T) {
t.Parallel()

code := Code(nil)
require.Equal(t, http.StatusOK, code)
})

t.Run("extracts code from wrapped error", func(t *testing.T) {
t.Parallel()

baseErr := WithCode(errors.New("not found"), http.StatusNotFound)
wrappedErr := fmt.Errorf("outer context: %w", baseErr)
code := Code(wrappedErr)
require.Equal(t, http.StatusNotFound, code)
})

t.Run("extracts code from deeply wrapped error", func(t *testing.T) {
t.Parallel()

baseErr := WithCode(errors.New("bad request"), http.StatusBadRequest)
wrapped1 := fmt.Errorf("layer 1: %w", baseErr)
wrapped2 := fmt.Errorf("layer 2: %w", wrapped1)
wrapped3 := fmt.Errorf("layer 3: %w", wrapped2)
code := Code(wrapped3)
require.Equal(t, http.StatusBadRequest, code)
})
}

func TestCodedError_Unwrap(t *testing.T) {
t.Parallel()

t.Run("errors.Is works with wrapped error", func(t *testing.T) {
t.Parallel()

sentinel := errors.New("sentinel")
err := WithCode(sentinel, http.StatusNotFound)
require.ErrorIs(t, err, sentinel)
})

t.Run("errors.Is works with double wrapped error", func(t *testing.T) {
t.Parallel()

sentinel := errors.New("sentinel")
coded := WithCode(sentinel, http.StatusNotFound)
wrapped := fmt.Errorf("outer: %w", coded)
require.ErrorIs(t, wrapped, sentinel)
})

t.Run("errors.As works with CodedError", func(t *testing.T) {
t.Parallel()

err := WithCode(errors.New("test"), http.StatusBadRequest)
wrapped := fmt.Errorf("wrapped: %w", err)

var coded *CodedError
require.ErrorAs(t, wrapped, &coded)
require.Equal(t, http.StatusBadRequest, coded.HTTPCode())
})
}

func TestNew(t *testing.T) {
t.Parallel()

t.Run("creates error with message and code", func(t *testing.T) {
t.Parallel()

err := New("custom error", http.StatusForbidden)
require.Equal(t, "custom error", err.Error())
require.Equal(t, http.StatusForbidden, Code(err))
})
}

func TestCodedError_HTTPCode(t *testing.T) {
t.Parallel()

tests := []struct {
name string
code int
expected int
}{
{"OK", http.StatusOK, http.StatusOK},
{"BadRequest", http.StatusBadRequest, http.StatusBadRequest},
{"NotFound", http.StatusNotFound, http.StatusNotFound},
{"Conflict", http.StatusConflict, http.StatusConflict},
{"InternalServerError", http.StatusInternalServerError, http.StatusInternalServerError},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := WithCode(errors.New("test"), tt.code)
coded := err.(*CodedError)
require.Equal(t, tt.expected, coded.HTTPCode())
})
}
}
Loading