Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,147 @@
// File Path: monorepo/cloud/maplefile-backend/pkg/httperror/httperror.go
package httperror
// This package introduces a new `error` type that combines an HTTP status code and a message.
import (
"encoding/json"
"errors"
"net/http"
)
// HTTPError represents an http error that occurred while handling a request
type HTTPError struct {
Code int `json:"-"` // HTTP Status code. We use `-` to skip json marshaling.
Errors *map[string]string `json:"-"` // The original error. Same reason as above.
}
// New creates a new HTTPError instance with a multi-field errors.
func New(statusCode int, errorsMap *map[string]string) error {
return HTTPError{
Code: statusCode,
Errors: errorsMap,
}
}
// NewForSingleField create a new HTTPError instance for a single field. This is a convinience constructor.
func NewForSingleField(statusCode int, field string, message string) error {
return HTTPError{
Code: statusCode,
Errors: &map[string]string{field: message},
}
}
// NewForBadRequest create a new HTTPError instance pertaining to 403 bad requests with the multi-errors. This is a convinience constructor.
func NewForBadRequest(err *map[string]string) error {
return HTTPError{
Code: http.StatusBadRequest,
Errors: err,
}
}
// NewForBadRequestWithSingleField create a new HTTPError instance pertaining to 403 bad requests for a single field. This is a convinience constructor.
func NewForBadRequestWithSingleField(field string, message string) error {
return HTTPError{
Code: http.StatusBadRequest,
Errors: &map[string]string{field: message},
}
}
func NewForInternalServerErrorWithSingleField(field string, message string) error {
return HTTPError{
Code: http.StatusInternalServerError,
Errors: &map[string]string{field: message},
}
}
// NewForNotFoundWithSingleField create a new HTTPError instance pertaining to 404 not found for a single field. This is a convinience constructor.
func NewForNotFoundWithSingleField(field string, message string) error {
return HTTPError{
Code: http.StatusNotFound,
Errors: &map[string]string{field: message},
}
}
// NewForServiceUnavailableWithSingleField create a new HTTPError instance pertaining service unavailable for a single field. This is a convinience constructor.
func NewForServiceUnavailableWithSingleField(field string, message string) error {
return HTTPError{
Code: http.StatusServiceUnavailable,
Errors: &map[string]string{field: message},
}
}
// NewForLockedWithSingleField create a new HTTPError instance pertaining to 424 locked for a single field. This is a convinience constructor.
func NewForLockedWithSingleField(field string, message string) error {
return HTTPError{
Code: http.StatusLocked,
Errors: &map[string]string{field: message},
}
}
// NewForForbiddenWithSingleField create a new HTTPError instance pertaining to 403 bad requests for a single field. This is a convinience constructor.
func NewForForbiddenWithSingleField(field string, message string) error {
return HTTPError{
Code: http.StatusForbidden,
Errors: &map[string]string{field: message},
}
}
// NewForUnauthorizedWithSingleField create a new HTTPError instance pertaining to 401 unauthorized for a single field. This is a convinience constructor.
func NewForUnauthorizedWithSingleField(field string, message string) error {
return HTTPError{
Code: http.StatusUnauthorized,
Errors: &map[string]string{field: message},
}
}
// NewForGoneWithSingleField create a new HTTPError instance pertaining to 410 gone for a single field. This is a convinience constructor.
func NewForGoneWithSingleField(field string, message string) error {
return HTTPError{
Code: http.StatusGone,
Errors: &map[string]string{field: message},
}
}
// Error function used to implement the `error` interface for returning errors.
func (err HTTPError) Error() string {
b, e := json.Marshal(err.Errors)
if e != nil { // Defensive code
return e.Error()
}
return string(b)
}
// ResponseError function returns the HTTP error response based on the httpcode used.
func ResponseError(rw http.ResponseWriter, err error) {
// Copied from:
// https://dev.to/tigorlazuardi/go-creating-custom-error-wrapper-and-do-proper-error-equality-check-11k7
rw.Header().Set("Content-Type", "Application/json")
//
// CASE 1 OF 2: Handle API Errors.
//
var ew HTTPError
if errors.As(err, &ew) {
rw.WriteHeader(ew.Code)
_ = json.NewEncoder(rw).Encode(ew.Errors)
return
}
//
// CASE 2 OF 2: Handle non ErrorWrapper types.
//
rw.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(rw).Encode(err.Error())
}
// NewForInternalServerError create a new HTTPError instance pertaining to 500 internal server error with the multi-errors. This is a convinience constructor.
func NewForInternalServerError(err string) error {
return HTTPError{
Code: http.StatusInternalServerError,
Errors: &map[string]string{"message": err},
}
}

