diff --git a/OPENTELEMETRY_INTEGRATION.md b/OPENTELEMETRY_INTEGRATION.md index 155c0f5..f8c51fb 100644 --- a/OPENTELEMETRY_INTEGRATION.md +++ b/OPENTELEMETRY_INTEGRATION.md @@ -260,12 +260,12 @@ import ( type UserHandler struct{} -func (h UserHandler) Parameters() handlers.HandlerParameters { - return handlers.HandlerParameters{ +func (h UserHandler) Parameters() handlers.HandlerParameters[GetUserRequest, GetUserResponse] { + return handlers.HandlerParameters[GetUserRequest, GetUserResponse]{ Title: "GetUser", Body: libRequest.JSON, ValidateHeader: true, - SaveToRequest: true, + Persistence: myRequestPersister{}, // optional; nil disables persistence Path: "/users/:id", EnableTracing: true, // Enable tracing for this handler TracingSpanName: "get_user_handler", diff --git a/go.mod b/go.mod index a4350fb..78ac021 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/blockloop/scan/v2 v2.5.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 + github.com/go-chi/chi/v5 v5.3.0 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.27.0 @@ -37,6 +38,7 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b + golang.org/x/oauth2 v0.36.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 @@ -58,7 +60,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-chi/chi/v5 v5.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect @@ -106,7 +107,6 @@ require ( golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect diff --git a/handlers/README.md b/handlers/README.md index 6d4f74a..465cc68 100644 --- a/handlers/README.md +++ b/handlers/README.md @@ -37,6 +37,7 @@ handlers/ ├── dmlHandler.go ├── ormQueryHandler.go ├── pagination.go +├── persistence.go ├── queryHandler.go ├── recovery.go └── *_test.go @@ -315,6 +316,47 @@ This allows consistent visibility across request pipelines. --- +# Request persistence (optional) + +Handlers support optional request/result persistence via `RequestPersister[Req, Resp]` on generic `HandlerParameters[Req, Resp]`. + +When `Persistence` is `nil` (default for built-in query/DML/call handlers), no insert or update runs. When set, the framework calls: + +1. `Insert(path, req)` after parse — failure aborts the request +2. `Update(path, req)` in `Recovery` after `Finalizer` and log collection — best-effort; errors are logged only and not retried + +```go +type RequestPersister[Req, Resp any] interface { + Insert(path string, req *HandlerRequest[Req, Resp]) error + Update(path string, req *HandlerRequest[Req, Resp]) error +} +``` + +There is no built-in persister in this package. Consumers implement `RequestPersister` when they need audit storage, for example delegating to `libRequest`: + +```go +type ServiceRequestPersister[Req, Resp any] struct{} + +func (ServiceRequestPersister[Req, Resp]) Insert(path string, req *handlers.HandlerRequest[Req, Resp]) error { + return req.Core.RequestTools().InitRequest(req.W, req.Title, path) +} + +func (ServiceRequestPersister[Req, Resp]) Update(path string, req *handlers.HandlerRequest[Req, Resp]) error { + reqLogLocal := req.W.Parser.GetLocal("reqLog") + reqLog, ok := reqLogLocal.(libRequest.RequestPtr) + if !ok || reqLog == nil { + return nil + } + reqLog.Outgoing = req.Response + // map errors from req.W.Parser.GetLocal("errorArray") as needed + return req.Core.RequestTools().UpdateRequestWithContext(req.W.Ctx, reqLog) +} +``` + +SQL and table shape for the service-tier `request` table live in application setup (`libApplication/env.go`), not in handlers. + +--- + # Testing The handlers package includes test coverage through files such as: diff --git a/handlers/baseHandler.go b/handlers/baseHandler.go index 93b7afe..6bf0314 100644 --- a/handlers/baseHandler.go +++ b/handlers/baseHandler.go @@ -17,17 +17,17 @@ import ( "go.opentelemetry.io/otel/trace" ) -type HandlerParameters struct { +type HandlerParameters[Req, Resp any] struct { Title string Body libRequest.Type ValidateHeader bool - SaveToRequest bool Path string HasReceipt bool RecoveryHandler func(any) FileResponse bool LogArrays []string LogTags []string + Persistence RequestPersister[Req, Resp] // Tracing parameters EnableTracing bool TracingSpanName string @@ -37,9 +37,9 @@ type HandlerInterface[Req any, Resp any] interface { // returns handler title // Request Bodymode // and validate header option - // and save to request table option + // and optional request persistence // and url path of handler - Parameters() HandlerParameters + Parameters() HandlerParameters[Req, Resp] // runs after validating request Initializer(req HandlerRequest[Req, Resp]) error // main handler runs after initialize @@ -124,8 +124,9 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( webFramework.AddServiceRegistrationLog(params.Title) return func(c context.Context) { start := time.Now() + var requestInserted bool var w webFramework.WebFramework - if params.SaveToRequest { + if params.Persistence != nil { w = libContext.InitContext(c) } else { w = libContext.InitContextNoAuditTrail(c) @@ -153,7 +154,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( attribute.String("handler.title", params.Title), attribute.String("handler.path", params.Path), attribute.Bool("handler.validate_header", params.ValidateHeader), - attribute.Bool("handler.save_to_request", params.SaveToRequest), + attribute.Bool("handler.has_persistence", params.Persistence != nil), ) } } @@ -168,7 +169,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( } defer func() { - Recovery(start, w, handler, params, trx, core) + Recovery(start, w, handler, params, &trx, core, requestInserted) }() // Ensure span is ended @@ -214,14 +215,12 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( w.Parser.SetLocal(libLogger.SlogRequestBody, trx.Request) webFramework.AddLog(w, webFramework.HandlerLogTag, slog.Any("request", trx.Request)) - if params.SaveToRequest { - errInitRequest := core.RequestTools().InitRequest(trx.W, params.Title, params.Path) - if errInitRequest != nil { - core.Responder().Error(trx.W, errInitRequest) + if params.Persistence != nil { + if errInsert := params.Persistence.Insert(params.Path, &trx); errInsert != nil { + core.Responder().Error(trx.W, errInsert) return } - } else { - w.Parser.SetLocal("reqLog", nil) + requestInserted = true } var errInit error diff --git a/handlers/baseHanlder_test.go b/handlers/baseHanlder_test.go index 5ad1337..26c9b42 100644 --- a/handlers/baseHanlder_test.go +++ b/handlers/baseHanlder_test.go @@ -2,6 +2,7 @@ package handlers import ( "log" + "sync/atomic" "testing" "github.com/gin-gonic/gin" @@ -17,20 +18,35 @@ type testResp struct { Result string `json:"result"` } +type testPersister[Req, Resp any] struct { + updateCalled *atomic.Bool +} + +func (p testPersister[Req, Resp]) Insert(path string, req *HandlerRequest[Req, Resp]) error { + return req.Core.RequestTools().InitRequest(req.W, req.Title, path) +} + +func (p testPersister[Req, Resp]) Update(_ string, _ *HandlerRequest[Req, Resp]) error { + if p.updateCalled != nil { + p.updateCalled.Store(true) + } + return nil +} + type testHandlerType[Req testReq, Resp testResp] struct { Title string Path string Mode libRequest.Type VerifyHeader bool - SaveRequest bool + Persistence RequestPersister[Req, Resp] } -func (h testHandlerType[Req, Resp]) Parameters() HandlerParameters { - return HandlerParameters{ +func (h testHandlerType[Req, Resp]) Parameters() HandlerParameters[Req, Resp] { + return HandlerParameters[Req, Resp]{ Title: h.Title, Body: h.Mode, ValidateHeader: h.VerifyHeader, - SaveToRequest: h.SaveRequest, + Persistence: h.Persistence, Path: h.Path, HasReceipt: false, RecoveryHandler: nil, @@ -58,6 +74,7 @@ func (h testHandlerType[Req, Resp]) Simulation(req HandlerRequest[Req, Resp]) (R func (h testHandlerType[Req, Resp]) Finalizer(req HandlerRequest[Req, Resp]) { log.Println("Finalizer") } + func TestBaseHandler(t *testing.T) { testCases := []testingtools.TestCase{ { @@ -85,7 +102,7 @@ func TestBaseHandler(t *testing.T) { testHandlerType[testReq, testResp]{ Title: "test", Path: "/path/to/api", - SaveRequest: true, + Persistence: testPersister[testReq, testResp]{}, Mode: libRequest.JSON, VerifyHeader: true, }, @@ -100,3 +117,75 @@ func TestBaseHandler(t *testing.T) { Silent: true, }) } + +func TestBaseHandlerPersistenceUpdate(t *testing.T) { + var updateCalled atomic.Bool + + env := testingtools.GetEnvWithDB[testingtools.TestEnv]( + testingtools.SampleRequestModelMock(t, nil).DB, + testingtools.DefaultAPIList, + ) + + handler := BaseHandler( + env.Interface, + testHandlerType[testReq, testResp]{ + Title: "test", + Path: "/path/to/api", + Persistence: testPersister[testReq, testResp]{updateCalled: &updateCalled}, + Mode: libRequest.JSON, + VerifyHeader: true, + }, + false, + ) + gin.SetMode(gin.ReleaseMode) + testingtools.TestDB(t, &testingtools.TestCase{ + Name: "Valid", + Url: "/", + Request: testReq{ID: "1"}, + Status: 200, + CheckBody: []string{"result", `"a"`}, + }, &testingtools.TestOptions{ + Path: "/", + Name: "check persistence update", + Method: "POST", + Handler: handler, + Silent: true, + }) + + if !updateCalled.Load() { + t.Fatal("expected Persistence.Update to be called after successful Insert") + } +} + +func TestBaseHandlerNoPersistence(t *testing.T) { + env := testingtools.GetEnvWithDB[testingtools.TestEnv]( + testingtools.SampleRequestModelMock(t, nil).DB, + testingtools.DefaultAPIList, + ) + + handler := BaseHandler( + env.Interface, + testHandlerType[testReq, testResp]{ + Title: "test", + Path: "/path/to/api", + Persistence: nil, + Mode: libRequest.JSON, + VerifyHeader: true, + }, + false, + ) + gin.SetMode(gin.ReleaseMode) + testingtools.TestDB(t, &testingtools.TestCase{ + Name: "Valid", + Url: "/", + Request: testReq{ID: "1"}, + Status: 200, + CheckBody: []string{"result", `"a"`}, + }, &testingtools.TestOptions{ + Path: "/", + Name: "check no persistence", + Method: "POST", + Handler: handler, + Silent: true, + }) +} diff --git a/handlers/consumeHandler.go b/handlers/consumeHandler.go index 72c26e1..0381215 100644 --- a/handlers/consumeHandler.go +++ b/handlers/consumeHandler.go @@ -49,22 +49,18 @@ type CallArgs[Req any, Resp any] struct { RecoveryHandler func(any) } -func (c CallArgs[Req, Resp]) Parameters() HandlerParameters { +func (c CallArgs[Req, Resp]) Parameters() HandlerParameters[Req, Resp] { var mode libRequest.Type if c.IsJson { mode = libRequest.JSON } else { mode = libRequest.Query } - save := false - if c.HasInitializer { - save = true - } - return HandlerParameters{ + return HandlerParameters[Req, Resp]{ Title: c.Title, Body: mode, ValidateHeader: false, - SaveToRequest: save, + Persistence: nil, Path: c.Path, HasReceipt: false, RecoveryHandler: c.RecoveryHandler, @@ -178,7 +174,6 @@ type ConsumeHandlerType[Req, Resp any] struct { Path string Mode libRequest.Type VerifyHeader bool - SaveToRequest bool HasReceipt bool Headers []string Api string @@ -187,12 +182,12 @@ type ConsumeHandlerType[Req, Resp any] struct { RecoveryHandler func(any) } -func (h *ConsumeHandlerType[Req, Resp]) Parameters() HandlerParameters { - return HandlerParameters{ +func (h *ConsumeHandlerType[Req, Resp]) Parameters() HandlerParameters[Req, Resp] { + return HandlerParameters[Req, Resp]{ Title: h.Title, Body: h.Mode, ValidateHeader: h.VerifyHeader, - SaveToRequest: h.SaveToRequest, + Persistence: nil, Path: h.Path, HasReceipt: h.HasReceipt, RecoveryHandler: h.RecoveryHandler, diff --git a/handlers/consumeHandler_test.go b/handlers/consumeHandler_test.go index c683cf2..b19824e 100644 --- a/handlers/consumeHandler_test.go +++ b/handlers/consumeHandler_test.go @@ -131,7 +131,6 @@ type testConsumeHandlerType[Req, Resp any] struct { Path string Mode libRequest.Type VerifyHeader bool - SaveToRequest bool HasReceipt bool Headers []string Api string @@ -140,12 +139,12 @@ type testConsumeHandlerType[Req, Resp any] struct { RecoveryHandler func(any) } -func (h testConsumeHandlerType[Req, Resp]) Parameters() handlers.HandlerParameters { - return handlers.HandlerParameters{ +func (h testConsumeHandlerType[Req, Resp]) Parameters() handlers.HandlerParameters[Req, Resp] { + return handlers.HandlerParameters[Req, Resp]{ Title: h.Title, Body: h.Mode, ValidateHeader: h.VerifyHeader, - SaveToRequest: h.SaveToRequest, + Persistence: nil, Path: h.Path, HasReceipt: false, RecoveryHandler: nil, @@ -222,10 +221,9 @@ func TestConsumeHandler(t *testing.T) { Method: "POST", }, Api: "simulation", - Path: "users", - Mode: libRequest.JSON, - VerifyHeader: false, - SaveToRequest: false, + Path: "users", + Mode: libRequest.JSON, + VerifyHeader: false, }, false, ) diff --git a/handlers/dmlHandler.go b/handlers/dmlHandler.go index 82d8859..9b0da0c 100644 --- a/handlers/dmlHandler.go +++ b/handlers/dmlHandler.go @@ -82,12 +82,12 @@ type DmlHandlerType[Req libQuery.DmlModel, Resp map[string]any] struct { RecoveryHandler func(any) } -func (h DmlHandlerType[Req, Resp]) Parameters() HandlerParameters { - return HandlerParameters{ +func (h DmlHandlerType[Req, Resp]) Parameters() HandlerParameters[Req, Resp] { + return HandlerParameters[Req, Resp]{ Title: h.Title, Body: h.Mode, ValidateHeader: h.VerifyHeader, - SaveToRequest: true, + Persistence: nil, Path: h.Path, HasReceipt: false, RecoveryHandler: h.RecoveryHandler, diff --git a/handlers/dmlHandler_test.go b/handlers/dmlHandler_test.go index d2c542c..2ab19a5 100644 --- a/handlers/dmlHandler_test.go +++ b/handlers/dmlHandler_test.go @@ -74,15 +74,6 @@ func (env *testDMLEnv) handler() any { ) } -func beforeDMLMocks(mockDB sqlmock.Sqlmock) { - var anyS testingtools.AnyString - mockDB.ExpectBegin() - mockDB.ExpectExec("").WithArgs(anyS, anyS).WillReturnResult(driver.RowsAffected(1)) - mockDB.ExpectExec("").WithArgs(anyS, anyS).WillReturnResult(driver.RowsAffected(1)) - mockDB.ExpectExec("").WithArgs(anyS, anyS).WillReturnResult(driver.RowsAffected(1)) - mockDB.ExpectExec("").WithArgs(anyS, anyS).WillReturnResult(driver.RowsAffected(1)) -} - func TestDMLHandler(t *testing.T) { testCases := []testingtools.TestCase{ { @@ -91,9 +82,14 @@ func TestDMLHandler(t *testing.T) { Request: testDMLReq{ID: "1"}, Status: 200, CheckBody: []string{Ins1, "rowsAffected", `:1`}, - Model: testingtools.SampleRequestModelMock(t, func(mockDB sqlmock.Sqlmock) { + Model: testingtools.SampleQueryMock(t, func(mockDB sqlmock.Sqlmock) { mockDB.ExpectPrepare(Pre1).ExpectQuery().WillReturnRows(sqlmock.NewRows([]string{"result", "key", "value"})) - beforeDMLMocks(mockDB) + var anyS testingtools.AnyString + mockDB.ExpectBegin() + mockDB.ExpectExec("").WithArgs(anyS, anyS).WillReturnResult(driver.RowsAffected(1)) + mockDB.ExpectExec("").WithArgs(anyS, anyS).WillReturnResult(driver.RowsAffected(1)) + mockDB.ExpectExec("").WithArgs(anyS, anyS).WillReturnResult(driver.RowsAffected(1)) + mockDB.ExpectExec("").WithArgs(anyS, anyS).WillReturnResult(driver.RowsAffected(1)) mockDB.ExpectExec(Ins1).WillReturnResult(driver.RowsAffected(1)) mockDB.ExpectCommit() }), @@ -105,7 +101,7 @@ func TestDMLHandler(t *testing.T) { Status: 500, // Safe error response: internal message (PreControl: pre1) no longer exposed; expect errors and safe description CheckBody: []string{"errors", "description"}, - Model: testingtools.SampleRequestModelMock(t, func(mockDB sqlmock.Sqlmock) { + Model: testingtools.SampleQueryMock(t, func(mockDB sqlmock.Sqlmock) { mockDB.ExpectPrepare(Pre1).ExpectQuery().WillReturnError(errors.New("error pre1")) }), }, diff --git a/handlers/ormQueryHandler.go b/handlers/ormQueryHandler.go index b0698c8..fc8d7e9 100644 --- a/handlers/ormQueryHandler.go +++ b/handlers/ormQueryHandler.go @@ -34,12 +34,12 @@ type OrmHandlerType[Row, Resp any] struct { OnEmpty200 bool } -func (q OrmHandlerType[Row, Resp]) Parameters() HandlerParameters { - return HandlerParameters{ +func (q OrmHandlerType[Row, Resp]) Parameters() HandlerParameters[Row, Resp] { + return HandlerParameters[Row, Resp]{ Title: q.Title, Body: q.Mode, ValidateHeader: q.VerifyHeader, - SaveToRequest: false, + Persistence: nil, Path: q.Path, HasReceipt: false, RecoveryHandler: q.RecoveryHandler, diff --git a/handlers/persistence.go b/handlers/persistence.go new file mode 100644 index 0000000..eee1dcd --- /dev/null +++ b/handlers/persistence.go @@ -0,0 +1,12 @@ +package handlers + +// RequestPersister optionally persists request lifecycle data for a handler. +// When HandlerParameters.Persistence is nil, insert/update are not called. +// +// Insert must succeed before the handler runs; failure aborts the request. +// Update is best-effort after the response may have been sent; the framework +// logs errors only and does not retry. +type RequestPersister[Req, Resp any] interface { + Insert(path string, req *HandlerRequest[Req, Resp]) error + Update(path string, req *HandlerRequest[Req, Resp]) error +} diff --git a/handlers/queryHandler.go b/handlers/queryHandler.go index d42b2bc..6ac758e 100644 --- a/handlers/queryHandler.go +++ b/handlers/queryHandler.go @@ -161,12 +161,12 @@ func Paginate[Row any](paginationData libRequest.PaginationData, data []Row, les return result[start:end] } -func (q QueryHandlerType[Row, Resp]) Parameters() HandlerParameters { - return HandlerParameters{ +func (q QueryHandlerType[Row, Resp]) Parameters() HandlerParameters[Row, Resp] { + return HandlerParameters[Row, Resp]{ Title: q.Title, Body: q.Mode, ValidateHeader: q.VerifyHeader, - SaveToRequest: false, + Persistence: nil, Path: q.Path, HasReceipt: false, RecoveryHandler: q.RecoveryHandler, diff --git a/handlers/recovery.go b/handlers/recovery.go index 722a4de..b4fe150 100644 --- a/handlers/recovery.go +++ b/handlers/recovery.go @@ -18,13 +18,14 @@ func Recovery[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( start time.Time, w webFramework.WebFramework, handler Handler, - params HandlerParameters, - trx HandlerRequest[Req, Resp], + params HandlerParameters[Req, Resp], + trx *HandlerRequest[Req, Resp], core requestCore.RequestCoreInterface, + requestInserted bool, ) { elapsed := time.Since(start) webFramework.AddLogTag(w, webFramework.HandlerLogTag, slog.String("elapsed", elapsed.String())) - libTracing.TraceVoid(handler.Finalizer, trx) + libTracing.TraceVoid(handler.Finalizer, *trx) webFramework.CollectLogTags(w, webFramework.HandlerLogTag) webFramework.CollectLogArrays(w, webFramework.HandlerLogTag) webFramework.CollectLogTags(w, webFramework.ErrorListLogTag) @@ -36,6 +37,14 @@ func Recovery[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( for id := range params.LogArrays { webFramework.CollectLogArrays(w, params.LogArrays[id]) } + if params.Persistence != nil && requestInserted { + if errUpdate := params.Persistence.Update(params.Path, trx); errUpdate != nil { + slog.Error("request persistence update failed", + slog.String("handler", params.Title), + slog.String("path", params.Path), + slog.Any("error", errUpdate)) + } + } if r := recover(); r != nil { if params.RecoveryHandler != nil { params.RecoveryHandler(r) diff --git a/libContext/init.go b/libContext/init.go index 6555e55..f0521ce 100644 --- a/libContext/init.go +++ b/libContext/init.go @@ -44,14 +44,6 @@ func initContext(c any, unknownUser bool) webFramework.WebFramework { var span trace.Span switch ctx := c.(type) { - case context.Context: - req, okReq := libNetHttp.RequestFromContext(ctx) - writer, okWriter := libNetHttp.ResponseWriterFromContext(ctx) - if okReq && okWriter { - return InitNetHttpContext(req, writer, unknownUser) - } - stack := response.GetStack(1, "libContext/init.go") - log.Fatalf("error in InitContext: context missing net/http request/response data %T, Stack: %s", ctx, stack) case *gin.Context: if unknownUser { ctx.Set(UserIdLocal, UnknownUser) @@ -86,6 +78,14 @@ func initContext(c any, unknownUser bool) webFramework.WebFramework { w.Parser = initTestContext(ctx) // No tracing in test context span = nil + case context.Context: + req, okReq := libNetHttp.RequestFromContext(ctx) + writer, okWriter := libNetHttp.ResponseWriterFromContext(ctx) + if okReq && okWriter { + return InitNetHttpContext(req, writer, unknownUser) + } + stack := response.GetStack(1, "libContext/init.go") + log.Fatalf("error in InitContext: context missing net/http request/response data %T, Stack: %s", ctx, stack) default: stack := response.GetStack(1, "libContext/init.go") log.Fatalf("error in InitContext: unknown webFramework %T, Stack: %s", ctx, stack)