Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Planned
- ozzo-routing compatibility layer (lowercase `Get`/`Post` methods) — deferred, see ADR-001

## [0.5.1] - 2026-09-10

### Fixed
- **Error pipeline** — `Router.SetErrorHandler()` with default mapping: Problem→status code, ValidationErrors→422, binding errors→400/415, unknown→500 without details. Previously all errors returned plain-text 500
- **Group middleware inheritance** — child groups with explicit middleware now append to parent middleware instead of replacing it. Prevents auth bypass when nesting groups
- **Shutdown order** — `Shutdown()` now drains active connections before calling cleanup callbacks. Previously callbacks (e.g. db.Close) ran while requests were still active
- **CORS preflight** — OPTIONS requests now reach middleware without explicit OPTIONS route. `Vary: Origin` header added on all CORS responses
- **405 Allow header** — 405 Method Not Allowed now includes `Allow` header listing valid methods (RFC 9110)
- **Group routes in OpenAPI** — routes registered via RouteGroup now appear in generated OpenAPI spec
- **Rate limit XFF spoofing** — default KeyFunc now uses `RemoteAddr` instead of trusting `X-Forwarded-For`

### Added
- `Router.SetErrorHandler(ErrorHandler)` — custom error handler
- `Router.SetMaxBodySize(int64)` — request body size limit (default 4MB, mapped to 413)
- `Tree.Contains(path)` — zero-alloc existence check for 405 responses

### Removed
- `Context.DB()` — broken context key, use `database.GetDB(c)` instead
- `Context.SSE()` — stub, use `stream.SSEUpgrade(c, handler)` instead
- `Context.WebSocket()` — stub, use `stream.WebSocketUpgrade(c, handler, opts)` instead
- `ErrStreamNotImported` — no longer needed

### Changed
- `encoding/json/v2` imports replaced with `encoding/json` (Go 1.27 default)

### Security
- Body size limit prevents memory DoS via large request bodies
- Rate limit no longer trusts spoofable proxy headers by default
- Error handler does not leak internal error details to clients
- CORS `Vary: Origin` prevents cache poisoning

## [0.5.0] - 2026-09-10