View file

@ -0,0 +1,328 @@
// File Path: monorepo/cloud/maplefile-backend/pkg/httperror/httperror_test.go
package httperror
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestNew(t *testing.T) {
tests := []struct {
name string
code int
errors map[string]string
wantCode int
}{
{
name: "basic error",
code: http.StatusBadRequest,
errors: map[string]string{"field": "error message"},
wantCode: http.StatusBadRequest,
},
{
name: "empty errors map",
code: http.StatusNotFound,
errors: map[string]string{},
wantCode: http.StatusNotFound,
},
{
name: "multiple errors",
code: http.StatusBadRequest,
errors: map[string]string{"field1": "error1", "field2": "error2"},
wantCode: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := New(tt.code, &tt.errors)
httpErr, ok := err.(HTTPError)
if !ok {
t.Fatal("expected HTTPError type")
}
if httpErr.Code != tt.wantCode {
t.Errorf("Code = %v, want %v", httpErr.Code, tt.wantCode)
}
for k, v := range tt.errors {
if (*httpErr.Errors)[k] != v {
t.Errorf("Errors[%s] = %v, want %v", k, (*httpErr.Errors)[k], v)
}
}
})
}
}
func TestNewForBadRequest(t *testing.T) {
tests := []struct {
name string
errors map[string]string
}{
{
name: "single error",
errors: map[string]string{"field": "error"},
},
{
name: "multiple errors",
errors: map[string]string{"field1": "error1", "field2": "error2"},
},
{
name: "empty errors",
errors: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := NewForBadRequest(&tt.errors)
httpErr, ok := err.(HTTPError)
if !ok {
t.Fatal("expected HTTPError type")
}
if httpErr.Code != http.StatusBadRequest {
t.Errorf("Code = %v, want %v", httpErr.Code, http.StatusBadRequest)
}
for k, v := range tt.errors {
if (*httpErr.Errors)[k] != v {
t.Errorf("Errors[%s] = %v, want %v", k, (*httpErr.Errors)[k], v)
}
}
})
}
}
func TestNewForSingleField(t *testing.T) {
tests := []struct {
name string
code int
field string
message string
}{
{
name: "basic error",
code: http.StatusBadRequest,
field: "test",
message: "error",
},
{
name: "empty field",
code: http.StatusNotFound,
field: "",
message: "error",
},
{
name: "empty message",
code: http.StatusBadRequest,
field: "field",
message: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := NewForSingleField(tt.code, tt.field, tt.message)
httpErr, ok := err.(HTTPError)
if !ok {
t.Fatal("expected HTTPError type")
}
if httpErr.Code != tt.code {
t.Errorf("Code = %v, want %v", httpErr.Code, tt.code)
}
if (*httpErr.Errors)[tt.field] != tt.message {
t.Errorf("Errors[%s] = %v, want %v", tt.field, (*httpErr.Errors)[tt.field], tt.message)
}
})
}
}
func TestError(t *testing.T) {
tests := []struct {
name string
errors map[string]string
wantErr bool
}{
{
name: "valid json",
errors: map[string]string{"field": "error"},
wantErr: false,
},
{
name: "empty map",
errors: map[string]string{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := HTTPError{
Code: http.StatusBadRequest,
Errors: &tt.errors,
}
errStr := err.Error()
var jsonMap map[string]string
if jsonErr := json.Unmarshal([]byte(errStr), &jsonMap); (jsonErr != nil) != tt.wantErr {
t.Errorf("Error() json.Unmarshal error = %v, wantErr %v", jsonErr, tt.wantErr)
return
}
if !tt.wantErr {
for k, v := range tt.errors {
if jsonMap[k] != v {
t.Errorf("Error() jsonMap[%s] = %v, want %v", k, jsonMap[k], v)
}
}
}
})
}
}
func TestResponseError(t *testing.T) {
tests := []struct {
name string
err error
wantCode int
wantContent string
}{
{
name: "http error",
err: NewForBadRequestWithSingleField("field", "invalid"),
wantCode: http.StatusBadRequest,
wantContent: `{"field":"invalid"}`,
},
{
name: "standard error",
err: fmt.Errorf("standard error"),
wantCode: http.StatusInternalServerError,
wantContent: `"standard error"`,
},
{
name: "nil error",
err: errors.New("<nil>"),
wantCode: http.StatusInternalServerError,
wantContent: `"\u003cnil\u003e"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := httptest.NewRecorder()
ResponseError(rr, tt.err)
// Check status code
if rr.Code != tt.wantCode {
t.Errorf("ResponseError() code = %v, want %v", rr.Code, tt.wantCode)
}
// Check content type
if ct := rr.Header().Get("Content-Type"); ct != "Application/json" {
t.Errorf("ResponseError() Content-Type = %v, want Application/json", ct)
}
// Trim newline from response for comparison
got := rr.Body.String()
got = got[:len(got)-1] // Remove trailing newline added by json.Encoder
if got != tt.wantContent {
t.Errorf("ResponseError() content = %v, want %v", got, tt.wantContent)
}
})
}
}
func TestErrorWrapping(t *testing.T) {
originalErr := errors.New("original error")
wrappedErr := fmt.Errorf("wrapped: %w", originalErr)
httpErr := NewForBadRequestWithSingleField("field", wrappedErr.Error())
// Test error unwrapping
if !errors.Is(httpErr, httpErr) {
t.Error("errors.Is failed for same error")
}
var targetErr HTTPError
if !errors.As(httpErr, &targetErr) {
t.Error("errors.As failed to get HTTPError")
}
}
// Test all convenience constructors
func TestConvenienceConstructors(t *testing.T) {
tests := []struct {
name string
create func() error
wantCode int
}{
{
name: "NewForBadRequestWithSingleField",
create: func() error {
return NewForBadRequestWithSingleField("field", "message")
},
wantCode: http.StatusBadRequest,
},
{
name: "NewForNotFoundWithSingleField",
create: func() error {
return NewForNotFoundWithSingleField("field", "message")
},
wantCode: http.StatusNotFound,
},
{
name: "NewForServiceUnavailableWithSingleField",
create: func() error {
return NewForServiceUnavailableWithSingleField("field", "message")
},
wantCode: http.StatusServiceUnavailable,
},
{
name: "NewForLockedWithSingleField",
create: func() error {
return NewForLockedWithSingleField("field", "message")
},
wantCode: http.StatusLocked,
},
{
name: "NewForForbiddenWithSingleField",
create: func() error {
return NewForForbiddenWithSingleField("field", "message")
},
wantCode: http.StatusForbidden,
},
{
name: "NewForUnauthorizedWithSingleField",
create: func() error {
return NewForUnauthorizedWithSingleField("field", "message")
},
wantCode: http.StatusUnauthorized,
},
{
name: "NewForGoneWithSingleField",
create: func() error {
return NewForGoneWithSingleField("field", "message")
},
wantCode: http.StatusGone,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.create()
httpErr, ok := err.(HTTPError)
if !ok {
t.Fatal("expected HTTPError type")
}
if httpErr.Code != tt.wantCode {
t.Errorf("Code = %v, want %v", httpErr.Code, tt.wantCode)
}
if (*httpErr.Errors)["field"] != "message" {
t.Errorf("Error message = %v, want 'message'", (*httpErr.Errors)["field"])
}
})
}
}

View file

@ -0,0 +1,289 @@
// Package httperror provides RFC 9457 compliant error handling for HTTP APIs.
// RFC 9457: Problem Details for HTTP APIs
// https://www.rfc-editor.org/rfc/rfc9457.html
package httperror
import (
"encoding/json"
"net/http"
"time"
)
// ProblemDetail represents an RFC 9457 problem detail response.
// It provides a standardized way to carry machine-readable details of errors
// in HTTP response content.
type ProblemDetail struct {
// Standard RFC 9457 fields
// Type is a URI reference that identifies the problem type.
// When dereferenced, it should provide human-readable documentation.
// Defaults to "about:blank" if not provided.
Type string `json:"type"`
// Status is the HTTP status code for this occurrence of the problem.
Status int `json:"status"`
// Title is a short, human-readable summary of the problem type.
Title string `json:"title"`
// Detail is a human-readable explanation specific to this occurrence.
Detail string `json:"detail,omitempty"`
// Instance is a URI reference that identifies this specific occurrence.
Instance string `json:"instance,omitempty"`
// MapleFile-specific extensions
// Errors contains field-specific validation errors.
// Key is the field name, value is the error message.
Errors map[string]string `json:"errors,omitempty"`
// Timestamp is the ISO 8601 timestamp when the error occurred.
Timestamp string `json:"timestamp"`
// TraceID is the request trace ID for debugging.
TraceID string `json:"trace_id,omitempty"`
}
// Problem type URIs - these identify categories of errors
const (
TypeValidationError = "https://api.maplefile.com/problems/validation-error"
TypeBadRequest = "https://api.maplefile.com/problems/bad-request"
TypeUnauthorized = "https://api.maplefile.com/problems/unauthorized"
TypeForbidden = "https://api.maplefile.com/problems/forbidden"
TypeNotFound = "https://api.maplefile.com/problems/not-found"
TypeConflict = "https://api.maplefile.com/problems/conflict"
TypeTooManyRequests = "https://api.maplefile.com/problems/too-many-requests"
TypeInternalError = "https://api.maplefile.com/problems/internal-error"
TypeServiceUnavailable = "https://api.maplefile.com/problems/service-unavailable"
)
// NewProblemDetail creates a new RFC 9457 problem detail.
func NewProblemDetail(status int, problemType, title, detail string) *ProblemDetail {
return &ProblemDetail{
Type: problemType,
Status: status,
Title: title,
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewValidationError creates a validation error problem detail.
// Use this when one or more fields fail validation.
func NewValidationError(fieldErrors map[string]string) *ProblemDetail {
detail := "One or more fields failed validation. Please check the errors and try again."
if len(fieldErrors) == 0 {
detail = "Validation failed."
}
return &ProblemDetail{
Type: TypeValidationError,
Status: http.StatusBadRequest,
Title: "Validation Failed",
Detail: detail,
Errors: fieldErrors,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewBadRequestError creates a generic bad request error.
// Use this for malformed requests or invalid input.
func NewBadRequestError(detail string) *ProblemDetail {
return &ProblemDetail{
Type: TypeBadRequest,
Status: http.StatusBadRequest,
Title: "Bad Request",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewUnauthorizedError creates an unauthorized error.
// Use this when authentication is required but missing or invalid.
func NewUnauthorizedError(detail string) *ProblemDetail {
if detail == "" {
detail = "Authentication is required to access this resource."
}
return &ProblemDetail{
Type: TypeUnauthorized,
Status: http.StatusUnauthorized,
Title: "Unauthorized",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewForbiddenError creates a forbidden error.
// Use this when the user is authenticated but lacks permission.
func NewForbiddenError(detail string) *ProblemDetail {
if detail == "" {
detail = "You do not have permission to access this resource."
}
return &ProblemDetail{
Type: TypeForbidden,
Status: http.StatusForbidden,
Title: "Forbidden",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewNotFoundError creates a not found error.
// Use this when a requested resource does not exist.
func NewNotFoundError(resourceType string) *ProblemDetail {
detail := "The requested resource was not found."
if resourceType != "" {
detail = resourceType + " not found."
}
return &ProblemDetail{
Type: TypeNotFound,
Status: http.StatusNotFound,
Title: "Not Found",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewConflictError creates a conflict error.
// Use this when the request conflicts with the current state.
func NewConflictError(detail string) *ProblemDetail {
if detail == "" {
detail = "The request conflicts with the current state of the resource."
}
return &ProblemDetail{
Type: TypeConflict,
Status: http.StatusConflict,
Title: "Conflict",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewTooManyRequestsError creates a rate limit exceeded error.
// Use this when the client has exceeded the allowed request rate.
// CWE-307: Used to prevent brute force attacks by limiting request frequency.
func NewTooManyRequestsError(detail string) *ProblemDetail {
if detail == "" {
detail = "Too many requests. Please try again later."
}
return &ProblemDetail{
Type: TypeTooManyRequests,
Status: http.StatusTooManyRequests,
Title: "Too Many Requests",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewInternalServerError creates an internal server error.
// Use this for unexpected errors that are not the client's fault.
func NewInternalServerError(detail string) *ProblemDetail {
if detail == "" {
detail = "An unexpected error occurred. Please try again later."
}
return &ProblemDetail{
Type: TypeInternalError,
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewServiceUnavailableError creates a service unavailable error.
// Use this when the service is temporarily unavailable.
func NewServiceUnavailableError(detail string) *ProblemDetail {
if detail == "" {
detail = "The service is temporarily unavailable. Please try again later."
}
return &ProblemDetail{
Type: TypeServiceUnavailable,
Status: http.StatusServiceUnavailable,
Title: "Service Unavailable",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// WithInstance adds the request path as the instance identifier.
func (p *ProblemDetail) WithInstance(instance string) *ProblemDetail {
p.Instance = instance
return p
}
// WithTraceID adds the request trace ID for debugging.
func (p *ProblemDetail) WithTraceID(traceID string) *ProblemDetail {
p.TraceID = traceID
return p
}
// WithError adds a single field error to the problem detail.
func (p *ProblemDetail) WithError(field, message string) *ProblemDetail {
if p.Errors == nil {
p.Errors = make(map[string]string)
}
p.Errors[field] = message
return p
}
// Error implements the error interface.
func (p *ProblemDetail) Error() string {
if p.Detail != "" {
return p.Detail
}
return p.Title
}
// ExtractRequestID gets the request ID from the request context or headers.
// This uses the existing request ID middleware.
func ExtractRequestID(r *http.Request) string {
// Try to get from context first (preferred)
if requestID := r.Context().Value("request_id"); requestID != nil {
if id, ok := requestID.(string); ok {
return id
}
}
// Fallback to header
if requestID := r.Header.Get("X-Request-ID"); requestID != "" {
return requestID
}
// No request ID found
return ""
}
// RespondWithProblem writes the RFC 9457 problem detail to the HTTP response.
// It sets the appropriate Content-Type header and status code.
func RespondWithProblem(w http.ResponseWriter, problem *ProblemDetail) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(problem.Status)
json.NewEncoder(w).Encode(problem)
}
// RespondWithError is a convenience function that handles both ProblemDetail
// and standard Go errors. If the error is a ProblemDetail, it writes it directly.
// Otherwise, it wraps it in an internal server error.
func RespondWithError(w http.ResponseWriter, r *http.Request, err error) {
requestID := ExtractRequestID(r)
// Check if error is already a ProblemDetail
if problem, ok := err.(*ProblemDetail); ok {
problem.WithInstance(r.URL.Path).WithTraceID(requestID)
RespondWithProblem(w, problem)
return
}
// Wrap standard error in internal server error
problem := NewInternalServerError(err.Error())
problem.WithInstance(r.URL.Path).WithTraceID(requestID)
RespondWithProblem(w, problem)
}

View file

@ -0,0 +1,357 @@
package httperror
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewValidationError(t *testing.T) {
fieldErrors := map[string]string{
"email": "Email is required",
"password": "Password must be at least 8 characters",
}
problem := NewValidationError(fieldErrors)
if problem.Type != TypeValidationError {
t.Errorf("Expected type %s, got %s", TypeValidationError, problem.Type)
}
if problem.Status != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, problem.Status)
}
if problem.Title != "Validation Failed" {
t.Errorf("Expected title 'Validation Failed', got '%s'", problem.Title)
}
if len(problem.Errors) != 2 {
t.Errorf("Expected 2 field errors, got %d", len(problem.Errors))
}
if problem.Errors["email"] != "Email is required" {
t.Errorf("Expected email error, got '%s'", problem.Errors["email"])
}
if problem.Timestamp == "" {
t.Error("Expected timestamp to be set")
}
}
func TestNewValidationError_Empty(t *testing.T) {
problem := NewValidationError(map[string]string{})
if problem.Detail != "Validation failed." {
t.Errorf("Expected detail 'Validation failed.', got '%s'", problem.Detail)
}
}
func TestNewBadRequestError(t *testing.T) {
detail := "Invalid request payload"
problem := NewBadRequestError(detail)
if problem.Type != TypeBadRequest {
t.Errorf("Expected type %s, got %s", TypeBadRequest, problem.Type)
}
if problem.Status != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, problem.Status)
}
if problem.Detail != detail {
t.Errorf("Expected detail '%s', got '%s'", detail, problem.Detail)
}
}
func TestNewUnauthorizedError(t *testing.T) {
detail := "Invalid token"
problem := NewUnauthorizedError(detail)
if problem.Type != TypeUnauthorized {
t.Errorf("Expected type %s, got %s", TypeUnauthorized, problem.Type)
}
if problem.Status != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, problem.Status)
}
if problem.Detail != detail {
t.Errorf("Expected detail '%s', got '%s'", detail, problem.Detail)
}
}
func TestNewUnauthorizedError_DefaultMessage(t *testing.T) {
problem := NewUnauthorizedError("")
if problem.Detail != "Authentication is required to access this resource." {
t.Errorf("Expected default detail message, got '%s'", problem.Detail)
}
}
func TestNewForbiddenError(t *testing.T) {
detail := "Insufficient permissions"
problem := NewForbiddenError(detail)
if problem.Type != TypeForbidden {
t.Errorf("Expected type %s, got %s", TypeForbidden, problem.Type)
}
if problem.Status != http.StatusForbidden {
t.Errorf("Expected status %d, got %d", http.StatusForbidden, problem.Status)
}
}
func TestNewNotFoundError(t *testing.T) {
problem := NewNotFoundError("User")
if problem.Type != TypeNotFound {
t.Errorf("Expected type %s, got %s", TypeNotFound, problem.Type)
}
if problem.Status != http.StatusNotFound {
t.Errorf("Expected status %d, got %d", http.StatusNotFound, problem.Status)
}
if problem.Detail != "User not found." {
t.Errorf("Expected detail 'User not found.', got '%s'", problem.Detail)
}
}
func TestNewConflictError(t *testing.T) {
detail := "Email already exists"
problem := NewConflictError(detail)
if problem.Type != TypeConflict {
t.Errorf("Expected type %s, got %s", TypeConflict, problem.Type)
}
if problem.Status != http.StatusConflict {
t.Errorf("Expected status %d, got %d", http.StatusConflict, problem.Status)
}
}
func TestNewInternalServerError(t *testing.T) {
detail := "Database connection failed"
problem := NewInternalServerError(detail)
if problem.Type != TypeInternalError {
t.Errorf("Expected type %s, got %s", TypeInternalError, problem.Type)
}
if problem.Status != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, problem.Status)
}
}
func TestNewServiceUnavailableError(t *testing.T) {
problem := NewServiceUnavailableError("")
if problem.Type != TypeServiceUnavailable {
t.Errorf("Expected type %s, got %s", TypeServiceUnavailable, problem.Type)
}
if problem.Status != http.StatusServiceUnavailable {
t.Errorf("Expected status %d, got %d", http.StatusServiceUnavailable, problem.Status)
}
}
func TestWithInstance(t *testing.T) {
problem := NewBadRequestError("Test")
instance := "/api/v1/test"
problem.WithInstance(instance)
if problem.Instance != instance {
t.Errorf("Expected instance '%s', got '%s'", instance, problem.Instance)
}
}
func TestWithTraceID(t *testing.T) {
problem := NewBadRequestError("Test")
traceID := "trace-123"
problem.WithTraceID(traceID)
if problem.TraceID != traceID {
t.Errorf("Expected traceID '%s', got '%s'", traceID, problem.TraceID)
}
}
func TestWithError(t *testing.T) {
problem := NewBadRequestError("Test")
problem.WithError("email", "Email is required")
problem.WithError("password", "Password is required")
if len(problem.Errors) != 2 {
t.Errorf("Expected 2 errors, got %d", len(problem.Errors))
}
if problem.Errors["email"] != "Email is required" {
t.Errorf("Expected email error, got '%s'", problem.Errors["email"])
}
}
func TestProblemDetailError(t *testing.T) {
detail := "Test detail"
problem := NewBadRequestError(detail)
if problem.Error() != detail {
t.Errorf("Expected Error() to return detail, got '%s'", problem.Error())
}
// Test with no detail
problem2 := &ProblemDetail{
Title: "Test Title",
}
if problem2.Error() != "Test Title" {
t.Errorf("Expected Error() to return title, got '%s'", problem2.Error())
}
}
func TestExtractRequestID_FromContext(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
// Note: In real code, request ID would be set by middleware
// For testing, we'll test the empty case
requestID := ExtractRequestID(req)
// Should return empty string when no request ID is present
if requestID != "" {
t.Errorf("Expected empty string, got '%s'", requestID)
}
}
func TestExtractRequestID_FromHeader(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Request-ID", "test-request-123")
requestID := ExtractRequestID(req)
if requestID != "test-request-123" {
t.Errorf("Expected 'test-request-123', got '%s'", requestID)
}
}
func TestRespondWithProblem(t *testing.T) {
problem := NewValidationError(map[string]string{
"email": "Email is required",
})
problem.WithInstance("/api/v1/test")
problem.WithTraceID("trace-123")
w := httptest.NewRecorder()
RespondWithProblem(w, problem)
// Check status code
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
// Check content type
contentType := w.Header().Get("Content-Type")
if contentType != "application/problem+json" {
t.Errorf("Expected Content-Type 'application/problem+json', got '%s'", contentType)
}
// Check JSON response
var response ProblemDetail
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Type != TypeValidationError {
t.Errorf("Expected type %s, got %s", TypeValidationError, response.Type)
}
if response.Instance != "/api/v1/test" {
t.Errorf("Expected instance '/api/v1/test', got '%s'", response.Instance)
}
if response.TraceID != "trace-123" {
t.Errorf("Expected traceID 'trace-123', got '%s'", response.TraceID)
}
if len(response.Errors) != 1 {
t.Errorf("Expected 1 error, got %d", len(response.Errors))
}
}
func TestRespondWithError_ProblemDetail(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/test", nil)
w := httptest.NewRecorder()
problem := NewBadRequestError("Test error")
RespondWithError(w, req, problem)
// Check status code
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
// Check that instance was set
var response ProblemDetail
json.NewDecoder(w.Body).Decode(&response)
if response.Instance != "/api/v1/test" {
t.Errorf("Expected instance to be set automatically, got '%s'", response.Instance)
}
}
func TestRespondWithError_StandardError(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/test", nil)
w := httptest.NewRecorder()
err := &customError{message: "Custom error"}
RespondWithError(w, req, err)
// Check status code (should be 500 for standard errors)
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
// Check that it was wrapped in a ProblemDetail
var response ProblemDetail
json.NewDecoder(w.Body).Decode(&response)
if response.Type != TypeInternalError {
t.Errorf("Expected type %s, got %s", TypeInternalError, response.Type)
}
if response.Detail != "Custom error" {
t.Errorf("Expected detail 'Custom error', got '%s'", response.Detail)
}
}
// Helper type for testing standard error handling
type customError struct {
message string
}
func (e *customError) Error() string {
return e.message
}
func TestChaining(t *testing.T) {
// Test method chaining
problem := NewBadRequestError("Test").
WithInstance("/api/v1/test").
WithTraceID("trace-123").
WithError("field1", "error1").
WithError("field2", "error2")
if problem.Instance != "/api/v1/test" {
t.Error("Instance not set correctly through chaining")
}
if problem.TraceID != "trace-123" {
t.Error("TraceID not set correctly through chaining")
}
if len(problem.Errors) != 2 {
t.Error("Errors not set correctly through chaining")
}
}