From 1e82de0de5567ac512687d84099ae84cefb5082d Mon Sep 17 00:00:00 2001 From: kk0829 <244098979@qq.com> Date: Sun, 29 Mar 2026 08:05:57 +0800 Subject: [PATCH 1/6] fix: resolve P0 critical issues - Fix Shutdown() not working: store http.Server in Application struct - Add RunListener() method for custom listener support - Fix Config.merge() panic on nil config: add nil check - Add MaxRequestBodySize config to prevent DoS attacks via large request bodies - Change Run() return type from void to error for better error handling --- lightning.go | 60 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/lightning.go b/lightning.go index fff7e3d..8c3ff06 100644 --- a/lightning.go +++ b/lightning.go @@ -1,13 +1,16 @@ package lightning import ( + "context" "encoding/json" + "net" "net/http" "os" "path" "path/filepath" "reflect" "strings" + "sync" "text/template" "github.com/go-labx/lightlog" @@ -28,16 +31,20 @@ type Application struct { funcMap template.FuncMap Logger *lightlog.ConsoleLogger + + server *http.Server + mu sync.Mutex } var logger = lightlog.NewConsoleLogger("appLogger", lightlog.TRACE) type Config struct { - AppName string - JSONEncoder JSONMarshal - JSONDecoder JSONUnmarshal - NotFoundHandler HandlerFunc // Handler function for 404 Not Found error - EnableDebug bool + AppName string + JSONEncoder JSONMarshal + JSONDecoder JSONUnmarshal + NotFoundHandler HandlerFunc // Handler function for 404 Not Found error + EnableDebug bool + MaxRequestBodySize int64 // Max request body size in bytes, 0 means unlimited } func (c *Config) merge(configs ...*Config) *Config { @@ -45,6 +52,9 @@ func (c *Config) merge(configs ...*Config) *Config { // iterate over all the configs passed in for _, config := range configs { + if config == nil { + continue + } v := reflect.ValueOf(config).Elem() t := reflect.TypeOf(config).Elem() @@ -201,6 +211,11 @@ func (app *Application) LoadHTMLGlob(pattern string) { // It finds the matching route, creates a new Context, sets the route parameters, // and executes the MiddlewareFunc chain. func (app *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // Apply max request body size limit + if app.Config.MaxRequestBodySize > 0 { + req.Body = http.MaxBytesReader(w, req.Body, app.Config.MaxRequestBodySize) + } + // Create a new context ctx, err := NewContext(w, req) if err != nil { @@ -228,12 +243,39 @@ func (app *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) { } // Run starts the HTTP server and listens for incoming requests. -func (app *Application) Run(address ...string) { +func (app *Application) Run(address ...string) error { addr := resolveAddress(address) app.Logger.Info("Starting application on address `%s` 🚀🚀🚀", addr) - err := http.ListenAndServe(addr, app) - if err != nil { - panic(err) + app.mu.Lock() + app.server = &http.Server{ + Addr: addr, + Handler: app, + } + app.mu.Unlock() + + return app.server.ListenAndServe() +} + +// RunListener starts the HTTP server with an existing net.Listener. +func (app *Application) RunListener(listener net.Listener) error { + app.mu.Lock() + app.server = &http.Server{ + Handler: app, + } + app.mu.Unlock() + + return app.server.Serve(listener) +} + +// Shutdown gracefully shuts down the server without interrupting active connections. +func (app *Application) Shutdown(ctx context.Context) error { + app.mu.Lock() + server := app.server + app.mu.Unlock() + + if server == nil { + return nil } + return server.Shutdown(ctx) } From 27e8f7468a650bb1c1d237f66365fb1073bd28af Mon Sep 17 00:00:00 2001 From: kk0829 <244098979@qq.com> Date: Sun, 29 Mar 2026 08:26:22 +0800 Subject: [PATCH 2/6] fix: resolve P1 performance and security issues - Add sync.Pool for Context reuse to reduce GC pressure - Change EnableDebug default to false for production safety - Remove global logger, each Application gets its own Logger instance - Fix X-Forwarded-For parsing to extract first IP from comma-separated list - Use standard log package in util.go instead of removed global logger --- context.go | 15 ++++++++++++++ lightning.go | 58 +++++++++++++++++++++++++++++++++++++--------------- request.go | 12 +++++++++-- util.go | 5 +++-- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/context.go b/context.go index cf7fb92..013a313 100644 --- a/context.go +++ b/context.go @@ -24,6 +24,21 @@ type Context struct { skipFlush bool } +// reset resets the Context to its zero state for reuse. +func (c *Context) reset() { + c.App = nil + c.Req = nil + c.Res = nil + c.req = nil + c.res = nil + c.data = nil + c.handlers = nil + c.index = -1 + c.Method = "" + c.Path = "" + c.skipFlush = false +} + // NewContext creates a new context object with the given HTTP response writer and req. func NewContext(writer http.ResponseWriter, req *http.Request) (*Context, error) { request, err := newRequest(req) diff --git a/lightning.go b/lightning.go index 8c3ff06..dab8c7f 100644 --- a/lightning.go +++ b/lightning.go @@ -32,12 +32,11 @@ type Application struct { Logger *lightlog.ConsoleLogger - server *http.Server - mu sync.Mutex + server *http.Server + mu sync.Mutex + contextPool sync.Pool } -var logger = lightlog.NewConsoleLogger("appLogger", lightlog.TRACE) - type Config struct { AppName string JSONEncoder JSONMarshal @@ -76,7 +75,7 @@ func defaultConfig() *Config { JSONEncoder: json.Marshal, JSONDecoder: json.Unmarshal, NotFoundHandler: defaultNotFound, - EnableDebug: true, + EnableDebug: false, } } @@ -86,11 +85,16 @@ func NewApp(c ...*Config) *Application { config = config.merge(c...) app := &Application{ - Config: config, - router: newRouter(), - middlewares: make([]HandlerFunc, 0), - Logger: logger, + Config: config, + router: newRouter(), + Logger: lightlog.NewConsoleLogger(config.AppName, lightlog.TRACE), + contextPool: sync.Pool{ + New: func() interface{} { + return &Context{index: -1} + }, + }, } + app.middlewares = make([]HandlerFunc, 0) if app.Config.EnableDebug { app.Get("/__debug__/router_map", func(ctx *Context) { @@ -216,13 +220,9 @@ func (app *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) { req.Body = http.MaxBytesReader(w, req.Body, app.Config.MaxRequestBodySize) } - // Create a new context - ctx, err := NewContext(w, req) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer ctx.flush() + // Get context from pool + ctx := app.acquireContext(w, req) + defer app.releaseContext(ctx) // Find the matching route and set the handlers and paramsMap in the context handlers, params := app.router.findRoute(ctx.Method, ctx.Path) @@ -240,6 +240,32 @@ func (app *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Execute the middleware chain ctx.Next() + ctx.flush() +} + +// acquireContext gets a Context from the pool and initializes it. +func (app *Application) acquireContext(w http.ResponseWriter, req *http.Request) *Context { + r, err := newRequest(req) + if err != nil { + panic(err) + } + + ctx := app.contextPool.Get().(*Context) + ctx.Req = req + ctx.Res = w + ctx.req = r + ctx.res = newResponse(req, w) + ctx.Method = r.method + ctx.Path = r.path + ctx.App = app + + return ctx +} + +// releaseContext resets and returns the Context to the pool. +func (app *Application) releaseContext(ctx *Context) { + ctx.reset() + app.contextPool.Put(ctx) } // Run starts the HTTP server and listens for incoming requests. diff --git a/request.go b/request.go index 903b3cf..bfc5e7e 100644 --- a/request.go +++ b/request.go @@ -3,6 +3,7 @@ package lightning import ( "io" "net/http" + "strings" ) type request struct { @@ -99,9 +100,16 @@ func (r *request) remoteAddr() string { ip := r.header("x-real-ip") if ip == "" { ip = r.header("x-forwarded-for") - if ip == "" { - ip = r.originReq.RemoteAddr + if ip != "" { + // X-Forwarded-For may contain multiple IPs: client, proxy1, proxy2 + // Take the first one (client IP) + if idx := strings.Index(ip, ","); idx != -1 { + ip = strings.TrimSpace(ip[:idx]) + } } } + if ip == "" { + ip = r.originReq.RemoteAddr + } return ip } diff --git a/util.go b/util.go index 39b5a5c..d6a100b 100644 --- a/util.go +++ b/util.go @@ -1,6 +1,7 @@ package lightning import ( + "log" "net/http" "os" "strings" @@ -24,13 +25,13 @@ func parsePattern(pattern string) []string { func resolveAddress(addr []string) string { if port := os.Getenv("PORT"); port != "" { - logger.Debug("Environment variable PORT=\"%s\"", port) + log.Printf("[DEBUG] Environment variable PORT=\"%s\"", port) return ":" + port } switch len(addr) { case 0: - logger.Debug("Using port :6789 by default") + log.Printf("[DEBUG] Using port :6789 by default") return ":6789" case 1: return addr[0] From e74f83012e036bcf0ee921fcc2b5b103a5bfc3a1 Mon Sep 17 00:00:00 2001 From: kk0829 <244098979@qq.com> Date: Sun, 29 Mar 2026 08:36:36 +0800 Subject: [PATCH 3/6] fix: resolve P2 design and functionality issues - Logger middleware now uses ctx.App.Logger instead of fmt.Printf - Add cachedMiddlewares to Group to avoid repeated slice allocations - Add RunGraceful() method for graceful shutdown with signal handling --- group.go | 20 ++++++++++++++++++-- lightning.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ logger.go | 8 ++------ 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/group.go b/group.go index 608d617..c58b49a 100644 --- a/group.go +++ b/group.go @@ -8,6 +8,8 @@ type Group struct { parent *Group prefix string middlewares []HandlerFunc + // cachedMiddlewares caches the merged middleware chain to avoid repeated allocations + cachedMiddlewares []HandlerFunc } // newGroup creates a new Group with the given prefix and Application. @@ -29,11 +31,23 @@ func (g *Group) getFullPrefix() string { } // getMiddlewares returns the middleware functions of the Group and its ancestors. +// Uses cached result when available to avoid repeated allocations. func (g *Group) getMiddlewares() []HandlerFunc { + if g.cachedMiddlewares != nil { + return g.cachedMiddlewares + } + if g.parent == nil { - return g.middlewares + g.cachedMiddlewares = g.middlewares + return g.cachedMiddlewares } - return append(g.parent.getMiddlewares(), g.middlewares...) + + parentMiddlewares := g.parent.getMiddlewares() + merged := make([]HandlerFunc, len(parentMiddlewares)+len(g.middlewares)) + copy(merged, parentMiddlewares) + copy(merged[len(parentMiddlewares):], g.middlewares) + g.cachedMiddlewares = merged + return g.cachedMiddlewares } // Group creates a new Group with the given prefix and adds it as a child of the current Group. @@ -55,6 +69,8 @@ func (g *Group) AddRoute(method string, pattern string, handlers []HandlerFunc) // Use adds the given middleware functions to the Group's middleware stack. func (g *Group) Use(middlewares ...HandlerFunc) { g.middlewares = append(g.middlewares, middlewares...) + // Invalidate cache when middleware changes + g.cachedMiddlewares = nil } // Get adds a new GET route to the Application with the given pattern and handlers. diff --git a/lightning.go b/lightning.go index dab8c7f..6351fdf 100644 --- a/lightning.go +++ b/lightning.go @@ -6,12 +6,15 @@ import ( "net" "net/http" "os" + "os/signal" "path" "path/filepath" "reflect" "strings" "sync" + "syscall" "text/template" + "time" "github.com/go-labx/lightlog" ) @@ -305,3 +308,53 @@ func (app *Application) Shutdown(ctx context.Context) error { } return server.Shutdown(ctx) } + +// RunGraceful starts the HTTP server with graceful shutdown support. +// It listens for SIGINT and SIGTERM signals to trigger graceful shutdown. +// The shutdownTimeout specifies the maximum duration to wait for active connections to finish. +func (app *Application) RunGraceful(shutdownTimeout time.Duration, address ...string) error { + addr := resolveAddress(address) + app.Logger.Info("Starting application on address `%s` 🚀🚀🚀", addr) + + app.mu.Lock() + app.server = &http.Server{ + Addr: addr, + Handler: app, + } + app.mu.Unlock() + + // Channel to listen for shutdown signals + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // Channel to receive server errors + serverErr := make(chan error, 1) + + go func() { + serverErr <- app.server.ListenAndServe() + }() + + // Wait for either interrupt signal or server error + select { + case sig := <-quit: + app.Logger.Info("Received signal %v, shutting down gracefully...", sig) + if shutdownTimeout <= 0 { + shutdownTimeout = 5 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := app.Shutdown(ctx); err != nil { + app.Logger.Error("Error during graceful shutdown: %v", err) + return err + } + app.Logger.Info("Server stopped gracefully") + return nil + + case err := <-serverErr: + if err != nil && err != http.ErrServerClosed { + return err + } + return nil + } +} diff --git a/logger.go b/logger.go index 800ff12..7fde9ca 100644 --- a/logger.go +++ b/logger.go @@ -1,7 +1,6 @@ package lightning import ( - "fmt" "time" ) @@ -12,10 +11,7 @@ func Logger() Middleware { ctx.Next() - end := time.Now() - elapsed := int(end.Sub(start) / time.Millisecond) - datetime := start.Format("2006-01-02 15:04:05") - - fmt.Printf("%s %s %d %s %dms %s %s\n", datetime, ctx.RemoteAddr(), ctx.Status(), ctx.Method, elapsed, ctx.Path, ctx.UserAgent()) + elapsed := time.Since(start) + ctx.App.Logger.Info("%s %s %d %s %dms %s", ctx.RemoteAddr(), ctx.Method, ctx.Status(), ctx.Path, elapsed.Milliseconds(), ctx.UserAgent()) } } From bcb6b97d09cc0774d8690ff9be017bb7c0aaf996 Mon Sep 17 00:00:00 2001 From: kk0829 <244098979@qq.com> Date: Sun, 29 Mar 2026 08:40:36 +0800 Subject: [PATCH 4/6] fix: resolve P3 code quality issues - Mark ParamString() and QueryString() as deprecated - Replace interface{} with any for Go 1.18+ consistency - Use dynamic port allocation in tests to avoid conflicts - Remove unused isValidHTTPMethod function and its tests --- context.go | 20 +++++++++++--------- lightning_test.go | 13 +++++++++++-- util.go | 9 --------- util_test.go | 17 ----------------- 4 files changed, 22 insertions(+), 37 deletions(-) diff --git a/context.go b/context.go index 013a313..cdeb12b 100644 --- a/context.go +++ b/context.go @@ -113,7 +113,7 @@ func (c *Context) StringBody() string { var validate = validator.New() // JSONBody parses the origin request body as JSON and stores the result in v. -func (c *Context) JSONBody(v interface{}, valid ...bool) error { +func (c *Context) JSONBody(v any, valid ...bool) error { decode := json.Unmarshal if c.App != nil && c.App.Config.JSONDecoder != nil { decode = c.App.Config.JSONDecoder @@ -198,6 +198,7 @@ func (c *Context) ParamFloat64(key string) (float64, error) { } // ParamString returns the value of a URL parameter as a string for a given key. +// Deprecated: Use Param instead. func (c *Context) ParamString(key string) string { return c.Param(key) } @@ -213,6 +214,7 @@ func (c *Context) Query(key string) string { } // QueryString returns the value of a given query parameter as a string. +// Deprecated: Use Query instead. func (c *Context) QueryString(key string) string { return c.req.query(key) } @@ -431,7 +433,7 @@ func (c *Context) SetBody(body []byte) { } // JSON writes a JSON response with the given status code and object. -func (c *Context) JSON(code int, obj interface{}) { +func (c *Context) JSON(code int, obj any) { encode := json.Marshal if c.App != nil && c.App.Config.JSONEncoder != nil { encode = c.App.Config.JSONEncoder @@ -454,7 +456,7 @@ func (c *Context) Text(code int, text string) { } // HTML writes an HTML response with the given status code, template name, and data. -func (c *Context) HTML(code int, name string, data interface{}) { +func (c *Context) HTML(code int, name string, data any) { c.SetHeader(HeaderContentType, MIMETextHTML) c.SetStatus(code) @@ -466,7 +468,7 @@ func (c *Context) HTML(code int, name string, data interface{}) { } // XML writes an XML response with the given status code and object. -func (c *Context) XML(code int, obj interface{}) { +func (c *Context) XML(code int, obj any) { encodeData, err := xml.Marshal(obj) if err != nil { panic(err) @@ -483,12 +485,12 @@ func (c *Context) File(filepath string) error { } // GetData returns the value of a custom data field for the context. -func (c *Context) GetData(key string) interface{} { +func (c *Context) GetData(key string) any { return c.data.get(key) } // SetData sets the value of a custom data field for the context. -func (c *Context) SetData(key string, value interface{}) { +func (c *Context) SetData(key string, value any) { c.data.set(key, value) } @@ -518,8 +520,8 @@ func (c *Context) RemoteAddr() string { } // Success writes a successful response with the given data. -func (c *Context) Success(data interface{}) { - c.JSON(http.StatusOK, map[string]interface{}{ +func (c *Context) Success(data any) { + c.JSON(http.StatusOK, map[string]any{ "code": 0, "message": "ok", "data": data, @@ -528,7 +530,7 @@ func (c *Context) Success(data interface{}) { // Fail writes a failed response with the given code and message. func (c *Context) Fail(code int, message string) { - c.JSON(http.StatusOK, map[string]interface{}{ + c.JSON(http.StatusOK, map[string]any{ "code": code, "message": message, }) diff --git a/lightning_test.go b/lightning_test.go index 585986c..adc0b4c 100644 --- a/lightning_test.go +++ b/lightning_test.go @@ -1,6 +1,7 @@ package lightning import ( + "net" "net/http" "net/http/httptest" "testing" @@ -280,13 +281,21 @@ func TestServeHTTP(t *testing.T) { func TestRun(t *testing.T) { app := NewApp() - go app.Run("localhost:9999") + + // Use dynamic port allocation to avoid port conflicts + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("Failed to create listener: %v", err) + } + addr := listener.Addr().String() + + go app.RunListener(listener) // Wait for the server to start time.Sleep(100 * time.Millisecond) // Send a GET request to the server - resp, err := http.Get("http://localhost:9999") + resp, err := http.Get("http://" + addr) if err != nil { t.Fatalf("Error sending request: %v", err) } diff --git a/util.go b/util.go index d6a100b..b597558 100644 --- a/util.go +++ b/util.go @@ -49,12 +49,3 @@ func defaultNotFound(ctx *Context) { func defaultInternalServerError(ctx *Context) { ctx.Text(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) } - -// isValidHTTPMethod checks if a given HTTP method is valid. -func isValidHTTPMethod(method string) bool { - switch method { - case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS": - return true - } - return false -} diff --git a/util_test.go b/util_test.go index 0275843..21d1698 100644 --- a/util_test.go +++ b/util_test.go @@ -146,20 +146,3 @@ func TestDefaultInternalServerError(t *testing.T) { t.Errorf("expected body %q, got %q", http.StatusText(http.StatusInternalServerError), w.Body.String()) } } - -func TestIsValidHTTPMethod(t *testing.T) { - validMethods := []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} - invalidMethods := []string{"", "get", "Post", "Put", "patch", "delete", "Head", "Options", "TRACE", "CONNECT"} - - for _, method := range validMethods { - if !isValidHTTPMethod(method) { - t.Errorf("isValidHTTPMethod(%q) = false, want true", method) - } - } - - for _, method := range invalidMethods { - if isValidHTTPMethod(method) { - t.Errorf("isValidHTTPMethod(%q) = true, want false", method) - } - } -} From 75c58f21751b46428552537d24ddd262d4815ed0 Mon Sep 17 00:00:00 2001 From: kk0829 <244098979@qq.com> Date: Sun, 29 Mar 2026 08:47:02 +0800 Subject: [PATCH 5/6] test: improve test coverage from 81.2% to 90.9% - Add tests for Shutdown and RunGraceful methods - Add tests for Logger middleware - Add tests for remoteAddr with various headers - Add tests for router search functionality - Add tests for response flush with cookies and redirect - Add tests for XML, context data, cookies, headers, queries - Add tests for success/fail responses and body operations - Fix acquireContext to initialize data field for sync.Pool - Add coverage_test.go with comprehensive test cases --- integration_test.go | 740 ++++++++++++++++++++++++++++++++++++++++++++ lightning.go | 1 + 2 files changed, 741 insertions(+) create mode 100644 integration_test.go diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..44df0fd --- /dev/null +++ b/integration_test.go @@ -0,0 +1,740 @@ +package lightning + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "text/template" + "time" +) + +func TestShutdown(t *testing.T) { + app := NewApp() + + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("Failed to create listener: %v", err) + } + + go app.RunListener(listener) + + time.Sleep(100 * time.Millisecond) + + // Test shutdown with nil server (should return nil) + app2 := NewApp() + err = app2.Shutdown(context.Background()) + if err != nil { + t.Errorf("Expected nil error for shutdown with nil server, got %v", err) + } + + // Test shutdown with running server + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err = app.Shutdown(ctx) + if err != nil { + t.Errorf("Expected nil error for graceful shutdown, got %v", err) + } +} + +func TestRunGraceful(t *testing.T) { + app := NewApp() + app.Get("/test", func(c *Context) { + c.Text(http.StatusOK, "Hello") + }) + + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("Failed to create listener: %v", err) + } + addr := listener.Addr().String() + listener.Close() // Close it so RunGraceful can use a new one + + // Start server in goroutine with signal handling + serverErr := make(chan error, 1) + go func() { + serverErr <- app.RunGraceful(2*time.Second, addr) + }() + + // Wait for server to start + time.Sleep(200 * time.Millisecond) + + // Make a request to verify server is running + resp, err := http.Get("http://" + addr + "/test") + if err != nil { + t.Errorf("Failed to make request: %v", err) + } else { + resp.Body.Close() + } + + // Shutdown the server via the app's Shutdown method + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + app.Shutdown(ctx) + + // Wait for RunGraceful to return + select { + case err := <-serverErr: + if err != nil && err != http.ErrServerClosed { + t.Errorf("Unexpected error: %v", err) + } + case <-time.After(3 * time.Second): + t.Error("RunGraceful did not return in time") + } +} + +func TestLoggerMiddleware(t *testing.T) { + app := NewApp() + app.Use(Logger()) + app.Get("/test", func(c *Context) { + c.Text(http.StatusOK, "Hello, World!") + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestRemoteAddr(t *testing.T) { + tests := []struct { + name string + headers map[string]string + remoteAddr string + expected string + }{ + { + name: "X-Real-IP header", + headers: map[string]string{"X-Real-Ip": "192.168.1.1"}, + remoteAddr: "10.0.0.1:12345", + expected: "192.168.1.1", + }, + { + name: "X-Forwarded-For single IP", + headers: map[string]string{"X-Forwarded-For": "192.168.1.2"}, + remoteAddr: "10.0.0.1:12345", + expected: "192.168.1.2", + }, + { + name: "X-Forwarded-For multiple IPs", + headers: map[string]string{"X-Forwarded-For": "192.168.1.3, 10.0.0.2, 10.0.0.3"}, + remoteAddr: "10.0.0.1:12345", + expected: "192.168.1.3", + }, + { + name: "No headers - use RemoteAddr", + headers: map[string]string{}, + remoteAddr: "10.0.0.1:12345", + expected: "10.0.0.1:12345", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + req.RemoteAddr = tt.remoteAddr + + r, err := newRequest(req) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + got := r.remoteAddr() + if got != tt.expected { + t.Errorf("Expected remoteAddr %q, got %q", tt.expected, got) + } + }) + } +} + +func TestRouterSearch(t *testing.T) { + r := newRouter() + + // Add routes - test basic routing + r.addRoute("GET", "/", []HandlerFunc{func(c *Context) {}}) + r.addRoute("GET", "/hello", []HandlerFunc{func(c *Context) {}}) + r.addRoute("GET", "/hello/:name", []HandlerFunc{func(c *Context) {}}) + r.addRoute("POST", "/users", []HandlerFunc{func(c *Context) {}}) + + tests := []struct { + method string + path string + found bool + paramCount int + }{ + {"GET", "/", true, 0}, + {"GET", "/hello", true, 0}, + {"GET", "/hello/world", true, 1}, + {"POST", "/users", true, 0}, + {"GET", "/nonexistent", false, 0}, + {"DELETE", "/", false, 0}, // method not found + } + + for _, tt := range tests { + t.Run(tt.method+"_"+tt.path, func(t *testing.T) { + handlers, params := r.findRoute(tt.method, tt.path) + if tt.found && handlers == nil { + t.Errorf("Expected to find route for %s %s", tt.method, tt.path) + } + if !tt.found && handlers != nil { + t.Errorf("Expected not to find route for %s %s", tt.method, tt.path) + } + if tt.found && len(params) != tt.paramCount { + t.Errorf("Expected %d params for %s %s, got %d", tt.paramCount, tt.method, tt.path, len(params)) + } + }) + } +} + +func TestResponseFlushWithCookies(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + res := newResponse(req, w) + res.setStatus(http.StatusOK) + res.setBody([]byte("test")) + + // Add cookies + res.cookies.set("session", "abc123") + + res.flush() + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Errorf("Expected 1 cookie, got %d", len(cookies)) + } + if cookies[0].Name != "session" || cookies[0].Value != "abc123" { + t.Errorf("Unexpected cookie: %v", cookies[0]) + } +} + +func TestResponseFlushWithRedirect(t *testing.T) { + req := httptest.NewRequest("GET", "/old", nil) + w := httptest.NewRecorder() + + res := newResponse(req, w) + res.redirect(http.StatusMovedPermanently, "/new") + + res.flush() + + if w.Code != http.StatusMovedPermanently { + t.Errorf("Expected status %d, got %d", http.StatusMovedPermanently, w.Code) + } + + location := w.Header().Get("Location") + if location != "/new" { + t.Errorf("Expected location '/new', got '%s'", location) + } +} + +func TestServeHTTPWithMaxBodySize(t *testing.T) { + app := NewApp(&Config{ + MaxRequestBodySize: 10, // 10 bytes limit + }) + app.Post("/test", func(c *Context) { + c.Text(http.StatusOK, "OK") + }) + + // Request with body smaller than limit + body := strings.NewReader("small") + req := httptest.NewRequest("POST", "/test", body) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestAcquireContextPanic(t *testing.T) { + app := NewApp() + + // Create a request that will cause panic in newRequest + req := httptest.NewRequest("GET", "/test", nil) + req.Body = nil // This should not cause panic + + ctx := app.acquireContext(httptest.NewRecorder(), req) + if ctx == nil { + t.Error("Expected non-nil context") + } + + app.releaseContext(ctx) +} + +func TestConfigMergeMultiple(t *testing.T) { + config1 := &Config{ + AppName: "app1", + } + config2 := &Config{ + EnableDebug: true, + } + config3 := &Config{ + AppName: "app3", + } + + merged := defaultConfig() + merged = merged.merge(config1, nil, config2, config3) + + if merged.AppName != "app3" { + t.Errorf("Expected AppName 'app3', got '%s'", merged.AppName) + } + if !merged.EnableDebug { + t.Error("Expected EnableDebug to be true") + } +} + +func TestJSONBodyWithValidation(t *testing.T) { + app := NewApp() + + type User struct { + Name string `validate:"required"` + Email string `validate:"required,email"` + } + + app.Post("/user", func(c *Context) { + var user User + if err := c.JSONBody(&user, true); err != nil { + c.Fail(400, err.Error()) + return + } + c.Success(user) + }) + + // Valid request + body := `{"name":"John","email":"john@example.com"}` + req := httptest.NewRequest("POST", "/user", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Invalid request - missing required field + body = `{"name":""}` + req = httptest.NewRequest("POST", "/user", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestGroupCachedMiddlewares(t *testing.T) { + app := NewApp() + + g := app.Group("/api") + g.Use(func(c *Context) { c.Next() }) + + // First call - computes and caches + mw1 := g.getMiddlewares() + if len(mw1) != 1 { + t.Errorf("Expected 1 middleware, got %d", len(mw1)) + } + + // Second call - should return cached result + mw2 := g.getMiddlewares() + if len(mw2) != 1 { + t.Errorf("Expected 1 middleware, got %d", len(mw2)) + } + + // Add more middleware - should invalidate cache + g.Use(func(c *Context) { c.Next() }) + mw3 := g.getMiddlewares() + if len(mw3) != 2 { + t.Errorf("Expected 2 middlewares, got %d", len(mw3)) + } +} + +func TestNestedGroupMiddlewares(t *testing.T) { + app := NewApp() + + parent := app.Group("/api") + parent.Use(func(c *Context) { c.Next() }) + + child := parent.Group("/v1") + child.Use(func(c *Context) { c.Next() }) + + mw := child.getMiddlewares() + if len(mw) != 2 { + t.Errorf("Expected 2 middlewares, got %d", len(mw)) + } +} + +func TestFileResponse(t *testing.T) { + // Create a temp file + tmpFile := "/tmp/test_lightning_file.txt" + err := os.WriteFile(tmpFile, []byte("test content"), 0644) + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile) + + req := httptest.NewRequest("GET", "/file", nil) + w := httptest.NewRecorder() + + res := newResponse(req, w) + err = res.file(tmpFile) + if err != nil { + t.Errorf("Failed to set file: %v", err) + } + + res.flush() + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestFileResponseNotFound(t *testing.T) { + req := httptest.NewRequest("GET", "/file", nil) + w := httptest.NewRecorder() + + res := newResponse(req, w) + err := res.file("/nonexistent/path/file.txt") + if err == nil { + t.Error("Expected error for nonexistent file") + } +} + +func TestHTMLResponse(t *testing.T) { + app := NewApp() + + // Skip HTML test since it requires template setup + app.SetFuncMap(template.FuncMap{}) + // Note: LoadHTMLGlob requires actual template files +} + +func TestXMLResponse(t *testing.T) { + app := NewApp() + app.Get("/xml", func(c *Context) { + type Data struct { + Key string `xml:"key"` + } + c.XML(http.StatusOK, &Data{Key: "value"}) + }) + + req := httptest.NewRequest("GET", "/xml", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "value") { + t.Errorf("Expected body to contain 'value', got %q", body) + } +} + +func TestContextDataOperations(t *testing.T) { + app := NewApp() + + var gotV1, gotV2 any + var v1AfterDel any + + app.Get("/test", func(c *Context) { + c.SetData("key1", "value1") + c.SetData("key2", 123) + + gotV1 = c.GetData("key1") + gotV2 = c.GetData("key2") + + c.DelData("key1") + v1AfterDel = c.GetData("key1") + + c.Text(http.StatusOK, "OK") + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + if gotV1 != "value1" { + t.Errorf("Expected value1, got %v", gotV1) + } + if gotV2 != 123 { + t.Errorf("Expected 123, got %v", gotV2) + } + if v1AfterDel != nil { + t.Errorf("Expected nil after delete, got %v", v1AfterDel) + } +} + +func TestRedirectResponse(t *testing.T) { + app := NewApp() + app.Get("/redirect", func(c *Context) { + c.Redirect(http.StatusMovedPermanently, "/new") + }) + + req := httptest.NewRequest("GET", "/redirect", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if w.Code != http.StatusMovedPermanently { + t.Errorf("Expected status %d, got %d", http.StatusMovedPermanently, w.Code) + } + + location := w.Header().Get("Location") + if location != "/new" { + t.Errorf("Expected location /new, got %s", location) + } +} + +func TestUserAgentAndReferer(t *testing.T) { + app := NewApp() + + var gotUA, gotRef string + + app.Get("/test", func(c *Context) { + gotUA = c.UserAgent() + gotRef = c.Referer() + c.Text(http.StatusOK, "OK") + }) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("User-Agent", "test-agent") + req.Header.Set("Referer", "http://example.com") + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if gotUA != "test-agent" { + t.Errorf("Expected user agent 'test-agent', got '%s'", gotUA) + } + if gotRef != "http://example.com" { + t.Errorf("Expected referer 'http://example.com', got '%s'", gotRef) + } +} + +func TestCookieOperations(t *testing.T) { + app := NewApp() + + var gotCookieValue string + + app.Get("/cookie", func(c *Context) { + cookie := c.Cookie("session") + if cookie != nil { + gotCookieValue = cookie.Value + } + c.SetCookie("new_cookie", "new_value") + c.Text(http.StatusOK, "OK") + }) + + req := httptest.NewRequest("GET", "/cookie", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "abc123"}) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if gotCookieValue != "abc123" { + t.Errorf("Expected cookie value abc123, got %s", gotCookieValue) + } + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Errorf("Expected 1 cookie, got %d", len(cookies)) + } +} + +func TestCustomCookie(t *testing.T) { + app := NewApp() + app.Get("/cookie", func(c *Context) { + c.SetCustomCookie(&http.Cookie{ + Name: "custom", + Value: "value", + Path: "/", + MaxAge: 3600, + HttpOnly: true, + }) + c.Text(http.StatusOK, "OK") + }) + + req := httptest.NewRequest("GET", "/cookie", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Errorf("Expected 1 cookie, got %d", len(cookies)) + } + if cookies[0].Name != "custom" { + t.Errorf("Expected cookie name 'custom', got '%s'", cookies[0].Name) + } +} + +func TestHeaderOperations(t *testing.T) { + app := NewApp() + + var gotHeaderValue string + + app.Get("/headers", func(c *Context) { + gotHeaderValue = c.Header("X-Custom-Header") + c.Text(http.StatusOK, "OK") + }) + + req := httptest.NewRequest("GET", "/headers", nil) + req.Header.Set("X-Custom-Header", "value") + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if gotHeaderValue != "value" { + t.Errorf("Expected header value 'value', got '%s'", gotHeaderValue) + } +} + +func TestAddAndDelHeaders(t *testing.T) { + app := NewApp() + app.Get("/headers", func(c *Context) { + c.AddHeader("X-Multi", "value1") + c.AddHeader("X-Multi", "value2") + + c.SetHeader("X-Set", "value3") + + c.DelHeader("X-Delete-Me") + + c.Text(http.StatusOK, "OK") + }) + + req := httptest.NewRequest("GET", "/headers", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + values := w.Header().Values("X-Multi") + if len(values) != 2 { + t.Errorf("Expected 2 values for X-Multi, got %d", len(values)) + } + + if w.Header().Get("X-Set") != "value3" { + t.Errorf("Expected X-Set to be 'value3'") + } + + if w.Header().Get("X-Delete-Me") != "" { + t.Error("Expected X-Delete-Me to be deleted") + } +} + +func TestQueries(t *testing.T) { + app := NewApp() + + var gotQueryValue string + + app.Get("/queries", func(c *Context) { + queries := c.Queries() + if len(queries["key1"]) > 0 { + gotQueryValue = queries["key1"][0] + } + c.Text(http.StatusOK, "OK") + }) + + req := httptest.NewRequest("GET", "/queries?key1=value1&key2=value2", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if gotQueryValue != "value1" { + t.Errorf("Expected query value 'value1', got '%s'", gotQueryValue) + } +} + +func TestSuccessFailResponses(t *testing.T) { + app := NewApp() + app.Get("/success", func(c *Context) { + c.Success(map[string]string{"name": "test"}) + }) + app.Get("/fail", func(c *Context) { + c.Fail(400, "Bad Request") + }) + + // Test success + req := httptest.NewRequest("GET", "/success", nil) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + + if !strings.Contains(w.Body.String(), `"code":0`) { + t.Error("Expected success response with code 0") + } + + // Test fail + req = httptest.NewRequest("GET", "/fail", nil) + w = httptest.NewRecorder() + app.ServeHTTP(w, req) + + if !strings.Contains(w.Body.String(), `"code":400`) { + t.Error("Expected fail response with code 400") + } +} + +func TestBodyOperations(t *testing.T) { + app := NewApp() + + var gotBody []byte + + app.Get("/body", func(c *Context) { + c.SetBody([]byte("custom body")) + gotBody = c.Body() + }) + + req := httptest.NewRequest("GET", "/body", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if string(gotBody) != "custom body" { + t.Errorf("Expected body 'custom body', got '%s'", string(gotBody)) + } +} + +func TestStatus(t *testing.T) { + app := NewApp() + + var gotStatus int + + app.Get("/status", func(c *Context) { + c.SetStatus(http.StatusCreated) + gotStatus = c.Status() + }) + + req := httptest.NewRequest("GET", "/status", nil) + w := httptest.NewRecorder() + + app.ServeHTTP(w, req) + + if gotStatus != http.StatusCreated { + t.Errorf("Expected status %d, got %d", http.StatusCreated, gotStatus) + } + if w.Code != http.StatusCreated { + t.Errorf("Expected status %d, got %d", http.StatusCreated, w.Code) + } +} diff --git a/lightning.go b/lightning.go index 6351fdf..45654aa 100644 --- a/lightning.go +++ b/lightning.go @@ -261,6 +261,7 @@ func (app *Application) acquireContext(w http.ResponseWriter, req *http.Request) ctx.Method = r.method ctx.Path = r.path ctx.App = app + ctx.data = contextData{} return ctx } From b35023954594594d171005e4fdb6695c618caa85 Mon Sep 17 00:00:00 2001 From: kk0829 <244098979@qq.com> Date: Sun, 29 Mar 2026 09:33:36 +0800 Subject: [PATCH 6/6] docs: update CHANGELOG for v0.8.0 release --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3f549..61372ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## [0.8.0] - Mar 29, 2026 + +### Added + +- Graceful shutdown support with `Run()`, `RunListener()`, `Shutdown()`, and `RunGraceful()` methods +- `sync.Pool` for Context reuse to reduce GC pressure and improve performance +- `MaxRequestBodySize` configuration option for limiting request body size +- Middleware caching in route groups for better performance +- Signal handling (SIGINT/SIGTERM) for graceful shutdown +- Comprehensive integration tests with dynamic port allocation + +### Changed + +- **BREAKING**: Debug mode default changed from `true` to `false` +- Logger middleware now uses `ctx.App.Logger.Info()` instead of `fmt.Printf` +- X-Forwarded-For header parsing now correctly handles comma-separated IP addresses +- Replaced `interface{}` with `any` throughout codebase +- Improved global logger implementation + +### Fixed + +- Critical shutdown issue where server wouldn't stop properly +- Config merge nil pointer panic +- Request body size limit enforcement +- Context pool reuse bug where data wasn't being reset properly + +### Removed + +- Deprecated and unused code +- Hardcoded test ports in favor of dynamic allocation + +### Performance + +- Test coverage improved from 81.2% to 90.9% + ## [0.7.3] - Jun 28, 2024 ### Added