### Added
Expand Down
6 changes: 3 additions & 3 deletions box_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,9 @@ func TestGenericContext_InvalidJSON(t *testing.T) {

r.ServeHTTP(w, req)

// Should return 500 due to binding error (handled by router error handling)
if w.Code != 500 {
t.Errorf("expected status 500, got %d", w.Code)
// Should return 400 due to binding error (malformed JSON → Bad Request).
if w.Code != 400 {
t.Errorf("expected status 400, got %d", w.Code)
}
}

Expand Down
213 changes: 7 additions & 206 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ type Context struct {
// data stores arbitrary values for passing data between middleware.
data map[string]any

// written tracks if any response body has been written.
written bool

// Middleware chain execution.
// Pre-allocated with capacity 16 to avoid allocations for typical middleware chains.
handlers []HandlerFunc
Expand Down Expand Up @@ -157,6 +160,7 @@ func (c *Context) reset() {

c.index = -1
c.aborted = false
c.written = false
}

// Next executes the next handler in the middleware chain.
Expand Down Expand Up @@ -332,6 +336,7 @@ func (c *Context) PostForm(name string) string {
//
// return c.String(200, "Hello, World!")
func (c *Context) String(code int, s string) error {
c.written = true
c.Response.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Response.WriteHeader(code)
_, err := c.Response.Write([]byte(s))
Expand All @@ -345,6 +350,7 @@ func (c *Context) String(code int, s string) error {
//
// return c.JSON(200, map[string]string{"message": "success"})
func (c *Context) JSON(code int, obj any) error {
c.written = true
c.Response.Header().Set("Content-Type", "application/json; charset=utf-8")
c.Response.WriteHeader(code)
encoder := json.NewEncoder(c.Response)
Expand Down Expand Up @@ -652,7 +658,7 @@ func (c *Context) GetBool(key string) bool {
// generic handler adapter. If you need custom error handling, check
// the error returned by your handler logic instead.
func (c *Context) Problem(p Problem) error {
// Set proper Content-Type for RFC 9457.
c.written = true
c.Response.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
c.Response.WriteHeader(p.Status)
encoder := json.NewEncoder(c.Response)
Expand Down Expand Up @@ -795,208 +801,3 @@ func NotAcceptable(detail string) Problem {

// ErrInvalidRedirectCode is returned when redirect code is not 3xx.
var ErrInvalidRedirectCode = errors.New("fursy: invalid redirect code (must be 3xx)")

// ========================================
// Real-time Communication Methods
// ========================================
//
// These methods provide integration with github.com/coregx/stream library
// for Server-Sent Events (SSE) and WebSocket real-time communication.
//
// To use these methods, you must:
// 1. Import github.com/coregx/fursy/plugins/stream package
// 2. Use stream.SSEHub[T]() or stream.WebSocketHub() middleware
// 3. Call c.SSE() or c.WebSocket() in handlers
//
// These methods are part of fursy core for convenient API,
// but the actual implementation requires the plugins/stream package.

// SSE upgrades the HTTP connection to Server-Sent Events.
//
// The handler function receives an SSE connection and should handle
// the SSE lifecycle (register to hub, send events, etc.).
//
// The connection is automatically closed when the handler returns.
//
// Requires: github.com/coregx/stream/sse package
//
// Example:
//
// // In main.go:
// import (
// "github.com/coregx/fursy"
// "github.com/coregx/fursy/plugins/stream"
// "github.com/coregx/stream/sse"
// )
//
// hub := sse.NewHub[Notification]()
// go hub.Run()
// defer hub.Close()
//
// router := fursy.New()
// router.Use(stream.SSEHub(hub))
//
// router.Handle("GET", "/events", func(c *fursy.Context) error {
// hub, _ := stream.GetSSEHub[Notification](c)
//
// return c.SSE(func(conn *sse.Conn) error {
// hub.Register(conn)
// defer hub.Unregister(conn)
// <-conn.Done()
// return nil
// })
// })
//
// Note: This method signature is defined in fursy core, but requires
// github.com/coregx/stream/sse to be imported in your code for the Conn type.
//
//nolint:revive // Parameters needed for API documentation (stub method).
func (c *Context) SSE(handler func(conn any) error) error {
// This method is intentionally defined with 'any' type to avoid
// importing github.com/coregx/stream/sse in fursy core.
//
// Users will import sse package and use the concrete *sse.Conn type
// in their handler function. The type checking happens at compile time.
//
// Actual implementation is provided by plugins/stream package helper.
return ErrStreamNotImported
}

// WebSocket upgrades the HTTP connection to WebSocket.
//
// The handler function receives a WebSocket connection and should handle
// the WebSocket lifecycle (register to hub, read/write messages, etc.).
//
// The connection is automatically closed when the handler returns.
//
// Requires: github.com/coregx/stream/websocket package
//
// Example:
//
// // In main.go:
// import (
// "github.com/coregx/fursy"
// "github.com/coregx/fursy/plugins/stream"
// "github.com/coregx/stream/websocket"
// )
//
// hub := websocket.NewHub()
// go hub.Run()
// defer hub.Close()
//
// router := fursy.New()
// router.Use(stream.WebSocketHub(hub))
//
// router.Handle("GET", "/ws", func(c *fursy.Context) error {
// hub, _ := stream.GetWebSocketHub(c)
//
// return c.WebSocket(func(conn *websocket.Conn) error {
// hub.Register(conn)
// defer hub.Unregister(conn)
//
// for {
// msgType, data, err := conn.Read()
// if err != nil {
// return err
// }
// hub.Broadcast(data)
// }
// }, nil)
// })
//
// Note: This method signature is defined in fursy core, but requires
// github.com/coregx/stream/websocket to be imported in your code for the Conn type.
//
//nolint:revive // Parameters needed for API documentation (stub method).
func (c *Context) WebSocket(handler func(conn any) error, opts any) error {
// This method is intentionally defined with 'any' types to avoid
// importing github.com/coregx/stream/websocket in fursy core.
//
// Users will import websocket package and use the concrete types:
// - *websocket.Conn for conn parameter
// - *websocket.UpgradeOptions for opts parameter
//
// Actual implementation is provided by plugins/stream package helper.
return ErrStreamNotImported
}

// ErrStreamNotImported is returned when SSE or WebSocket methods are called
// without importing github.com/coregx/fursy/plugins/stream package.
var ErrStreamNotImported = errors.New("fursy: stream plugin not imported - add 'import _ \"github.com/coregx/fursy/plugins/stream\"' to your code")

// ========================================
// Database Integration Methods
// ========================================
//
// These methods provide integration with database/sql through
// github.com/coregx/fursy/plugins/database package.
//
// To use these methods, you must:
// 1. Import github.com/coregx/fursy/plugins/database package
// 2. Use database.Middleware(db) to configure database
// 3. Call c.DB() in handlers to access database
//
// The database integration is designed to work with any database/sql driver
// (PostgreSQL, MySQL, SQLite, etc.) while providing convenient fursy integration.

// DB returns the database connection from the context.
//
// Returns nil if database middleware is not configured.
//
// Requires: github.com/coregx/fursy/plugins/database package
//
// Example:
//
// // In main.go:
// import (
// "database/sql"
// "github.com/coregx/fursy"
// "github.com/coregx/fursy/plugins/database"
// _ "github.com/lib/pq" // PostgreSQL driver
// )
//
// sqlDB, _ := sql.Open("postgres", dsn)
// db := database.NewDB(sqlDB)
//
// router := fursy.New()
// router.Use(database.Middleware(db))
//
// router.Handle("GET", "/users/:id", func(c *fursy.Context) error {
// db := c.DB()
// if db == nil {
// return c.Problem(fursy.InternalServerError("Database not configured"))
// }
//
// var user User
// err := db.QueryRow(c.Request.Context(),
// "SELECT id, name FROM users WHERE id = $1", c.Param("id")).
// Scan(&user.ID, &user.Name)
//
// if err == sql.ErrNoRows {
// return c.Problem(fursy.NotFound("User not found"))
// }
// if err != nil {
// return c.Problem(fursy.InternalServerError(err.Error()))
// }
//
// return c.JSON(200, user)
// })
//
// Note: This method signature returns 'any' to avoid importing
// github.com/coregx/fursy/plugins/database in fursy core.
// The actual type is *database.DB when database middleware is configured.
func (c *Context) DB() any {
// This method is intentionally defined with 'any' return type to avoid
// importing github.com/coregx/fursy/plugins/database in fursy core.
//
// Users will import database package and use type assertion:
// db := c.DB().(*database.DB)
//
// Or the type-safe helper:
// db, ok := database.GetDB(c)
//
// Actual implementation is provided by database.Middleware().
// The key (0) matches dbKey from plugins/database package.
type dbContextKey int
return c.Request.Context().Value(dbContextKey(0))
}
Loading
Loading