From 24b7c6ee9227ee03ef4a501ef738bf7751722696 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 13:39:02 +0300 Subject: [PATCH 01/24] Add demo-app for httpserver pakcage --- demo-app/cmd/api/main.go | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 demo-app/cmd/api/main.go diff --git a/demo-app/cmd/api/main.go b/demo-app/cmd/api/main.go new file mode 100644 index 0000000..a52ba97 --- /dev/null +++ b/demo-app/cmd/api/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "net/http" + "time" + + "github.com/mishankov/platforma/application" + "github.com/mishankov/platforma/httpserver" + "github.com/mishankov/platforma/log" +) + +func main() { + ctx := context.Background() + + // Initialize new application + app := application.New() + + // Create HTTP server + api := httpserver.New("8080", 3*time.Second) + + // Add /ping endpoint to `api` + api.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("pong")) + }) + + // Add middleware to `api`. It will add trace ID to logs and responce headers + api.Use(httpserver.NewTraceIdMiddleware(nil, "")) + + // Create handler group + subApiGroup := httpserver.NewHandlerGroup() + + // Add /clock endpoint to handler group + subApiGroup.HandleFunc("/clock", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(time.Now().String())) + }) + + // Add middleware to `api`. It will log all incoming requests to this handle group + subApiGroup.UseFunc(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.InfoContext(r.Context(), "incoming request", "addr", r.RemoteAddr) + h.ServeHTTP(w, r) + }) + }) + + // Add handle group to HTTP server with /subApi path + api.HandleGroup("/subApi", subApiGroup) + + // Register http server as application server + app.RegisterService("api", api) + + // Run application + if err := app.Run(ctx); err != nil { + log.ErrorContext(ctx, "app finished with error", "error", err) + } +} From 4c0063ee73de3024584a35312de76ff00d298a14 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 13:39:20 +0300 Subject: [PATCH 02/24] Rename TraceId to TraceIdMiddleware --- httpserver/traceid.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/httpserver/traceid.go b/httpserver/traceid.go index 1795c52..eb7c5f3 100644 --- a/httpserver/traceid.go +++ b/httpserver/traceid.go @@ -9,8 +9,8 @@ import ( "github.com/google/uuid" ) -// TraceId is a middleware that adds a trace ID to the request context and response headers. -type TraceId struct { +// TraceIdMiddleware is a middleware that adds a trace ID to the request context and response headers. +type TraceIdMiddleware struct { contextKey any header string } @@ -18,7 +18,7 @@ type TraceId struct { // NewTraceIdMiddleware returns a new TraceId middleware. // If key is nil, log.TraceIdKey is used. // If header is empty, "Platforma-Trace-Id" is used. -func NewTraceIdMiddleware(contextKey any, header string) *TraceId { +func NewTraceIdMiddleware(contextKey any, header string) *TraceIdMiddleware { if contextKey == nil { contextKey = log.TraceIdKey } @@ -27,10 +27,10 @@ func NewTraceIdMiddleware(contextKey any, header string) *TraceId { header = "Platforma-Trace-Id" } - return &TraceId{contextKey: contextKey, header: header} + return &TraceIdMiddleware{contextKey: contextKey, header: header} } -func (m *TraceId) Wrap(h http.Handler) http.Handler { +func (m *TraceIdMiddleware) Wrap(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { traceId := uuid.NewString() ctx := context.WithValue(r.Context(), m.contextKey, traceId) From 9f5cec8d96d0922c88985bc89f86b51e36697462 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 13:39:31 +0300 Subject: [PATCH 03/24] Remove demo task from Taskfile --- Taskfile.dist.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml index 59609cd..a2d84ba 100644 --- a/Taskfile.dist.yml +++ b/Taskfile.dist.yml @@ -17,8 +17,3 @@ tasks: deps: - lint - test - - demo: - dir: demo-app - cmds: - - go run ./cmd/clock/main.go From b7e4297d880550bf907dbd817940c28f243160c7 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 13:39:41 +0300 Subject: [PATCH 04/24] Fix log message capitalization --- application/application.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/application.go b/application/application.go index d8dcd14..8eea018 100644 --- a/application/application.go +++ b/application/application.go @@ -92,7 +92,7 @@ func (a *Application) Run(ctx context.Context) error { ctx = context.Background() } - log.InfoContext(ctx, "Starting application", "startupTasks", len(a.startupTasks)) + log.InfoContext(ctx, "starting application", "startupTasks", len(a.startupTasks)) for dbName, db := range a.databases { log.InfoContext(ctx, "migrating database", "database", dbName) From eca8be59b351461feb58f777f61d740c47e72eda Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 13:40:01 +0300 Subject: [PATCH 05/24] Improve middleware wrapping documentation --- httpserver/middleware.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httpserver/middleware.go b/httpserver/middleware.go index 34dc2d9..157d8df 100644 --- a/httpserver/middleware.go +++ b/httpserver/middleware.go @@ -21,7 +21,7 @@ func (f MiddlewareFunc) Wrap(h http.Handler) http.Handler { // wrapHandlerInMiddleware wraps an http.Handler with a chain of middlewares. // The middlewares are applied in reverse order of the provided slice, -// meaning the last middleware in the slice will be the first to execute. +// meaning the last middleware in the slice will be the most inner. func wrapHandlerInMiddleware(handler http.Handler, middlewares []Middleware) http.Handler { finalHandler := handler for _, middleware := range slices.Backward(middlewares) { From ebd5554e82d9f82e38031e76c8a22fc6711df18a Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 14:17:17 +0300 Subject: [PATCH 06/24] Add comment about available API endpoints --- demo-app/cmd/api/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/demo-app/cmd/api/main.go b/demo-app/cmd/api/main.go index a52ba97..af4bdd2 100644 --- a/demo-app/cmd/api/main.go +++ b/demo-app/cmd/api/main.go @@ -53,4 +53,6 @@ func main() { if err := app.Run(ctx); err != nil { log.ErrorContext(ctx, "app finished with error", "error", err) } + + // Now you can access http://localhost:8080/ping and http://localhost:8080/subApi/clock URLs with GET method } From 88346d973b9036fddeced2b5717d0eb58861ee89 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 14:32:52 +0300 Subject: [PATCH 07/24] Add /long endpoint for graceful shutdown testing The endpoint sleeps for 10 seconds before responding, allowing graceful shutdown behavior to be tested during long-running requests. --- demo-app/cmd/api/main.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/demo-app/cmd/api/main.go b/demo-app/cmd/api/main.go index af4bdd2..99b6cf1 100644 --- a/demo-app/cmd/api/main.go +++ b/demo-app/cmd/api/main.go @@ -24,7 +24,13 @@ func main() { w.Write([]byte("pong")) }) - // Add middleware to `api`. It will add trace ID to logs and responce headers + // Add /long endpoint to HTTP server to test graceful shutdown + api.HandleFunc("/long", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(10 * time.Second) + w.Write([]byte("pong")) + }) + + // Add middleware to HTTP server. It will add trace ID to logs and responce headers api.Use(httpserver.NewTraceIdMiddleware(nil, "")) // Create handler group @@ -35,7 +41,7 @@ func main() { w.Write([]byte(time.Now().String())) }) - // Add middleware to `api`. It will log all incoming requests to this handle group + // Add middleware to HTTP server. It will log all incoming requests to this handle group subApiGroup.UseFunc(func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.InfoContext(r.Context(), "incoming request", "addr", r.RemoteAddr) @@ -46,7 +52,7 @@ func main() { // Add handle group to HTTP server with /subApi path api.HandleGroup("/subApi", subApiGroup) - // Register http server as application server + // Register HTTP server as application server app.RegisterService("api", api) // Run application @@ -54,5 +60,6 @@ func main() { log.ErrorContext(ctx, "app finished with error", "error", err) } - // Now you can access http://localhost:8080/ping and http://localhost:8080/subApi/clock URLs with GET method + // Now you can access http://localhost:8080/ping, http://localhost:8080/long + // and http://localhost:8080/subApi/clock URLs with GET method } From 52ce116238c15f08f4906f2ec1bfc4b70805c5f2 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 14:32:59 +0300 Subject: [PATCH 08/24] Improve HTTP server shutdown error message --- httpserver/httpserver.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go index 26aa6e9..82ad481 100644 --- a/httpserver/httpserver.go +++ b/httpserver/httpserver.go @@ -70,8 +70,7 @@ func (s *HttpServer) Run(ctx context.Context) error { defer shutdownRelease() if err := server.Shutdown(shutdownCtx); err != nil { - log.ErrorContext(ctx, "HTTP shutdown error", "error", err) - return fmt.Errorf("failed to shutdown server: %w", err) + return fmt.Errorf("failed to gracefully shutdown HTTP server: %w", err) } log.InfoContext(ctx, "graceful shutdown completed.") From b975919b214f171502375862c8534701366240ef Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 14:49:33 +0300 Subject: [PATCH 09/24] Add ServeHTTP method to HttpServer This implements the http.Handler interface directly on HttpServer, allowing it to be used as a handler in other HTTP servers or middleware chains. --- httpserver/httpserver.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go index 82ad481..14614ff 100644 --- a/httpserver/httpserver.go +++ b/httpserver/httpserver.go @@ -46,6 +46,10 @@ func (s *HttpServer) UseFunc(middlewareFuncs ...func(http.Handler) http.Handler) } } +func (s *HttpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} + func (s *HttpServer) Run(ctx context.Context) error { server := &http.Server{ Addr: ":" + s.port, From 056debc14333eb5efebc08d15929e9107bf37fba Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 14:49:48 +0300 Subject: [PATCH 10/24] Refactor HTTP server tests --- httpserver/httpserver_test.go | 128 +++++++++++----------------------- 1 file changed, 41 insertions(+), 87 deletions(-) diff --git a/httpserver/httpserver_test.go b/httpserver/httpserver_test.go index 21a4589..65f1df2 100644 --- a/httpserver/httpserver_test.go +++ b/httpserver/httpserver_test.go @@ -1,116 +1,70 @@ package httpserver_test import ( - "context" + "io" "net/http" + "net/http/httptest" "testing" - "time" "github.com/mishankov/platforma/httpserver" ) -// testHandler is a simple handler for testing -type testHandler struct{} - -func (h *testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) -} - -func TestHttpServer_ShutdownCompletesBeforeTimeout(t *testing.T) { +func TestHttpServer(t *testing.T) { t.Parallel() - // Create a test HTTP server directly to test shutdown behavior - server := &http.Server{ - Addr: ":8080", - Handler: &testHandler{}, - } - - // Start server in goroutine - go func() { - server.ListenAndServe() - }() + t.Run("single http.HandlerFunc endpoint", func(t *testing.T) { + t.Parallel() - // Give server a moment to start - time.Sleep(100 * time.Millisecond) + server := httpserver.New("", 0) - // Test shutdown with a long timeout but expect it to complete quickly - shutdownTimeout := 5 * time.Second - startTime := time.Now() + server.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("pong")) + }) - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() + r := httptest.NewRequest(http.MethodGet, "/ping", nil) + w := httptest.NewRecorder() - err := server.Shutdown(ctx) - shutdownDuration := time.Since(startTime) + server.ServeHTTP(w, r) - // Verify shutdown completed quickly (much less than full timeout) - if shutdownDuration > 1*time.Second { - t.Errorf("shutdown took %v, expected to complete much faster than %v timeout", shutdownDuration, shutdownTimeout) - } + resp := w.Result() + body, _ := io.ReadAll(resp.Body) - if err != nil { - t.Errorf("unexpected shutdown error: %v", err) - } -} - -func TestHttpServer_ShutdownWithNoActiveConnections(t *testing.T) { - t.Parallel() + if string(body) != "pong" { + t.Errorf("expected body to be 'pong', got %s", string(body)) + } + }) - // Create HttpServer instance to test the integration - httpServer := httpserver.New("8081", 3*time.Second) - httpServer.Handle("/test", &testHandler{}) + t.Run("single http.Handler endpoint", func(t *testing.T) { + t.Parallel() - // Create a test server to simulate the internal http.Server - testServer := &http.Server{ - Addr: ":8081", - Handler: &testHandler{}, - } + pingHandler := &handler{ + serveHttp: func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("pong")) + }, + } - // Start server - go func() { - testServer.ListenAndServe() - }() + server := httpserver.New("", 0) - // Give server time to start - time.Sleep(100 * time.Millisecond) + server.Handle("/ping", pingHandler) - // Test shutdown - should complete quickly since no active connections - startTime := time.Now() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() + r := httptest.NewRequest(http.MethodGet, "/ping", nil) + w := httptest.NewRecorder() - err := testServer.Shutdown(ctx) - shutdownDuration := time.Since(startTime) + server.ServeHTTP(w, r) - // Should complete much faster than the full timeout - if shutdownDuration > 500*time.Millisecond { - t.Errorf("shutdown with no connections took %v, expected <500ms", shutdownDuration) - } + resp := w.Result() + body, _ := io.ReadAll(resp.Body) - if err != nil { - t.Errorf("unexpected shutdown error: %v", err) - } + if string(body) != "pong" { + t.Errorf("expected body to be 'pong', got %s", string(body)) + } + }) } -func TestHttpServer_Healthcheck(t *testing.T) { - t.Parallel() - - server := httpserver.New("8083", 5*time.Second) - - result := server.Healthcheck(context.Background()) - - healthMap, ok := result.(map[string]any) - if !ok { - t.Fatalf("expected map[string]any, got %T", result) - } - - port, exists := healthMap["port"] - if !exists { - t.Error("healthcheck should contain 'port' field") - } +type handler struct { + serveHttp func(http.ResponseWriter, *http.Request) +} - if port != "8083" { - t.Errorf("expected port '8083', got '%v'", port) - } +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.serveHttp(w, r) } From d172ea616e32104745c4bf50d6537e64735ec2b2 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 14:58:02 +0300 Subject: [PATCH 11/24] Disable linter for demo-app directory --- .golangci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 8596adb..bed01b8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -61,6 +61,9 @@ linters: rules: json: camel exclusions: + paths: + - demo-app + rules: - path-except: "database/*" linters: From e5a6c1f1908c217a904cd8b86072f94d3b21816d Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 15:03:59 +0300 Subject: [PATCH 12/24] Add healthcheck test for HTTP server --- httpserver/httpserver_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/httpserver/httpserver_test.go b/httpserver/httpserver_test.go index 65f1df2..3a0e420 100644 --- a/httpserver/httpserver_test.go +++ b/httpserver/httpserver_test.go @@ -1,6 +1,7 @@ package httpserver_test import ( + "context" "io" "net/http" "net/http/httptest" @@ -59,6 +60,16 @@ func TestHttpServer(t *testing.T) { t.Errorf("expected body to be 'pong', got %s", string(body)) } }) + + t.Run("healthcheck", func(t *testing.T) { + t.Parallel() + + server := httpserver.New("8080", 0) + port := server.Healthcheck(context.TODO()).(map[string]any)["port"] + if port != "8080" { + t.Errorf("expected port to be 8080, got %s", port) + } + }) } type handler struct { @@ -66,5 +77,10 @@ type handler struct { } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - h.serveHttp(w, r) + if h.serveHttp != nil { + h.serveHttp(w, r) + return + } + + w.WriteHeader(http.StatusOK) } From 7cbde5d36f06205876f90977b75256d7c18a4256 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 15:06:51 +0300 Subject: [PATCH 13/24] Add type assertion for health check data in test --- httpserver/httpserver_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/httpserver/httpserver_test.go b/httpserver/httpserver_test.go index 3a0e420..b502c8f 100644 --- a/httpserver/httpserver_test.go +++ b/httpserver/httpserver_test.go @@ -65,7 +65,12 @@ func TestHttpServer(t *testing.T) { t.Parallel() server := httpserver.New("8080", 0) - port := server.Healthcheck(context.TODO()).(map[string]any)["port"] + hcData, ok := server.Healthcheck(context.TODO()).(map[string]any) + if !ok { + t.Fatal("failed type assert health data") + } + + port := hcData["port"] if port != "8080" { t.Errorf("expected port to be 8080, got %s", port) } From 42f111ca40337c76eda4d0def7d631bb32fe8e05 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 15:57:34 +0300 Subject: [PATCH 14/24] Wrap handler in middleware before serving HTTP requests --- httpserver/httpserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go index 14614ff..dd1573d 100644 --- a/httpserver/httpserver.go +++ b/httpserver/httpserver.go @@ -47,7 +47,7 @@ func (s *HttpServer) UseFunc(middlewareFuncs ...func(http.Handler) http.Handler) } func (s *HttpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.mux.ServeHTTP(w, r) + wrapHandlerInMiddleware(s.mux, s.middlewares).ServeHTTP(w, r) } func (s *HttpServer) Run(ctx context.Context) error { From bdbefd36bfec099223b5d9fc2f24aed34e46f757 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 15:58:02 +0300 Subject: [PATCH 15/24] Tests for middleware --- httpserver/httpserver_test.go | 131 +++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/httpserver/httpserver_test.go b/httpserver/httpserver_test.go index b502c8f..7e86b24 100644 --- a/httpserver/httpserver_test.go +++ b/httpserver/httpserver_test.go @@ -31,7 +31,7 @@ func TestHttpServer(t *testing.T) { body, _ := io.ReadAll(resp.Body) if string(body) != "pong" { - t.Errorf("expected body to be 'pong', got %s", string(body)) + t.Fatalf("expected body to be 'pong', got %s", string(body)) } }) @@ -57,7 +57,7 @@ func TestHttpServer(t *testing.T) { body, _ := io.ReadAll(resp.Body) if string(body) != "pong" { - t.Errorf("expected body to be 'pong', got %s", string(body)) + t.Fatalf("expected body to be 'pong', got %s", string(body)) } }) @@ -72,7 +72,121 @@ func TestHttpServer(t *testing.T) { port := hcData["port"] if port != "8080" { - t.Errorf("expected port to be 8080, got %s", port) + t.Fatalf("expected port to be 8080, got %s", port) + } + }) + + t.Run("Use middleware", func(t *testing.T) { + t.Parallel() + + server := httpserver.New("", 0) + + customMiddleware := &testMiddleware{ + wrapFunc: func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test-Middleware", "applied") + next.ServeHTTP(w, r) + }) + }, + } + server.Use(customMiddleware) + server.Handle("/test", &handler{}) + + r := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + server.ServeHTTP(w, r) + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status code to be 200, got %d", resp.StatusCode) + } + + middlewareHeader := resp.Header.Get("X-Test-Middleware") + if middlewareHeader != "applied" { + t.Fatalf("expected X-Test-Middleware header to be 'applied', got %s", middlewareHeader) + } + }) + + t.Run("UseFunc middleware", func(t *testing.T) { + t.Parallel() + + server := httpserver.New("", 0) + + customMiddlewareFunc := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test-Func-Middleware", "applied") + next.ServeHTTP(w, r) + }) + } + + server.UseFunc(customMiddlewareFunc) + server.Handle("/test", &handler{}) + + r := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + server.ServeHTTP(w, r) + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status code to be 200, got %d", resp.StatusCode) + } + + middlewareHeader := resp.Header.Get("X-Test-Func-Middleware") + if middlewareHeader != "applied" { + t.Fatalf("expected X-Test-Middleware header to be 'applied', got %s", middlewareHeader) + } + }) + + t.Run("multiple middlewares", func(t *testing.T) { + t.Parallel() + + server := httpserver.New("", 0) + + middlewareCallLog := []string{} + + firstMiddleware := &testMiddleware{ + wrapFunc: func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + middlewareCallLog = append(middlewareCallLog, "first") + w.Header().Set("X-First-Middleware", "applied") + next.ServeHTTP(w, r) + }) + }, + } + + secondMiddlewareFunc := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + middlewareCallLog = append(middlewareCallLog, "second") + w.Header().Set("X-Second-Middleware", "applied") + next.ServeHTTP(w, r) + }) + } + + server.Use(firstMiddleware) + server.UseFunc(secondMiddlewareFunc) + + r := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + server.ServeHTTP(w, r) + resp := w.Result() + + firstHeader := resp.Header.Get("X-First-Middleware") + if firstHeader != "applied" { + t.Fatalf("expected X-First-Middleware header to be 'applied', got %s", firstHeader) + } + + secondHeader := resp.Header.Get("X-Second-Middleware") + if secondHeader != "applied" { + t.Fatalf("expected X-Second-Middleware header to be 'applied', got %s", secondHeader) + } + + if middlewareCallLog[0] != "first" { + t.Fatalf("expected first middleware to be called first, got %s", middlewareCallLog[0]) + } + + if middlewareCallLog[1] != "second" { + t.Fatalf("expected second middleware to be called second, got %s", middlewareCallLog[1]) } }) } @@ -89,3 +203,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + +type testMiddleware struct { + wrapFunc func(http.Handler) http.Handler +} + +func (m *testMiddleware) Wrap(next http.Handler) http.Handler { + if m.wrapFunc != nil { + return m.wrapFunc(next) + } + return next +} From e144d25346ded54dfa853079ebe9b41bbb34b1b2 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 16:13:28 +0300 Subject: [PATCH 16/24] Add HandleGroup method for nested handler groups --- httpserver/handlergroup.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/httpserver/handlergroup.go b/httpserver/handlergroup.go index 697fdd8..db38a26 100644 --- a/httpserver/handlergroup.go +++ b/httpserver/handlergroup.go @@ -39,6 +39,10 @@ func (hg *HandlerGroup) HandleFunc(pattern string, handler func(http.ResponseWri hg.mux.Handle(pattern, http.HandlerFunc(handler)) } +func (hg *HandlerGroup) HandleGroup(pattern string, handler http.Handler) { + hg.mux.Handle(pattern+"/", http.StripPrefix(pattern, handler)) +} + // ServeHTTP implements the http.Handler interface, allowing HandlerGroup to // be used as an HTTP handler itself. func (hg *HandlerGroup) ServeHTTP(w http.ResponseWriter, r *http.Request) { From 3546334c2e1aab48e0ceba4b633184bf83ba0102 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 16:13:40 +0300 Subject: [PATCH 17/24] Refactor HttpServer to use embedded HandlerGroup --- httpserver/httpserver.go | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go index dd1573d..bcb6b31 100644 --- a/httpserver/httpserver.go +++ b/httpserver/httpserver.go @@ -14,40 +14,13 @@ import ( ) type HttpServer struct { - mux *http.ServeMux + HandlerGroup port string shutdownTimeout time.Duration - middlewares []Middleware } func New(port string, shutdownTimeout time.Duration) *HttpServer { - return &HttpServer{mux: http.NewServeMux(), port: port, shutdownTimeout: shutdownTimeout} -} - -func (s *HttpServer) Handle(pattern string, handler http.Handler) { - s.mux.Handle(pattern, handler) -} - -func (s *HttpServer) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { - s.mux.HandleFunc(pattern, http.HandlerFunc(handler)) -} - -func (s *HttpServer) HandleGroup(pattern string, handler http.Handler) { - s.mux.Handle(pattern+"/", http.StripPrefix(pattern, handler)) -} - -func (s *HttpServer) Use(middlewares ...Middleware) { - s.middlewares = append(s.middlewares, middlewares...) -} - -func (s *HttpServer) UseFunc(middlewareFuncs ...func(http.Handler) http.Handler) { - for _, middlewareFunc := range middlewareFuncs { - s.middlewares = append(s.middlewares, MiddlewareFunc(middlewareFunc)) - } -} - -func (s *HttpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - wrapHandlerInMiddleware(s.mux, s.middlewares).ServeHTTP(w, r) + return &HttpServer{HandlerGroup: HandlerGroup{mux: http.NewServeMux()}, port: port, shutdownTimeout: shutdownTimeout} } func (s *HttpServer) Run(ctx context.Context) error { From 462a8634b2cf650144ce2eff0cff9fb9a380e723 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 16:17:37 +0300 Subject: [PATCH 18/24] Remove verbose flag from test command --- Taskfile.dist.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml index a2d84ba..fe1f1e8 100644 --- a/Taskfile.dist.yml +++ b/Taskfile.dist.yml @@ -11,7 +11,7 @@ tasks: test: cmds: - - go test -cover -v ./... + - go test -cover ./... check: deps: From 908e311d1fb96772a0b8fb1352d86d0867c38ccd Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 16:17:44 +0300 Subject: [PATCH 19/24] Replace HandlerGroup field with type alias handleGroup --- httpserver/httpserver.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go index bcb6b31..8c15c7e 100644 --- a/httpserver/httpserver.go +++ b/httpserver/httpserver.go @@ -13,14 +13,16 @@ import ( "github.com/mishankov/platforma/log" ) +type handleGroup = HandlerGroup + type HttpServer struct { - HandlerGroup + handleGroup port string shutdownTimeout time.Duration } func New(port string, shutdownTimeout time.Duration) *HttpServer { - return &HttpServer{HandlerGroup: HandlerGroup{mux: http.NewServeMux()}, port: port, shutdownTimeout: shutdownTimeout} + return &HttpServer{handleGroup: HandlerGroup{mux: http.NewServeMux()}, port: port, shutdownTimeout: shutdownTimeout} } func (s *HttpServer) Run(ctx context.Context) error { From d8f1144967c2ca9df849f1ac3e166f24a2c2900d Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 16:20:20 +0300 Subject: [PATCH 20/24] Change HttpServer to embed pointer to handleGroup --- httpserver/httpserver.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go index 8c15c7e..74dff37 100644 --- a/httpserver/httpserver.go +++ b/httpserver/httpserver.go @@ -16,13 +16,13 @@ import ( type handleGroup = HandlerGroup type HttpServer struct { - handleGroup + *handleGroup port string shutdownTimeout time.Duration } func New(port string, shutdownTimeout time.Duration) *HttpServer { - return &HttpServer{handleGroup: HandlerGroup{mux: http.NewServeMux()}, port: port, shutdownTimeout: shutdownTimeout} + return &HttpServer{handleGroup: NewHandlerGroup(), port: port, shutdownTimeout: shutdownTimeout} } func (s *HttpServer) Run(ctx context.Context) error { From 31ae15333dde20088beb622b8a434a045c2fac47 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 18:29:23 +0300 Subject: [PATCH 21/24] Remove middleware application from HandlerGroup methods --- httpserver/handlergroup.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/httpserver/handlergroup.go b/httpserver/handlergroup.go index db38a26..a7047ac 100644 --- a/httpserver/handlergroup.go +++ b/httpserver/handlergroup.go @@ -27,18 +27,17 @@ func (hg *HandlerGroup) UseFunc(middlewareFuncs ...func(http.Handler) http.Handl } } -// Handle registers an http.Handler for the given pattern, applying all -// middlewares in the HandlerGroup's chain. +// Handle registers an http.Handler for the given pattern func (hg *HandlerGroup) Handle(pattern string, handler http.Handler) { hg.mux.Handle(pattern, handler) } -// HandleFunc registers an http.HandlerFunc for the given pattern, applying all -// middlewares in the HandlerGroup's chain. +// HandleFunc registers an http.HandlerFunc for the given pattern func (hg *HandlerGroup) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { hg.mux.Handle(pattern, http.HandlerFunc(handler)) } +// HandleGroup applies `http.StripPrefix` to http.Handler and registers it for the given pattern func (hg *HandlerGroup) HandleGroup(pattern string, handler http.Handler) { hg.mux.Handle(pattern+"/", http.StripPrefix(pattern, handler)) } From eec69b1f1545410218e80a525f38c2b8edbead63 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 18:34:28 +0300 Subject: [PATCH 22/24] Add HandlerGroup test case The test case verifies that HandlerGroup can be registered at a specific path prefix and that requests are properly routed to handlers within the group. --- httpserver/httpserver_test.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/httpserver/httpserver_test.go b/httpserver/httpserver_test.go index 7e86b24..b56005b 100644 --- a/httpserver/httpserver_test.go +++ b/httpserver/httpserver_test.go @@ -61,6 +61,27 @@ func TestHttpServer(t *testing.T) { } }) + t.Run("handle group", func(t *testing.T) { + t.Parallel() + + hg := httpserver.NewHandlerGroup() + hg.Handle("/test", &handler{}) + + server := httpserver.New("", 0) + server.HandleGroup("/hg", hg) + + r := httptest.NewRequest(http.MethodGet, "/hg/test", nil) + w := httptest.NewRecorder() + + server.ServeHTTP(w, r) + + resp := w.Result() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status code to be 200, got %d", resp.StatusCode) + } + }) + t.Run("healthcheck", func(t *testing.T) { t.Parallel() @@ -76,7 +97,7 @@ func TestHttpServer(t *testing.T) { } }) - t.Run("Use middleware", func(t *testing.T) { + t.Run("use", func(t *testing.T) { t.Parallel() server := httpserver.New("", 0) @@ -107,7 +128,7 @@ func TestHttpServer(t *testing.T) { } }) - t.Run("UseFunc middleware", func(t *testing.T) { + t.Run("use func", func(t *testing.T) { t.Parallel() server := httpserver.New("", 0) From 3771e46e978b22b4436d54eb4deede1560ed29c6 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 19:18:03 +0300 Subject: [PATCH 23/24] Add trace ID middleware tests --- httpserver/traceid_test.go | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 httpserver/traceid_test.go diff --git a/httpserver/traceid_test.go b/httpserver/traceid_test.go new file mode 100644 index 0000000..016960c --- /dev/null +++ b/httpserver/traceid_test.go @@ -0,0 +1,41 @@ +package httpserver_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/mishankov/platforma/httpserver" + "github.com/mishankov/platforma/log" +) + +func TestTraceIdMiddleware(t *testing.T) { + t.Parallel() + + t.Run("default params", func(t *testing.T) { + t.Parallel() + + m := httpserver.NewTraceIdMiddleware(nil, "") + wrappedHandler := m.Wrap(&handler{serveHttp: func(w http.ResponseWriter, r *http.Request) { + i, ok := r.Context().Value(log.TraceIdKey).(string) + if ok { + w.Header().Add("TraceIdFromContext", i) + } + }}) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, r) + resp := w.Result() + + if len(resp.Header.Get("Platforma-Trace-Id")) == 0 { + t.Fatalf("default trace id header expected, got: %s", resp.Header) + } + + if len(resp.Header.Get("TraceIdFromContext")) == 0 { + t.Fatalf("trsce id from cotext expected, got: %s", resp.Header) + } + + }) +} From d762541965f398ce82e03f0fa50b35f1eb7b0b03 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Wed, 29 Oct 2025 20:08:43 +0300 Subject: [PATCH 24/24] Add revive checks to httpserver --- .golangci.yml | 2 +- demo-app/cmd/api/main.go | 2 +- httpserver/httpserver.go | 15 ++++++++++----- httpserver/httpserver_test.go | 12 ++++++------ httpserver/recover_test.go | 4 ++-- httpserver/traceid.go | 19 ++++++++++--------- httpserver/traceid_test.go | 4 ++-- 7 files changed, 32 insertions(+), 26 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index bed01b8..86e236d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -65,7 +65,7 @@ linters: - demo-app rules: - - path-except: "database/*" + - path-except: "(database|httpserver)/*" linters: - revive diff --git a/demo-app/cmd/api/main.go b/demo-app/cmd/api/main.go index 99b6cf1..9fbb1a8 100644 --- a/demo-app/cmd/api/main.go +++ b/demo-app/cmd/api/main.go @@ -31,7 +31,7 @@ func main() { }) // Add middleware to HTTP server. It will add trace ID to logs and responce headers - api.Use(httpserver.NewTraceIdMiddleware(nil, "")) + api.Use(httpserver.NewTraceIDMiddleware(nil, "")) // Create handler group subApiGroup := httpserver.NewHandlerGroup() diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go index 74dff37..49232a8 100644 --- a/httpserver/httpserver.go +++ b/httpserver/httpserver.go @@ -1,3 +1,4 @@ +// Package httpserver provides HTTP server functionality with middleware support. package httpserver import ( @@ -15,17 +16,20 @@ import ( type handleGroup = HandlerGroup -type HttpServer struct { +// HTTPServer represents an HTTP server with middleware support and graceful shutdown. +type HTTPServer struct { *handleGroup port string shutdownTimeout time.Duration } -func New(port string, shutdownTimeout time.Duration) *HttpServer { - return &HttpServer{handleGroup: NewHandlerGroup(), port: port, shutdownTimeout: shutdownTimeout} +// New creates a new HTTPServer instance with the specified port and shutdown timeout. +func New(port string, shutdownTimeout time.Duration) *HTTPServer { + return &HTTPServer{handleGroup: NewHandlerGroup(), port: port, shutdownTimeout: shutdownTimeout} } -func (s *HttpServer) Run(ctx context.Context) error { +// Run starts the HTTP server and handles graceful shutdown on interrupt signals. +func (s *HTTPServer) Run(ctx context.Context) error { server := &http.Server{ Addr: ":" + s.port, Handler: wrapHandlerInMiddleware(s.mux, s.middlewares), @@ -56,7 +60,8 @@ func (s *HttpServer) Run(ctx context.Context) error { return nil } -func (s *HttpServer) Healthcheck(ctx context.Context) any { +// Healthcheck returns health check information for the HTTP server. +func (s *HTTPServer) Healthcheck(_ context.Context) any { return map[string]any{ "port": s.port, } diff --git a/httpserver/httpserver_test.go b/httpserver/httpserver_test.go index b56005b..b7674f0 100644 --- a/httpserver/httpserver_test.go +++ b/httpserver/httpserver_test.go @@ -10,7 +10,7 @@ import ( "github.com/mishankov/platforma/httpserver" ) -func TestHttpServer(t *testing.T) { +func TestHTTPServer(t *testing.T) { t.Parallel() t.Run("single http.HandlerFunc endpoint", func(t *testing.T) { @@ -18,7 +18,7 @@ func TestHttpServer(t *testing.T) { server := httpserver.New("", 0) - server.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + server.HandleFunc("/ping", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("pong")) }) @@ -39,7 +39,7 @@ func TestHttpServer(t *testing.T) { t.Parallel() pingHandler := &handler{ - serveHttp: func(w http.ResponseWriter, r *http.Request) { + serveHTTP: func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("pong")) }, } @@ -213,12 +213,12 @@ func TestHttpServer(t *testing.T) { } type handler struct { - serveHttp func(http.ResponseWriter, *http.Request) + serveHTTP func(http.ResponseWriter, *http.Request) } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if h.serveHttp != nil { - h.serveHttp(w, r) + if h.serveHTTP != nil { + h.serveHTTP(w, r) return } diff --git a/httpserver/recover_test.go b/httpserver/recover_test.go index 88ef043..3eb52e7 100644 --- a/httpserver/recover_test.go +++ b/httpserver/recover_test.go @@ -13,14 +13,14 @@ type panicHandler struct { panicMessage string } -func (h *panicHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *panicHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) { panic(h.panicMessage) } // normalHandler is a test handler that returns success type normalHandler struct{} -func (h *normalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *normalHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("Success")) } diff --git a/httpserver/traceid.go b/httpserver/traceid.go index eb7c5f3..e26aa01 100644 --- a/httpserver/traceid.go +++ b/httpserver/traceid.go @@ -9,16 +9,16 @@ import ( "github.com/google/uuid" ) -// TraceIdMiddleware is a middleware that adds a trace ID to the request context and response headers. -type TraceIdMiddleware struct { +// TraceIDMiddleware is a middleware that adds a trace ID to the request context and response headers. +type TraceIDMiddleware struct { contextKey any header string } -// NewTraceIdMiddleware returns a new TraceId middleware. +// NewTraceIDMiddleware returns a new TraceID middleware. // If key is nil, log.TraceIdKey is used. // If header is empty, "Platforma-Trace-Id" is used. -func NewTraceIdMiddleware(contextKey any, header string) *TraceIdMiddleware { +func NewTraceIDMiddleware(contextKey any, header string) *TraceIDMiddleware { if contextKey == nil { contextKey = log.TraceIdKey } @@ -27,16 +27,17 @@ func NewTraceIdMiddleware(contextKey any, header string) *TraceIdMiddleware { header = "Platforma-Trace-Id" } - return &TraceIdMiddleware{contextKey: contextKey, header: header} + return &TraceIDMiddleware{contextKey: contextKey, header: header} } -func (m *TraceIdMiddleware) Wrap(h http.Handler) http.Handler { +// Wrap implements the Middleware interface by adding trace ID to requests. +func (m *TraceIDMiddleware) Wrap(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - traceId := uuid.NewString() - ctx := context.WithValue(r.Context(), m.contextKey, traceId) + traceID := uuid.NewString() + ctx := context.WithValue(r.Context(), m.contextKey, traceID) r = r.WithContext(ctx) - w.Header().Set(m.header, traceId) + w.Header().Set(m.header, traceID) h.ServeHTTP(w, r) }) diff --git a/httpserver/traceid_test.go b/httpserver/traceid_test.go index 016960c..30eb602 100644 --- a/httpserver/traceid_test.go +++ b/httpserver/traceid_test.go @@ -15,8 +15,8 @@ func TestTraceIdMiddleware(t *testing.T) { t.Run("default params", func(t *testing.T) { t.Parallel() - m := httpserver.NewTraceIdMiddleware(nil, "") - wrappedHandler := m.Wrap(&handler{serveHttp: func(w http.ResponseWriter, r *http.Request) { + m := httpserver.NewTraceIDMiddleware(nil, "") + wrappedHandler := m.Wrap(&handler{serveHTTP: func(w http.ResponseWriter, r *http.Request) { i, ok := r.Context().Value(log.TraceIdKey).(string) if ok { w.Header().Add("TraceIdFromContext", i)