Skip to content

Commit

Permalink
feat: RFC 9457 compatible (#10)
Browse files Browse the repository at this point in the history
* feat: RFC 9457 compatible

* fix: test content-type (resolve: discussion_r1936938314)

* fix: validation as package var (resolve: discussion_r1936947547)

* fix: handle response encode error (resolve discussion_r1936947554)

* feat: expanding problem details

* fix: adjust typo and handle mutex

* fix: use default title based on status code

* fix: mu.RLock for getters
  • Loading branch information
rluders authored Jan 31, 2025
1 parent f081d27 commit 1909b26
Show file tree
Hide file tree
Showing 15 changed files with 580 additions and 129 deletions.
2 changes: 2 additions & 0 deletions examples/chi/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
Expand Down
3 changes: 3 additions & 0 deletions examples/chi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ func main() {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)

// Define the ProblemBaseURL - doesn't create the handlers
httpsuite.SetProblemBaseURL("http://localhost:8080")

// Define the endpoint POST
r.Post("/submit/{id}", func(w http.ResponseWriter, r *http.Request) {
// Using the function for parameter extraction to the ParseRequest
Expand Down
2 changes: 2 additions & 0 deletions examples/gorillamux/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
Expand Down
3 changes: 3 additions & 0 deletions examples/gorillamux/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func main() {
// Creating the router with Gorilla Mux
r := mux.NewRouter()

// Define the ProblemBaseURL - doesn't create the handlers
httpsuite.SetProblemBaseURL("http://localhost:8080")

r.HandleFunc("/submit/{id}", func(w http.ResponseWriter, r *http.Request) {
// Using the function for parameter extraction to the ParseRequest
req, err := httpsuite.ParseRequest[*SampleRequest](w, r, GorillaMuxParamExtractor, "id")
Expand Down
2 changes: 1 addition & 1 deletion examples/stdmux/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module stdmux_example

go 1.23

require github.com/rluders/httpsuite/v2 v2.0.0
require github.com/rluders/httpsuite/v2 v2.0.0

require (
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
Expand Down
2 changes: 2 additions & 0 deletions examples/stdmux/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
Expand Down
3 changes: 3 additions & 0 deletions examples/stdmux/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ func main() {
// Creating the router using the Go standard mux
mux := http.NewServeMux()

// Define the ProblemBaseURL - doesn't create the handlers
httpsuite.SetProblemBaseURL("http://localhost:8080")

// Define the endpoint POST
mux.HandleFunc("/submit/", func(w http.ResponseWriter, r *http.Request) {
// Using the function for parameter extraction to the ParseRequest
Expand Down
147 changes: 147 additions & 0 deletions problem_details.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package httpsuite

import (
"net/http"
"sync"
)

const BlankUrl = "about:blank"

var (
mu sync.RWMutex
problemBaseURL = BlankUrl
errorTypePaths = map[string]string{
"validation_error": "/errors/validation-error",
"not_found_error": "/errors/not-found",
"server_error": "/errors/server-error",
"bad_request_error": "/errors/bad-request",
}
)

// ProblemDetails conforms to RFC 9457, providing a standard format for describing errors in HTTP APIs.
type ProblemDetails struct {
Type string `json:"type"` // A URI reference identifying the problem type.
Title string `json:"title"` // A short, human-readable summary of the problem.
Status int `json:"status"` // The HTTP status code.
Detail string `json:"detail,omitempty"` // Detailed explanation of the problem.
Instance string `json:"instance,omitempty"` // A URI reference identifying the specific instance of the problem.
Extensions map[string]interface{} `json:"extensions,omitempty"` // Custom fields for additional details.
}

// NewProblemDetails creates a ProblemDetails instance with standard fields.
func NewProblemDetails(status int, problemType, title, detail string) *ProblemDetails {
if problemType == "" {
problemType = BlankUrl
}
if title == "" {
title = http.StatusText(status)
if title == "" {
title = "Unknown error"
}
}
return &ProblemDetails{
Type: problemType,
Title: title,
Status: status,
Detail: detail,
}
}

// SetProblemBaseURL configures the base URL used in the "type" field for ProblemDetails.
//
// This function allows applications using httpsuite to provide a custom domain and structure
// for error documentation URLs. By setting this base URL, the library can generate meaningful
// and discoverable problem types.
//
// Parameters:
// - baseURL: The base URL where error documentation is hosted (e.g., "https://api.mycompany.com").
//
// Example usage:
//
// httpsuite.SetProblemBaseURL("https://api.mycompany.com")
//
// Once configured, generated ProblemDetails will include a "type" such as:
//
// "https://api.mycompany.com/errors/validation-error"
//
// If the base URL is not set, the default value for the "type" field will be "about:blank".
func SetProblemBaseURL(baseURL string) {
mu.Lock()
defer mu.Unlock()
problemBaseURL = baseURL
}

// SetProblemErrorTypePath sets or updates the path for a specific error type.
//
// This allows applications to define custom paths for error documentation.
//
// Parameters:
// - errorType: The unique key identifying the error type (e.g., "validation_error").
// - path: The path under the base URL where the error documentation is located.
//
// Example usage:
//
// httpsuite.SetProblemErrorTypePath("validation_error", "/errors/validation-error")
//
// After setting this path, the generated problem type for "validation_error" will be:
//
// "https://api.mycompany.com/errors/validation-error"
func SetProblemErrorTypePath(errorType, path string) {
mu.Lock()
defer mu.Unlock()
errorTypePaths[errorType] = path
}

// SetProblemErrorTypePaths sets or updates multiple paths for different error types.
//
// This allows applications to define multiple custom paths at once.
//
// Parameters:
// - paths: A map of error types to paths (e.g., {"validation_error": "/errors/validation-error"}).
//
// Example usage:
//
// paths := map[string]string{
// "validation_error": "/errors/validation-error",
// "not_found_error": "/errors/not-found",
// }
// httpsuite.SetProblemErrorTypePaths(paths)
//
// This method overwrites any existing paths with the same keys.
func SetProblemErrorTypePaths(paths map[string]string) {
mu.Lock()
defer mu.Unlock()
for errorType, path := range paths {
errorTypePaths[errorType] = path
}
}

// GetProblemTypeURL get the full problem type URL based on the error type.
//
// If the error type is not found in the predefined paths, it returns a default unknown error path.
//
// Parameters:
// - errorType: The unique key identifying the error type (e.g., "validation_error").
//
// Example usage:
//
// problemTypeURL := GetProblemTypeURL("validation_error")
func GetProblemTypeURL(errorType string) string {
mu.RLock()
defer mu.RUnlock()
if path, exists := errorTypePaths[errorType]; exists {
return getProblemBaseURL() + path
}

return BlankUrl
}

// getProblemBaseURL just return the baseURL if it isn't "about:blank"
func getProblemBaseURL() string {
mu.RLock()
defer mu.RUnlock()
if problemBaseURL == BlankUrl {
return ""
}
return problemBaseURL
}
156 changes: 156 additions & 0 deletions problem_details_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package httpsuite

import (
"testing"

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

func Test_SetProblemBaseURL(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "Set valid base URL",
input: "https://api.example.com",
expected: "https://api.example.com",
},
{
name: "Set base URL to blank",
input: BlankUrl,
expected: BlankUrl,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
SetProblemBaseURL(tt.input)
assert.Equal(t, tt.expected, problemBaseURL)
})
}
}

func Test_SetProblemErrorTypePath(t *testing.T) {
tests := []struct {
name string
errorKey string
path string
expected string
}{
{
name: "Set custom error path",
errorKey: "custom_error",
path: "/errors/custom-error",
expected: "/errors/custom-error",
},
{
name: "Override existing path",
errorKey: "validation_error",
path: "/errors/new-validation-error",
expected: "/errors/new-validation-error",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
SetProblemErrorTypePath(tt.errorKey, tt.path)
assert.Equal(t, tt.expected, errorTypePaths[tt.errorKey])
})
}
}

func Test_GetProblemTypeURL(t *testing.T) {
// Setup initial state
SetProblemBaseURL("https://api.example.com")
SetProblemErrorTypePath("validation_error", "/errors/validation-error")

tests := []struct {
name string
errorType string
expectedURL string
}{
{
name: "Valid error type",
errorType: "validation_error",
expectedURL: "https://api.example.com/errors/validation-error",
},
{
name: "Unknown error type",
errorType: "unknown_error",
expectedURL: BlankUrl,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetProblemTypeURL(tt.errorType)
assert.Equal(t, tt.expectedURL, result)
})
}
}

func Test_getProblemBaseURL(t *testing.T) {
tests := []struct {
name string
baseURL string
expectedResult string
}{
{
name: "Base URL is set",
baseURL: "https://api.example.com",
expectedResult: "https://api.example.com",
},
{
name: "Base URL is about:blank",
baseURL: BlankUrl,
expectedResult: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
problemBaseURL = tt.baseURL
assert.Equal(t, tt.expectedResult, getProblemBaseURL())
})
}
}

func Test_NewProblemDetails(t *testing.T) {
tests := []struct {
name string
status int
problemType string
title string
detail string
expectedType string
}{
{
name: "All fields provided",
status: 400,
problemType: "https://api.example.com/errors/validation-error",
title: "Validation Error",
detail: "Invalid input",
expectedType: "https://api.example.com/errors/validation-error",
},
{
name: "Empty problem type",
status: 404,
problemType: "",
title: "Not Found",
detail: "The requested resource was not found",
expectedType: BlankUrl,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
details := NewProblemDetails(tt.status, tt.problemType, tt.title, tt.detail)
assert.Equal(t, tt.status, details.Status)
assert.Equal(t, tt.title, details.Title)
assert.Equal(t, tt.detail, details.Detail)
assert.Equal(t, tt.expectedType, details.Type)
})
}
}
Loading

0 comments on commit 1909b26

Please sign in to comment.