diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1f6f4be..8370dd1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,19 +6,23 @@ # that modifies code they own. # Default owner for everything in the repo -# TODO: Update with actual maintainer GitHub username(s) * @kolkov # Core routing engine - Critical performance and correctness /internal/radix/ @kolkov -/internal/pool/ @kolkov +/internal/binding/ @kolkov +/internal/negotiate/ @kolkov # Public API files - Breaking changes require careful review /router.go @kolkov /context.go @kolkov -/handler.go @kolkov +/context_base.go @kolkov +/context_generic.go @kolkov +/handler_generic.go @kolkov /group.go @kolkov /error.go @kolkov +/problem.go @kolkov +/openapi.go @kolkov # Built-in middleware - Standard middleware implementations /middleware/ @kolkov @@ -38,7 +42,6 @@ # Code quality and linting configuration /.golangci.yml @kolkov -/Makefile @kolkov # Tests - Ensure coverage and reliability *_test.go @kolkov diff --git a/CHANGELOG.md b/CHANGELOG.md index d947e60..0136aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ozzo-routing compatibility layer (lowercase `Get`/`Post` methods) — deferred, see ADR-001 - Radix tree edge cases (root path + param routes) — tracked by differential fuzz +## [0.6.0] - 2026-09-11 + +### Security +- **CORS preflight echo bypass** — preflight responses now return only filtered allowed headers, not raw `Access-Control-Request-Headers` value +- **Example JWT alg confusion** — production boilerplate validates `*jwt.SigningMethodHMAC` before returning key +- **PanicHandler re-panics `http.ErrAbortHandler`** — matches Recovery() behavior, Go net/http expects propagation + +### Added +- **`NewRateLimiter()`** — returns `*RateLimiter` with exported `Handler()` and `Stop()` methods for lifecycle management +- **`RateLimitConfig.NoHeaders`** — opt-out from `X-RateLimit-*` response headers (previously impossible to disable) + +### Changed +- **RFC 9457 Problem Details is now the default error response** for all auto-generated errors: + - Router: 404, 405, 413, 415, 400 (binding/decode), 500 + - Middleware: JWT 401, BasicAuth 401, RateLimit 429, CircuitBreaker 503, Recovery 500 + - Content-Type changed from `text/plain` to `application/problem+json` +- **Form binding type errors** (e.g., `age=abc`) now return 400 Bad Request (was 500 Internal Server Error) +- **`Problem.WithExtension`/`WithExtensions`** — deep-copy map to prevent aliasing between chained calls +- **Shutdown godoc** — fixed order description (drain connections first, then callbacks) + +### Fixed +- **CODEOWNERS** — removed references to non-existent `/internal/pool/`, `/handler.go`, `/Makefile` +- **SECURITY.md** — removed non-existent APIs (CSRF, Timeout, BodyLimit, HTTPSRedirect), fixed RateLimit signature, updated supported versions to 0.5.x +- **llms.md** — corrected `encoding/json/v2` → `encoding/json`, fixed stale API examples +- **Plugin READMEs** — replaced `router.Run()` with `http.ListenAndServe()` +- **README.md** — updated performance numbers (256 ns → 53 ns, 0 alloc) + +### Dependencies +- plugins/opentelemetry: OTel v1.38.0 → v1.46.0 +- plugins/validator: go-playground/validator v10.24.0 → v10.30.4 +- plugins/database: modernc.org/sqlite v1.40.1 → v1.58.0 +- plugins: fursy v0.5.3 → v0.5.4, stream v0.1.4 → v0.1.5 + ## [0.5.4] - 2026-09-10 ### Changed diff --git a/README.md b/README.md index 6afc771..100c8c5 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,8 @@ spec := r.OpenAPI(fursy.OpenAPIConfig{ ### Production-Ready Performance -- **256 ns/op** static routes, **326 ns/op** parametric routes -- **1 allocation/op** (routing hot path) -- **~10M req/s** throughput (simple routes) +- **~53 ns/op** static routes, **~75 ns/op** parametric routes +- **0 allocations/op** (zero-allocation routing) - Zero-allocation radix tree routing - Efficient context pooling @@ -141,7 +140,7 @@ go get github.com/coregx/fursy ## 🚀 Features -- ✅ **High Performance Routing** - 256-326 ns/op, 1 alloc/op +- ✅ **High Performance Routing** - ~53 ns/op static, ~75 ns/op parametric, 0 alloc/op - ✅ **Type-Safe Generic Methods** - `router.POST()` with Box[Req, Res] and Go 1.27 type inference - ✅ **Automatic Validation** - Set once, validate everywhere with 100+ tags - ✅ **Content Negotiation** - RFC 9110 compliant, AI agent support @@ -1171,7 +1170,7 @@ Your fursy application will automatically send traces to Jaeger. No configuratio **Coverage**: 94.6% test coverage (total), 94.6% core, 97.7% binding, 95.6% middleware -**Performance**: 256 ns/op (static), 326 ns/op (parametric), 1 alloc/op +**Performance**: ~53 ns/op (static), ~75 ns/op (parametric), 0 alloc/op **Roadmap**: diff --git a/SECURITY.md b/SECURITY.md index 2a41295..23912b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,8 @@ FURSY HTTP Router is currently in active development (0.x versions). We provide | Version | Supported | | ------- | ------------------ | -| 0.2.x | :white_check_mark: | -| 0.1.x | :white_check_mark: | -| < 0.1.0 | :x: | +| 0.5.x | :white_check_mark: | +| < 0.5.0 | :x: | Future stable releases (v1.0+) will follow semantic versioning with LTS support. @@ -105,7 +104,7 @@ router.GET("/files/:path", func(c *fursy.Context) error { **Mitigation**: - ✅ Efficient radix tree routing (O(log n) lookups) -- ✅ Zero-allocation routing (1 alloc/op) +- ✅ Zero-allocation routing (0 alloc/op) - ✅ Context pooling (prevents memory leaks) - ✅ Graceful shutdown (prevents resource leaks) - ✅ Circuit breaker middleware (prevents cascade failures) @@ -113,15 +112,14 @@ router.GET("/files/:path", func(c *fursy.Context) error { **User Recommendations**: ```go -// ✅ Use built-in middleware for protection -router.Use(fursy.RateLimit(100, time.Minute)) // 100 req/min -router.Use(fursy.CircuitBreaker(0.5, 100)) // 50% error rate threshold +import "github.com/coregx/fursy/middleware" -// ✅ Set reasonable timeouts -router.Use(fursy.Timeout(30 * time.Second)) +// ✅ Use built-in middleware for protection +router.Use(middleware.RateLimit(100, 200)) // 100 req/s, burst 200 +router.Use(middleware.CircuitBreaker()) // Default: 5 consecutive failures, 60s timeout // ✅ Limit request body size -router.Use(fursy.BodyLimit(10 * 1024 * 1024)) // 10MB max +router.SetMaxBodySize(10 * 1024 * 1024) // 10MB max ``` ### 3. Injection Attacks @@ -137,7 +135,7 @@ router.Use(fursy.BodyLimit(10 * 1024 * 1024)) // 10MB max **Mitigation**: - ✅ Parameter extraction is safe (no SQL/command execution) -- ✅ JSON parsing uses `encoding/json/v2` (safe unmarshaling) +- ✅ JSON parsing uses `encoding/json` (safe unmarshaling) - ✅ Header handling through stdlib (validated) - 🔄 **User Responsibility**: Sanitize data before database/external use @@ -176,22 +174,22 @@ router.POST("/search", func(c *fursy.Context) error { **User Best Practices**: ```go +import "github.com/coregx/fursy/middleware" + // ✅ Use JWT middleware for authentication -jwtMiddleware := fursy.JWT(fursy.JWTConfig{ - Secret: os.Getenv("JWT_SECRET"), - Expiration: 24 * time.Hour, -}) +jwtMiddleware := middleware.JWT([]byte(os.Getenv("JWT_SECRET"))) // Protected routes -protected := router.Group("/api", jwtMiddleware) -protected.GET("/users", getUsersHandler) -protected.POST("/users", createUserHandler) +protected := router.Group("/api") +protected.Use(jwtMiddleware) +protected.Handle("GET", "/users", getUsersHandler) +protected.Handle("POST", "/users", createUserHandler) // ✅ Implement authorization in handlers func getUsersHandler(c *fursy.Context) error { user := c.Get("user").(User) if !user.IsAdmin() { - return c.Error(403, fursy.Forbidden("Admin required")) + return c.Problem(fursy.Forbidden("Admin required")) } // ... } @@ -207,19 +205,20 @@ func getUsersHandler(c *fursy.Context) error { - DOM-based XSS (client-side rendering) **Mitigation**: -- ✅ JSON responses automatically escaped (`encoding/json/v2`) +- ✅ JSON responses automatically escaped (`encoding/json`) - ✅ Content-Type headers set correctly - ✅ Security headers middleware (CSP, X-XSS-Protection) - 🔄 **User Responsibility**: Sanitize HTML/JS output **User Best Practices**: ```go +import "github.com/coregx/fursy/middleware" + // ✅ Use security headers middleware -router.Use(fursy.SecurityHeaders(fursy.SecurityConfig{ +router.Use(middleware.SecureWithConfig(middleware.SecureConfig{ ContentSecurityPolicy: "default-src 'self'", XFrameOptions: "DENY", - XContentTypeOptions: "nosniff", - XSSProtection: "1; mode=block", + ContentTypeNosniff: "nosniff", })) // ✅ Return JSON (auto-escaped) @@ -241,24 +240,25 @@ router.GET("/profile", func(c *fursy.Context) error { **Risk**: Forged requests from malicious sites. **Mitigation**: -- ✅ CSRF token middleware available - ✅ SameSite cookie support -- ✅ Origin header validation -- 🔄 **User Responsibility**: Enable CSRF protection +- ✅ Origin header validation via CORS middleware +- 🔄 **User Responsibility**: Implement CSRF protection + +**Note**: CSRF middleware is not yet implemented -- planned for a future release. In the meantime, use SameSite cookies and CORS origin validation to mitigate CSRF risks. **User Best Practices**: ```go -// ✅ Enable CSRF protection for state-changing operations -csrfMiddleware := fursy.CSRF(fursy.CSRFConfig{ - TokenLength: 32, - CookieName: "_csrf", - HeaderName: "X-CSRF-Token", -}) +import "github.com/coregx/fursy/middleware" -router.Use(csrfMiddleware) +// ✅ Use CORS with strict origin validation +router.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: "https://example.com", + AllowMethods: "GET,POST,PUT,DELETE", + AllowCredentials: true, +})) -// Safe methods (GET, HEAD, OPTIONS) are exempt -// POST, PUT, DELETE require CSRF token +// ✅ Use SameSite cookies for session management +// Set SameSite=Strict or SameSite=Lax on session cookies ``` ## Security Best Practices for Users @@ -295,14 +295,15 @@ router.POST("/users", func(c *fursy.Context) error { Protect against abuse with rate limiting: ```go -// Global rate limit -router.Use(fursy.RateLimit(1000, time.Hour)) - -// Per-route rate limits -router.POST("/login", - fursy.RateLimit(5, time.Minute), // 5 attempts per minute - loginHandler, -) +import "github.com/coregx/fursy/middleware" + +// Global rate limit (rate per second, burst) +router.Use(middleware.RateLimit(100, 200)) + +// Per-group rate limits +loginGroup := router.Group("/login") +loginGroup.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10 +loginGroup.Handle("POST", "", loginHandler) ``` ### Error Handling @@ -333,17 +334,18 @@ router.GET("/users/:id", func(c *fursy.Context) error { Always use HTTPS in production: ```go -// ✅ Redirect HTTP to HTTPS -router.Use(fursy.HTTPSRedirect()) +import "github.com/coregx/fursy/middleware" -// ✅ Set secure headers -router.Use(fursy.SecurityHeaders(fursy.SecurityConfig{ +// ✅ Set HSTS headers to enforce HTTPS +router.Use(middleware.SecureWithConfig(middleware.SecureConfig{ HSTSMaxAge: 31536000, // 1 year - HSTSIncludeSubdomains: true, - HSTSPreload: true, + HSTSExcludeSubdomains: false, + HSTSPreloadEnabled: true, })) ``` +**Note**: HTTPS redirect middleware is not yet implemented. Use a reverse proxy (nginx, Caddy) or cloud load balancer for HTTP-to-HTTPS redirection. + ## Known Security Considerations ### 1. Route Parameter Injection diff --git a/context_test.go b/context_test.go index b1c853d..90796fb 100644 --- a/context_test.go +++ b/context_test.go @@ -783,9 +783,9 @@ func TestRouter_ContextErrorHandling(t *testing.T) { t.Errorf("Status code = %d, want 500", w.Code) } - body := w.Body.String() - if body != "Internal Server Error" { - t.Errorf("Body = %q, want %q", body, "Internal Server Error") + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/problem+json") { + t.Errorf("Content-Type = %q, want application/problem+json", ct) } } diff --git a/examples/10-production-boilerplate/internal/shared/auth/jwt.go b/examples/10-production-boilerplate/internal/shared/auth/jwt.go index 7b38262..f46ba08 100644 --- a/examples/10-production-boilerplate/internal/shared/auth/jwt.go +++ b/examples/10-production-boilerplate/internal/shared/auth/jwt.go @@ -52,6 +52,9 @@ func (s *JWTService) GenerateToken(userID, role string) (string, error) { // ValidateToken validates JWT token and returns claims. func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } return s.secret, nil }) diff --git a/examples/README.md b/examples/README.md index 4160f0c..96c6bdf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -56,8 +56,8 @@ go run main.go **1. Basic Router Setup** ```go router := fursy.New() -router.GET("/", handler) -router.Run(":8080") +router.Handle("GET", "/", handler) +log.Fatal(http.ListenAndServe(":8080", router)) ``` **2. With Database** diff --git a/examples/validation/06-production/README.md b/examples/validation/06-production/README.md index 7403610..43d75f1 100644 --- a/examples/validation/06-production/README.md +++ b/examples/validation/06-production/README.md @@ -346,8 +346,8 @@ Press `Ctrl+C` and observe graceful shutdown: - [ ] Change `JWT_SECRET` to strong random value - [ ] Use HTTPS in production - [ ] Implement password hashing (bcrypt) -- [ ] Add rate limiting (see `fursy.RateLimit()`) -- [ ] Add CORS middleware (see `fursy.CORS()`) +- [ ] Add rate limiting (see `middleware.RateLimit()`) +- [ ] Add CORS middleware (see `middleware.CORS()`) - [ ] Add request ID tracking - [ ] Implement proper session management - [ ] Add audit logging diff --git a/internal/binding/binding.go b/internal/binding/binding.go index 1a545d3..9b39686 100644 --- a/internal/binding/binding.go +++ b/internal/binding/binding.go @@ -172,7 +172,7 @@ func mapForm(ptr any, form map[string][]string) error { // Set field value if err := setField(field, values[0]); err != nil { - return fmt.Errorf("set field %s error: %w", structField.Name, err) + return &DecodeError{Err: fmt.Errorf("field %s: %w", structField.Name, err)} } } diff --git a/internal/binding/binding_test.go b/internal/binding/binding_test.go index 2d787a4..312610c 100644 --- a/internal/binding/binding_test.go +++ b/internal/binding/binding_test.go @@ -473,6 +473,23 @@ func TestMapForm_MissingFormValues(t *testing.T) { } } +// TestMapForm_TypeMismatch tests that form type errors return DecodeError. +func TestMapForm_TypeMismatch(t *testing.T) { + form := url.Values{ + "age": {"not-a-number"}, + } + var result BindTestStruct + err := mapForm(&result, form) + if err == nil { + t.Fatal("expected error for type mismatch, got nil") + } + + var decodeErr *DecodeError + if !errors.As(err, &decodeErr) { + t.Errorf("expected DecodeError, got %T: %v", err, err) + } +} + // TestMapForm_UnexportedFields tests that unexported fields are skipped. func TestMapForm_UnexportedFields(t *testing.T) { type withUnexported struct { diff --git a/llms.md b/llms.md index 60d3949..b3b3d7f 100644 --- a/llms.md +++ b/llms.md @@ -40,7 +40,7 @@ - **RFC 9457 Problem Details**: Standardized error responses built-in - **OpenAPI 3.1 Generation**: Automatic API documentation from code - **Minimal Dependencies**: Core = stdlib only, middleware = 2 dependencies (JWT, RateLimit) -- **Zero-Allocation Routing**: 256 ns/op, 1 alloc/op (production-ready performance) +- **Zero-Allocation Routing**: ~53 ns/op, 0 alloc/op (production-ready performance) - **Production Middleware**: 8 built-in middleware (Logger, Recovery, CORS, BasicAuth, JWT, RateLimit, CircuitBreaker, Secure) - **Content Negotiation**: RFC 9110 compliant with AI agent support (Markdown responses) @@ -52,7 +52,7 @@ - **Coverage**: 88.9% core (exceeds >85% target) - **Linter**: 0 issues (golangci-lint strict mode) - **Tests**: 150+ test functions, 19 benchmarks -- **Performance**: 256 ns/op static, 326 ns/op parametric, ~10M req/s throughput +- **Performance**: ~53 ns/op static, ~75 ns/op parametric, 0 alloc/op ### Production Ready @@ -76,7 +76,7 @@ - Current phase and progress (Phase 3 Complete, Phase 4 Ready) - Active tasks (currently: documentation and examples) - Test coverage (88.9%) -- Performance metrics (256 ns/op) +- Performance metrics (53 ns/op, 0 alloc) - Recent updates (rebranding FURY → fursy) - Kanban status (25 done, 32 in backlog) @@ -137,7 +137,7 @@ - O(log n) lookup complexity - 87.9% test coverage -**Performance**: 256 ns/op (static), 326 ns/op (parametric), 1 alloc/op +**Performance**: ~53 ns/op (static), ~75 ns/op (parametric), 0 alloc/op ### Generic Type-Safe Methods (Go 1.27+) @@ -654,19 +654,15 @@ admin.Handle("GET", "/users", listUsers) ## Development Standards -### 1. JSON: encoding/json/v2 ⚠️ CRITICAL +### 1. JSON: encoding/json -**MUST use** the new `encoding/json/v2` package: +Use the standard `encoding/json` package: ```go -// ✅ CORRECT: -import "encoding/json/v2" - -// ❌ WRONG: -import "encoding/json" // Old version, don't use! +import "encoding/json" ``` -**Why**: Go 1.25+ introduced new JSON API with better performance and features. +Go 1.27 made json/v2 the default implementation behind the `encoding/json` import path, so the import is simply `"encoding/json"`. ### 2. Logging: log/slog @@ -866,10 +862,10 @@ func BenchmarkRouter_StaticRoute(b *testing.B) { ``` **Current performance targets** (achieved): -- Static routes: <500 ns/op ✅ (256 ns/op) -- Parametric routes: <500 ns/op ✅ (326 ns/op) -- Allocations: 1 alloc/op ✅ -- Throughput: >100k req/s ✅ (~10M req/s) +- Static routes: <500 ns/op ✅ (~53 ns/op) +- Parametric routes: <500 ns/op ✅ (~75 ns/op) +- Allocations: 0 alloc/op ✅ +- Throughput: >100k req/s ✅ --- @@ -1147,7 +1143,7 @@ router.Use(middleware.Secure(middleware.SecureConfig{ | **Content Negotiation** | ✅ RFC 9110 | 🔧 Partial | 🔧 Partial | 🔧 Partial | ❌ No | | **OpenAPI Generation** | ✅ Built-in | 🔧 Plugin | 🔧 Plugin | 🔧 Plugin | 🔧 Plugin | | **Zero Deps (core)** | ✅ Yes | ❌ No | ❌ No | ❌ No | ✅ Yes | -| **Performance** | ⭐⭐⭐⭐⭐ 256 ns/op | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | +| **Performance** | ⭐⭐⭐⭐⭐ ~53 ns/op | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | | **Go Version** | 1.27+ | 1.13+ | 1.17+ | 1.17+ | 1.16+ | **fursy unique advantages**: @@ -1284,19 +1280,12 @@ router.Use(middleware.Secure(middleware.SecureConfig{ ### Routing Performance **Static routes**: -- 256 ns/op ✅ -- 1 alloc/op ✅ -- ~10.5M ops/s throughput +- ~53 ns/op ✅ +- 0 alloc/op ✅ **Parametric routes**: -- 326 ns/op ✅ (1 param) -- 344 ns/op ✅ (2 params) -- 561 ns/op ✅ (4 params - deep nesting) -- 1 alloc/op for all ✅ - -**Wildcard routes**: -- 539 ns/op ✅ -- 1 alloc/op ✅ +- ~75 ns/op ✅ (1 param) +- 0 alloc/op for all ✅ ### Context Operations @@ -1337,17 +1326,13 @@ router.Use(middleware.Secure(middleware.SecureConfig{ ## Common Gotchas -### 1. MUST use encoding/json/v2 ⚠️ CRITICAL +### 1. JSON: encoding/json ```go -// ❌ WRONG: import "encoding/json" - -// ✅ CORRECT: -import "encoding/json/v2" ``` -**Why**: Go 1.25+ introduced new JSON API. +Go 1.27 made json/v2 the default implementation behind the `encoding/json` import path, so the import is simply `"encoding/json"`. ### 2. MUST use log/slog for logging @@ -1675,7 +1660,7 @@ import "github.com/coregx/fursy/plugins/validator" 2. **RFC 9457 Problem Details** - Standard error format everywhere 3. **OpenAPI 3.1 generation** - Automatic from code 4. **Minimal dependencies** - Core = stdlib only -5. **256 ns/op routing** - Zero-allocation, 1 alloc/op +5. **~53 ns/op routing** - Zero-allocation, 0 alloc/op 6. **8 production middleware** - Logger, Recovery, CORS, BasicAuth, JWT, RateLimit, CircuitBreaker, Secure 7. **RFC 9110 content negotiation** - Multi-format responses including Markdown for AI agents @@ -1692,7 +1677,7 @@ import "github.com/coregx/fursy/plugins/validator" 1. **ALWAYS read STATUS.md first** (`.claude/STATUS.md`) 2. **ALWAYS read LINTER_RULES.md before coding** (`.claude/LINTER_RULES.md`) -3. **MUST use `encoding/json/v2`** (not `encoding/json`) +3. **MUST use `encoding/json`** (Go 1.27 json/v2 is the default behind this import path) 4. **MUST use `log/slog`** (not `log`) 5. **MUST run `go test -race`** before commit 6. **MUST pass `golangci-lint run`** with 0 issues diff --git a/middleware/basicauth.go b/middleware/basicauth.go index f7e0027..ad6b3a7 100644 --- a/middleware/basicauth.go +++ b/middleware/basicauth.go @@ -118,7 +118,7 @@ func BasicAuthWithConfig(config BasicAuthConfig) fursy.HandlerFunc { // Authentication failed - send WWW-Authenticate header. c.SetHeader("WWW-Authenticate", `Basic realm="`+config.Realm+`"`) - return c.String(http.StatusUnauthorized, "Unauthorized") + return c.Problem(fursy.NewProblem(http.StatusUnauthorized, "Unauthorized", "")) } } diff --git a/middleware/basicauth_test.go b/middleware/basicauth_test.go index 2582ffd..f9cb471 100644 --- a/middleware/basicauth_test.go +++ b/middleware/basicauth_test.go @@ -9,6 +9,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "github.com/coregx/fursy" @@ -72,8 +73,9 @@ func TestBasicAuth_NoAuth(t *testing.T) { t.Errorf("expected WWW-Authenticate header, got %s", wwwAuth) } - if w.Body.String() != "Unauthorized" { - t.Errorf("expected 'Unauthorized', got %s", w.Body.String()) + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/problem+json") { + t.Errorf("expected application/problem+json, got %s", ct) } } diff --git a/middleware/circuitbreaker.go b/middleware/circuitbreaker.go index 12b257d..becddd1 100644 --- a/middleware/circuitbreaker.go +++ b/middleware/circuitbreaker.go @@ -512,7 +512,7 @@ func (cb *circuitBreaker) GetCounts() Counts { // defaultCircuitBreakerErrorHandler is the default error handler for open circuit. func defaultCircuitBreakerErrorHandler(c *fursy.Context) error { - return c.String(http.StatusServiceUnavailable, "Service temporarily unavailable (circuit breaker open)") + return c.Problem(fursy.NewProblem(http.StatusServiceUnavailable, "Service Unavailable", "circuit breaker open")) } // Reset manually resets the circuit breaker to Closed state (for testing). diff --git a/middleware/cors.go b/middleware/cors.go index 1df23d8..7d6f868 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -222,7 +222,7 @@ func (cfg *CORSConfig) setPreflightHeaders(origin, method, reqHeaders string, he } if allowedHeaders != "" { - headers.Set(headerAllowHeaders, reqHeaders) + headers.Set(headerAllowHeaders, allowedHeaders) } } diff --git a/middleware/cors_test.go b/middleware/cors_test.go index 5e0bcd7..d6b545d 100644 --- a/middleware/cors_test.go +++ b/middleware/cors_test.go @@ -486,6 +486,45 @@ func TestCORSConfig_IsPreflightAllowed(t *testing.T) { }) } +// TestCORS_PreflightFilteredHeaders verifies that preflight responses only +// echo back headers that are in the AllowHeaders list, not the raw +// Access-Control-Request-Headers value. Echoing unfiltered headers lets +// browsers believe disallowed headers are permitted (allowlist bypass). +func TestCORS_PreflightFilteredHeaders(t *testing.T) { + r := fursy.New() + r.Use(CORSWithConfig(CORSConfig{ + AllowOrigins: "https://example.com", + AllowMethods: "GET,POST", + AllowHeaders: "Content-Type,Authorization", + })) + + r.Handle("OPTIONS", "/api", func(c *fursy.Context) error { + return c.NoContent(204) + }) + r.Handle("POST", "/api", func(c *fursy.Context) error { + return c.String(200, "OK") + }) + + req := httptest.NewRequest("OPTIONS", "/api", http.NoBody) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "Content-Type, X-Evil-Header") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + got := w.Header().Get("Access-Control-Allow-Headers") + + // Must contain only allowed headers, NOT X-Evil-Header. + if strings.Contains(got, "X-Evil-Header") { + t.Errorf("preflight echoed disallowed header: got %q, want only allowed headers", got) + } + + // Content-Type should still be present (it IS allowed). + if !strings.Contains(got, "Content-Type") { + t.Errorf("preflight missing allowed header Content-Type: got %q", got) + } +} + // --- F4 audit fix: CORS preflight unreachable + Vary: Origin --- // TestCORS_PreflightWithoutRoute verifies that an OPTIONS preflight request diff --git a/middleware/jwt.go b/middleware/jwt.go index d6741fb..61a3caa 100644 --- a/middleware/jwt.go +++ b/middleware/jwt.go @@ -421,7 +421,7 @@ func validateClaim(claims jwt.Claims, key, expected string) bool { // defaultJWTErrorHandler is the default error handler for JWT validation failures. // Does not expose error details to prevent information leakage. func defaultJWTErrorHandler(c *fursy.Context, _ error) error { - return c.String(http.StatusUnauthorized, "Unauthorized") + return c.Problem(fursy.NewProblem(http.StatusUnauthorized, "Unauthorized", "")) } // JWTHelper provides helper functions for working with JWT tokens. diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index f8d0c0a..637abdb 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -63,6 +63,10 @@ type RateLimitConfig struct { // Default: true (always enabled, recommended by RFC) Headers bool + // NoHeaders disables X-RateLimit-* response headers. + // Takes precedence over Headers when true. + NoHeaders bool + // MaxKeys is the maximum number of keys to store in memory. // Prevents memory exhaustion from key explosion. // When exceeded, oldest keys are evicted (LRU). @@ -90,6 +94,40 @@ type RateLimitStore interface { Cleanup(expireAfter time.Duration) } +// RateLimiter wraps a rate-limiting handler with lifecycle management. +// Use NewRateLimiter to create, Handler() to get the middleware, Stop() to +// release the cleanup goroutine when the limiter is no longer needed. +type RateLimiter struct { + handler fursy.HandlerFunc + store RateLimitStore +} + +// NewRateLimiter creates a RateLimiter with exported Stop for cleanup. +func NewRateLimiter(config RateLimitConfig) *RateLimiter { + rl := &RateLimiter{} + if config.Store == nil { + store := newInMemoryStore(config.MaxKeys) + config.Store = store + rl.store = store + } else { + rl.store = config.Store + } + rl.handler = RateLimitWithConfig(config) + return rl +} + +// Handler returns the middleware HandlerFunc for use with router.Use(). +func (rl *RateLimiter) Handler() fursy.HandlerFunc { + return rl.handler +} + +// Stop releases the cleanup goroutine. Safe to call multiple times. +func (rl *RateLimiter) Stop() { + if ms, ok := rl.store.(*inMemoryStore); ok { + ms.Stop() + } +} + // inMemoryStore is the default in-memory store for rate limiters. // Uses container/list for true LRU eviction with O(1) operations. type inMemoryStore struct { @@ -308,9 +346,9 @@ func RateLimitWithConfig(config RateLimitConfig) fursy.HandlerFunc { config.ErrorHandler = defaultRateLimitErrorHandler } - // Headers always enabled (RFC-compliant) unless explicitly disabled. - if !config.Headers { - // Enable headers by default. + if config.NoHeaders { + config.Headers = false + } else if !config.Headers { config.Headers = true } @@ -412,5 +450,5 @@ func defaultRateLimitErrorHandler(c *fursy.Context, retryAfter time.Duration) er c.SetHeader("X-RateLimit-Remaining", "0") // Return 429 Too Many Requests. - return c.String(http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.") + return c.Problem(fursy.NewProblem(http.StatusTooManyRequests, "Too Many Requests", "Rate limit exceeded. Please try again later.")) } diff --git a/middleware/ratelimit_test.go b/middleware/ratelimit_test.go index 56b0c52..21981a6 100644 --- a/middleware/ratelimit_test.go +++ b/middleware/ratelimit_test.go @@ -754,3 +754,57 @@ func TestRateLimit_StopIdempotent(_ *testing.T) { store.Stop() store.Stop() } + +// TestNewRateLimiter_StopExported verifies that NewRateLimiter returns a +// RateLimiter with an exported Stop() method that stops the cleanup goroutine. +func TestNewRateLimiter_StopExported(t *testing.T) { + rl := NewRateLimiter(RateLimitConfig{ + Rate: 10, + Burst: 20, + }) + + r := fursy.New() + r.Use(rl.Handler()) + r.Handle("GET", "/test", func(c *fursy.Context) error { + return c.String(200, "OK") + }) + + req := httptest.NewRequest("GET", "/test", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Stop must not panic and must be callable from user code. + rl.Stop() + rl.Stop() // idempotent +} + +// TestRateLimit_HeadersDisable verifies that Headers can be explicitly disabled. +func TestRateLimit_HeadersDisable(t *testing.T) { + rl := NewRateLimiter(RateLimitConfig{ + Rate: 10, + Burst: 20, + NoHeaders: true, + }) + + r := fursy.New() + r.Use(rl.Handler()) + r.Handle("GET", "/test", func(c *fursy.Context) error { + return c.String(200, "OK") + }) + + req := httptest.NewRequest("GET", "/test", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + if got := w.Header().Get("X-RateLimit-Limit"); got != "" { + t.Errorf("expected no X-RateLimit-Limit header when disabled, got %q", got) + } +} diff --git a/middleware/recovery.go b/middleware/recovery.go index 7e11db6..202d90a 100644 --- a/middleware/recovery.go +++ b/middleware/recovery.go @@ -114,8 +114,7 @@ func handlePanic(r interface{}, c *fursy.Context, logger *slog.Logger, config Re // Print stack to stderr for visibility. printStackToStderr(panicErr, stack, config) - // Send 500 response. - return c.String(http.StatusInternalServerError, "Internal Server Error") + return c.Problem(fursy.NewProblem(http.StatusInternalServerError, "Internal Server Error", "")) } // getStackTrace gets the current stack trace if not disabled. @@ -173,15 +172,17 @@ func PanicHandler() fursy.HandlerFunc { return func(c *fursy.Context) (err error) { defer func() { if r := recover(); r != nil { - // Convert panic to error. + if abortErr, ok := r.(error); ok && errors.Is(abortErr, http.ErrAbortHandler) { + panic(r) + } + if e, ok := r.(error); ok { err = e } else { err = fmt.Errorf("%v", r) } - // Send 500 response. - _ = c.String(http.StatusInternalServerError, "Internal Server Error") + _ = c.Problem(fursy.NewProblem(http.StatusInternalServerError, "Internal Server Error", "")) } }() diff --git a/plugins/database/README.md b/plugins/database/README.md index 9a31186..2cc98a4 100644 --- a/plugins/database/README.md +++ b/plugins/database/README.md @@ -24,6 +24,9 @@ package main import ( "database/sql" + "log" + "net/http" + "github.com/coregx/fursy" "github.com/coregx/fursy/plugins/database" _ "github.com/lib/pq" // PostgreSQL driver @@ -66,7 +69,7 @@ func main() { return c.JSON(200, user) }) - router.Run(":8080") + log.Fatal(http.ListenAndServe(":8080", router)) } ``` diff --git a/plugins/database/go.mod b/plugins/database/go.mod index 7891163..419d19c 100644 --- a/plugins/database/go.mod +++ b/plugins/database/go.mod @@ -3,21 +3,20 @@ module github.com/coregx/fursy/plugins/database go 1.27 require ( - github.com/coregx/fursy v0.5.3 - modernc.org/sqlite v1.40.1 + github.com/coregx/fursy v0.5.4 + modernc.org/sqlite v1.58.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.36.0 // indirect - modernc.org/libc v1.66.10 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.6 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect + modernc.org/memory v1.12.1 // indirect ) replace github.com/coregx/fursy => ../.. diff --git a/plugins/database/go.sum b/plugins/database/go.sum index 86155cd..7ad7cda 100644 --- a/plugins/database/go.sum +++ b/plugins/database/go.sum @@ -1,48 +1,49 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= -modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= -modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= -modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= -modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= -modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/plugins/opentelemetry/go.mod b/plugins/opentelemetry/go.mod index 5e6f3c7..51c6601 100644 --- a/plugins/opentelemetry/go.mod +++ b/plugins/opentelemetry/go.mod @@ -3,20 +3,21 @@ module github.com/coregx/fursy/plugins/opentelemetry go 1.27 require ( - github.com/coregx/fursy v0.5.3 - go.opentelemetry.io/otel v1.38.0 - go.opentelemetry.io/otel/metric v1.38.0 - go.opentelemetry.io/otel/sdk v1.38.0 - go.opentelemetry.io/otel/sdk/metric v1.38.0 - go.opentelemetry.io/otel/trace v1.38.0 + github.com/coregx/fursy v0.5.4 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/metric v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 + go.opentelemetry.io/otel/sdk/metric v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 ) require ( - github.com/go-logr/logr v1.4.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - golang.org/x/sys v0.36.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + golang.org/x/sys v0.47.0 // indirect ) // Use local fursy module during development. diff --git a/plugins/opentelemetry/go.sum b/plugins/opentelemetry/go.sum index 5943255..c24af50 100644 --- a/plugins/opentelemetry/go.sum +++ b/plugins/opentelemetry/go.sum @@ -1,33 +1,33 @@ -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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -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.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/plugins/stream/README.md b/plugins/stream/README.md index 889877c..27b371f 100644 --- a/plugins/stream/README.md +++ b/plugins/stream/README.md @@ -26,6 +26,7 @@ package main import ( "log" + "net/http" "time" "github.com/coregx/fursy" @@ -76,7 +77,7 @@ func main() { return c.JSON(200, map[string]string{"status": "sent"}) }) - log.Fatal(router.Run(":8080")) + log.Fatal(http.ListenAndServe(":8080", router)) } ``` @@ -98,6 +99,7 @@ package main import ( "log" + "net/http" "github.com/coregx/fursy" "github.com/coregx/fursy/plugins/stream" @@ -144,7 +146,7 @@ func main() { }) }) - log.Fatal(router.Run(":8080")) + log.Fatal(http.ListenAndServe(":8080", router)) } ``` diff --git a/plugins/stream/go.mod b/plugins/stream/go.mod index 4fc25ad..dbcdba3 100644 --- a/plugins/stream/go.mod +++ b/plugins/stream/go.mod @@ -3,8 +3,8 @@ module github.com/coregx/fursy/plugins/stream go 1.27 require ( - github.com/coregx/fursy v0.5.3 - github.com/coregx/stream v0.1.4 + github.com/coregx/fursy v0.5.4 + github.com/coregx/stream v0.1.5 ) // Local development - replace with actual module paths. diff --git a/plugins/stream/go.sum b/plugins/stream/go.sum index faecde8..7a58a74 100644 --- a/plugins/stream/go.sum +++ b/plugins/stream/go.sum @@ -1,2 +1,2 @@ -github.com/coregx/stream v0.1.4 h1:MQE88pkvuFBlFkrtogk1X39CZJG8oE+avkx3+Y5GJq4= -github.com/coregx/stream v0.1.4/go.mod h1:Nv8tdDu8yQ8jK7ctayRkXdmk3TMX1sS8En2qg0Izz7A= +github.com/coregx/stream v0.1.5 h1:rsrTqw9MN0ciHyusRgEmcVtVIAHVfb+wYm2FSWWW01w= +github.com/coregx/stream v0.1.5/go.mod h1:Nv8tdDu8yQ8jK7ctayRkXdmk3TMX1sS8En2qg0Izz7A= diff --git a/plugins/validator/README.md b/plugins/validator/README.md index 099e071..033517c 100644 --- a/plugins/validator/README.md +++ b/plugins/validator/README.md @@ -32,6 +32,9 @@ go get github.com/coregx/fursy/plugins/validator package main import ( + "log" + "net/http" + "github.com/coregx/fursy" "github.com/coregx/fursy/plugins/validator" ) @@ -64,7 +67,7 @@ func main() { return c.Created("/users/"+user.ID, user) }) - router.Run(":8080") + log.Fatal(http.ListenAndServe(":8080", router)) } ``` diff --git a/plugins/validator/go.mod b/plugins/validator/go.mod index 49c898c..1ea9aee 100644 --- a/plugins/validator/go.mod +++ b/plugins/validator/go.mod @@ -3,19 +3,18 @@ module github.com/coregx/fursy/plugins/validator go 1.27 require ( - github.com/coregx/fursy v0.5.3 - github.com/go-playground/validator/v10 v10.24.0 + github.com/coregx/fursy v0.5.4 + github.com/go-playground/validator/v10 v10.30.4 ) require ( - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.21.0 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect ) replace github.com/coregx/fursy => ../.. diff --git a/plugins/validator/go.sum b/plugins/validator/go.sum index 7ae379e..4b92ca5 100644 --- a/plugins/validator/go.sum +++ b/plugins/validator/go.sum @@ -1,28 +1,26 @@ 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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= -github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= -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/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= +github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= 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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/problem.go b/problem.go index 6e3acc1..b240975 100644 --- a/problem.go +++ b/problem.go @@ -148,21 +148,25 @@ func (p Problem) WithInstance(instance string) Problem { // WithExtension adds an extension field to the problem. func (p Problem) WithExtension(key string, value any) Problem { - if p.Extensions == nil { - p.Extensions = make(map[string]any) + ext := make(map[string]any, len(p.Extensions)+1) + for k, v := range p.Extensions { + ext[k] = v } - p.Extensions[key] = value + ext[key] = value + p.Extensions = ext return p } // WithExtensions sets multiple extension fields at once. func (p Problem) WithExtensions(extensions map[string]any) Problem { - if p.Extensions == nil { - p.Extensions = make(map[string]any) + ext := make(map[string]any, len(p.Extensions)+len(extensions)) + for k, v := range p.Extensions { + ext[k] = v } for k, v := range extensions { - p.Extensions[k] = v + ext[k] = v } + p.Extensions = ext return p } diff --git a/router.go b/router.go index 41299b9..84b3b2f 100644 --- a/router.go +++ b/router.go @@ -723,29 +723,29 @@ func defaultErrorHandler(c *Context, err error) { // MaxBytesError → 413 Payload Too Large. var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { - _ = c.String(http.StatusRequestEntityTooLarge, "Request Entity Too Large") + _ = c.Problem(NewProblem(http.StatusRequestEntityTooLarge, "Request Entity Too Large", "")) return } // Binding errors → 400 or 415. if errors.Is(err, binding.ErrUnsupportedMediaType) { - _ = c.String(http.StatusUnsupportedMediaType, "Unsupported Media Type") + _ = c.Problem(NewProblem(http.StatusUnsupportedMediaType, "Unsupported Media Type", "")) return } if errors.Is(err, binding.ErrEmptyRequestBody) { - _ = c.String(http.StatusBadRequest, "Bad Request") + _ = c.Problem(NewProblem(http.StatusBadRequest, "Bad Request", "request body is empty")) return } // JSON/XML decode errors → 400. var decodeErr *binding.DecodeError if errors.As(err, &decodeErr) { - _ = c.String(http.StatusBadRequest, "Bad Request") + _ = c.Problem(NewProblem(http.StatusBadRequest, "Bad Request", decodeErr.Error())) return } // Unknown → 500 without details (security: don't leak internals). - _ = c.String(http.StatusInternalServerError, "Internal Server Error") + _ = c.Problem(NewProblem(http.StatusInternalServerError, "Internal Server Error", "")) } // handleNotFound sends a 404 or 405 response depending on configuration. @@ -773,7 +773,7 @@ func (r *Router) handleNotFound(c *Context, w http.ResponseWriter, req *http.Req if allowed := r.allowedMethods(path, req.Method); allowed != "" { terminalHandler = func(ctx *Context) error { ctx.SetHeader("Allow", allowed) - return ctx.String(http.StatusMethodNotAllowed, "Method Not Allowed") + return ctx.Problem(NewProblem(http.StatusMethodNotAllowed, "Method Not Allowed", "")) } } } @@ -781,7 +781,7 @@ func (r *Router) handleNotFound(c *Context, w http.ResponseWriter, req *http.Req // 404 Not Found: default. if terminalHandler == nil { terminalHandler = func(ctx *Context) error { - return ctx.String(http.StatusNotFound, "Not Found") + return ctx.Problem(NewProblem(http.StatusNotFound, "Not Found", "")) } } @@ -973,8 +973,8 @@ func (r *Router) OnShutdown(f func()) { // Shutdown gracefully shuts down the HTTP server and executes registered callbacks. // // Shutdown works in two phases: -// 1. Calls all registered OnShutdown callbacks in reverse order -// 2. Calls http.Server.Shutdown() to gracefully stop the server +// 1. Calls http.Server.Shutdown() to drain active connections +// 2. Calls all registered OnShutdown callbacks in reverse order // // The server shutdown process: // - Immediately closes all listeners (stops accepting new connections) diff --git a/router_test.go b/router_test.go index 9a4c9ae..b291f17 100644 --- a/router_test.go +++ b/router_test.go @@ -2,6 +2,7 @@ package fursy import ( "bytes" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -287,9 +288,56 @@ func TestRouter_ServeHTTP_NotFound(t *testing.T) { if w.Code != http.StatusNotFound { t.Errorf("Status code = %d, want %d", w.Code, http.StatusNotFound) } - body, _ := io.ReadAll(w.Body) - if string(body) != "Not Found" { - t.Errorf("Body = %q, want %q", body, "Not Found") + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/problem+json") { + t.Errorf("Content-Type = %q, want application/problem+json", ct) + } +} + +// TestRouter_ErrorResponses_ProblemJSON verifies that all auto-generated error +// responses use RFC 9457 Problem Details (application/problem+json). +func TestRouter_ErrorResponses_ProblemJSON(t *testing.T) { + r := New() + r.Handle("GET", "/users", func(c *Context) error { + return c.String(200, "OK") + }) + + tests := []struct { + name string + method string + path string + status int + }{ + {"404 Not Found", "GET", "/notfound", 404}, + {"405 Method Not Allowed", "POST", "/users", 405}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, http.NoBody) + r.ServeHTTP(w, req) + + if w.Code != tt.status { + t.Fatalf("status = %d, want %d", w.Code, tt.status) + } + + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/problem+json") { + t.Errorf("Content-Type = %q, want application/problem+json", ct) + } + + var p Problem + if err := json.NewDecoder(w.Body).Decode(&p); err != nil { + t.Fatalf("failed to decode Problem JSON: %v", err) + } + if p.Status != tt.status { + t.Errorf("Problem.Status = %d, want %d", p.Status, tt.status) + } + if p.Title == "" { + t.Error("Problem.Title should not be empty") + } + }) } } @@ -308,9 +356,9 @@ func TestRouter_ServeHTTP_MethodNotAllowed(t *testing.T) { if w.Code != http.StatusMethodNotAllowed { t.Errorf("Status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) } - body, _ := io.ReadAll(w.Body) - if string(body) != "Method Not Allowed" { - t.Errorf("Body = %q, want %q", body, "Method Not Allowed") + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/problem+json") { + t.Errorf("Content-Type = %q, want application/problem+json", ct) } } @@ -376,9 +424,9 @@ func TestRouter_ServeHTTP_HandlerError(t *testing.T) { if w.Code != http.StatusInternalServerError { t.Errorf("Status code = %d, want %d", w.Code, http.StatusInternalServerError) } - body, _ := io.ReadAll(w.Body) - if string(body) != "Internal Server Error" { - t.Errorf("Body = %q, want %q", body, "Internal Server Error") + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/problem+json") { + t.Errorf("Content-Type = %q, want application/problem+json", ct) } } @@ -546,9 +594,9 @@ func TestRouter_StripTrailingSlash(t *testing.T) { {"strip trailing slash", "/users/", 200, "users"}, {"param exact", "/users/42/posts", 200, "posts:42"}, {"param strip slash", "/users/42/posts/", 200, "posts:42"}, - {"unregistered path", "/notfound", 404, "Not Found"}, - {"unregistered with slash", "/notfound/", 404, "Not Found"}, - {"root path", "/", 404, "Not Found"}, + {"unregistered path", "/notfound", 404, ""}, + {"unregistered with slash", "/notfound/", 404, ""}, + {"root path", "/", 404, ""}, } for _, tt := range tests { @@ -560,9 +608,11 @@ func TestRouter_StripTrailingSlash(t *testing.T) { if w.Code != tt.wantCode { t.Errorf("GET %s: status = %d, want %d", tt.path, w.Code, tt.wantCode) } - body, _ := io.ReadAll(w.Body) - if string(body) != tt.wantBody { - t.Errorf("GET %s: body = %q, want %q", tt.path, body, tt.wantBody) + if tt.wantBody != "" { + body, _ := io.ReadAll(w.Body) + if string(body) != tt.wantBody { + t.Errorf("GET %s: body = %q, want %q", tt.path, body, tt.wantBody) + } } }) }