From 4789409f04253764ca145e7b6f3e5ee5e5ce2da1 Mon Sep 17 00:00:00 2001 From: Hamid Malek Mohammadi Date: Sat, 20 Jun 2026 17:16:17 +0330 Subject: [PATCH] Add handler outcome metadata and persistence helpers for Update persisters. Expose Outcome on HandlerRequest, record emitted HTTP status from the responder, fix panic recovery before Update, and add FuncPersister plus record ID helpers with tests. Co-authored-by: Cursor --- handlers/README.md | 62 +++++++++- handlers/baseHandler.go | 61 +++++++--- handlers/baseHanlder_test.go | 217 +++++++++++++++++++++++++++-------- handlers/persistence.go | 35 ++++++ handlers/persistence_test.go | 116 +++++++++++++++++++ handlers/recovery.go | 60 +++++----- response/webHandler.go | 13 +++ 7 files changed, 472 insertions(+), 92 deletions(-) create mode 100644 handlers/persistence_test.go diff --git a/handlers/README.md b/handlers/README.md index 465cc68..0b668f2 100644 --- a/handlers/README.md +++ b/handlers/README.md @@ -332,13 +332,64 @@ type RequestPersister[Req, Resp any] interface { } ``` -There is no built-in persister in this package. Consumers implement `RequestPersister` when they need audit storage, for example delegating to `libRequest`: +### Handler outcome fields + +After the response is sent (or after a panic is captured in `Recovery`), `Update` receives a `HandlerRequest` with populated outcome metadata: + +```go +type HandlerOutcome struct { + Error error // nil on success; set on handler/init/parse/insert errors and panics + HTTPStatus int // HTTP status sent or intended (500 on panic); 0 if no response was sent +} + +type HandlerRequest[Req, Resp any] struct { + ... + Outcome HandlerOutcome + Duration time.Duration // elapsed handler time, set in Recovery + RespSent bool +} +``` + +`HTTPStatus` is recorded by the response layer (`response.LastHTTPStatusLocal`) after `Responder().OK` / `Responder().Error`, so it reflects the status actually emitted — not a duplicate mapping in handlers. On panic, `Recovery` sets `HTTPStatus` to `500` before `Update` runs. + +Use `trx.Outcome.Error` with `libError.Unwrap` to build persistence outcome without reading parser locals such as `errorArray`. + +### Record ID handoff + +Use a shared local key instead of per-domain names (`shahkar_request_id`, `card_issue_id`, etc.): + +```go +handlers.SetPersistedRecordID(req.W, recordID) // any type: int64, string UUID, etc. +id, ok := handlers.GetPersistedRecordID(req.W) +``` + +### FuncPersister + +For tests or thin adapters, implement persistence with function fields: + +```go +p := handlers.FuncPersister[MyReq, MyResp]{ + InsertFn: func(path string, req *handlers.HandlerRequest[MyReq, MyResp]) error { ... }, + UpdateFn: func(path string, req *handlers.HandlerRequest[MyReq, MyResp]) error { ... }, +} +``` + +Nil function fields are no-ops. Use `Persistence: nil` when no persistence is needed. + +### Example service persister + +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) + err := req.Core.RequestTools().InitRequest(req.W, req.Title, path) + if err != nil { + return err + } + handlers.SetPersistedRecordID(req.W, req.W.Parser.GetLocal("reqLog").(libRequest.RequestPtr).GetId()) + return nil } func (ServiceRequestPersister[Req, Resp]) Update(path string, req *handlers.HandlerRequest[Req, Resp]) error { @@ -348,7 +399,11 @@ func (ServiceRequestPersister[Req, Resp]) Update(path string, req *handlers.Hand return nil } reqLog.Outgoing = req.Response - // map errors from req.W.Parser.GetLocal("errorArray") as needed + if req.Outcome.Error != nil { + if ok, errData := libError.Unwrap(req.Outcome.Error); ok { + _ = errData // map code/status into your storage model + } + } return req.Core.RequestTools().UpdateRequestWithContext(req.W.Ctx, reqLog) } ``` @@ -363,6 +418,7 @@ The handlers package includes test coverage through files such as: ```text baseHanlder_test.go +persistence_test.go callApi_test.go consumeHandler_test.go dmlHandler_test.go diff --git a/handlers/baseHandler.go b/handlers/baseHandler.go index 6bf0314..22a0ca1 100644 --- a/handlers/baseHandler.go +++ b/handlers/baseHandler.go @@ -50,6 +50,11 @@ type HandlerInterface[Req any, Resp any] interface { Simulation(req HandlerRequest[Req, Resp]) (Resp, error) } +type HandlerOutcome struct { + Error error + HTTPStatus int +} + type HandlerRequest[Req any, Resp any] struct { Title string Core requestCore.RequestCoreInterface @@ -60,11 +65,18 @@ type HandlerRequest[Req any, Resp any] struct { Args []any RespSent bool Builder func(status int, rawResp []byte, headers map[string]string) (*Resp, error) + Outcome HandlerOutcome + Duration time.Duration // Tracing fields Span trace.Span SpanCtx context.Context } +func (trx *HandlerRequest[Req, Resp]) SetOutcome(err error, httpStatus int) { + trx.Outcome.Error = err + trx.Outcome.HTTPStatus = httpStatus +} + // Tracing methods for HandlerRequest func (hr *HandlerRequest[Req, Resp]) AddSpanAttribute(key, value string) { if hr.Span != nil && hr.Span.IsRecording() { @@ -114,6 +126,29 @@ func (hr HandlerRequest[Req, Resp]) GetParser() webFramework.RequestParser { return hr.W.Parser } +func respondError[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], err error) { + core.Responder().Error(trx.W, err) + trx.SetOutcome(err, response.LastHTTPStatus(trx.W)) +} + +func respondOK[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], resp Resp) { + core.Responder().OK(trx.W, resp) + trx.SetOutcome(nil, response.LastHTTPStatus(trx.W)) + trx.RespSent = true +} + +func respondOKWithReceipt[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], resp Resp, receipt *response.Receipt) { + core.Responder().OKWithReceipt(trx.W, resp, receipt) + trx.SetOutcome(nil, response.LastHTTPStatus(trx.W)) + trx.RespSent = true +} + +func respondOKWithAttachment[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], attachment *response.FileResponse) { + core.Responder().OKWithAttachment(trx.W, attachment) + trx.SetOutcome(nil, response.LastHTTPStatus(trx.W)) + trx.RespSent = true +} + func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( core requestCore.RequestCoreInterface, handler Handler, @@ -169,7 +204,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( } defer func() { - Recovery(start, w, handler, params, &trx, core, requestInserted) + Recovery(start, w, handler, params, &trx, core, requestInserted, recover()) }() // Ensure span is ended @@ -186,7 +221,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( params.ValidateHeader, ) if errParse != nil { - core.Responder().Error(trx.W, errParse) + respondError(core, &trx, errParse) return } @@ -196,11 +231,12 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( var err error trx.Response, err = libTracing.TraceFunc(handler.Simulation, trx) if err != nil { - core.Responder().Error(trx.W, err) + respondError(core, &trx, err) return } - core.Responder().OK(trx.W, trx.Response) + respondOK(core, &trx, trx.Response) + return } var errParse error @@ -209,7 +245,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( params.Body, params.ValidateHeader) if errParse != nil { - core.Responder().Error(trx.W, errParse) + respondError(core, &trx, errParse) return } w.Parser.SetLocal(libLogger.SlogRequestBody, trx.Request) @@ -217,7 +253,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( if params.Persistence != nil { if errInsert := params.Persistence.Insert(params.Path, &trx); errInsert != nil { - core.Responder().Error(trx.W, errInsert) + respondError(core, &trx, errInsert) return } requestInserted = true @@ -227,7 +263,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( errInit = libTracing.TraceError(handler.Initializer, trx) webFramework.AddLog(w, webFramework.HandlerLogTag, slog.Any("initialize", errInit)) if errInit != nil { - core.Responder().Error(trx.W, errInit) + respondError(core, &trx, errInit) return } @@ -235,7 +271,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( trx.Response, err = libTracing.TraceFunc(handler.Handler, trx) webFramework.AddLog(w, webFramework.HandlerLogTag, slog.Any("main-handler", err)) if err != nil { - core.Responder().Error(trx.W, err) + respondError(core, &trx, err) return } webFramework.AddLog(w, webFramework.HandlerLogTag, slog.Any("response", trx.Response)) @@ -244,8 +280,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( if receipt != nil { rc, ok := receipt.(*response.Receipt) if ok { - core.Responder().OKWithReceipt(trx.W, trx.Response, rc) - trx.RespSent = true + respondOKWithReceipt(core, &trx, trx.Response, rc) } else { slog.Error("registered as handler with receipt, but receipt local was", slog.Any("receipt", fmt.Sprintf("%t", receipt))) } @@ -259,8 +294,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( if attachment != nil { rc, ok := attachment.(*response.FileResponse) if ok { - core.Responder().OKWithAttachment(trx.W, rc) - trx.RespSent = true + respondOKWithAttachment(core, &trx, rc) } else { slog.Error("registered as handler with attachment, but attachment local was", slog.Any("receipt", fmt.Sprintf("%t", attachment))) } @@ -270,8 +304,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( } if !trx.RespSent { - core.Responder().OK(trx.W, trx.Response) - trx.RespSent = true + respondOK(core, &trx, trx.Response) } } } diff --git a/handlers/baseHanlder_test.go b/handlers/baseHanlder_test.go index 26c9b42..9079aa3 100644 --- a/handlers/baseHanlder_test.go +++ b/handlers/baseHanlder_test.go @@ -2,11 +2,14 @@ package handlers import ( "log" + "net/http" "sync/atomic" "testing" "github.com/gin-gonic/gin" + "github.com/hmmftg/requestCore/libError" "github.com/hmmftg/requestCore/libRequest" + "github.com/hmmftg/requestCore/status" "github.com/hmmftg/requestCore/testingtools" ) @@ -18,27 +21,35 @@ type testResp struct { Result string `json:"result"` } -type testPersister[Req, Resp any] struct { +type capturingPersister[Req, Resp any] struct { updateCalled *atomic.Bool + lastUpdated **HandlerRequest[Req, Resp] } -func (p testPersister[Req, Resp]) Insert(path string, req *HandlerRequest[Req, Resp]) error { +func (p capturingPersister[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 { +func (p capturingPersister[Req, Resp]) Update(_ string, req *HandlerRequest[Req, Resp]) error { if p.updateCalled != nil { p.updateCalled.Store(true) } + if p.lastUpdated != nil { + *p.lastUpdated = req + } return nil } type testHandlerType[Req testReq, Resp testResp] struct { - Title string - Path string - Mode libRequest.Type - VerifyHeader bool - Persistence RequestPersister[Req, Resp] + Title string + Path string + Mode libRequest.Type + VerifyHeader bool + Persistence RequestPersister[Req, Resp] + RecoveryHandler func(any) + InitErr error + HandlerErr error + PanicInHandler bool } func (h testHandlerType[Req, Resp]) Parameters() HandlerParameters[Req, Resp] { @@ -49,7 +60,7 @@ func (h testHandlerType[Req, Resp]) Parameters() HandlerParameters[Req, Resp] { Persistence: h.Persistence, Path: h.Path, HasReceipt: false, - RecoveryHandler: nil, + RecoveryHandler: h.RecoveryHandler, FileResponse: false, LogArrays: nil, LogTags: nil, @@ -59,10 +70,16 @@ func (h testHandlerType[Req, Resp]) Parameters() HandlerParameters[Req, Resp] { } func (h testHandlerType[Req, Resp]) Initializer(req HandlerRequest[Req, Resp]) error { log.Println("Initializer") - return nil + return h.InitErr } func (h testHandlerType[Req, Resp]) Handler(req HandlerRequest[Req, Resp]) (Resp, error) { log.Println("Handler") + if h.PanicInHandler { + panic("handler panic") + } + if h.HandlerErr != nil { + return Resp{}, h.HandlerErr + } result := testResp{Result: "a"} return Resp(result), nil } @@ -75,6 +92,37 @@ func (h testHandlerType[Req, Resp]) Finalizer(req HandlerRequest[Req, Resp]) { log.Println("Finalizer") } +func testEnv(t *testing.T) *testingtools.TestEnv { + return testingtools.GetEnvWithDB[testingtools.TestEnv]( + testingtools.SampleRequestModelMock(t, nil).DB, + testingtools.DefaultAPIList, + ) +} + +func runPersistenceHandlerTest( + t *testing.T, + handler testHandlerType[testReq, testResp], + request any, + expectedStatus int, +) { + t.Helper() + gin.SetMode(gin.ReleaseMode) + testingtools.TestDB(t, &testingtools.TestCase{ + Name: "persistence", + Url: "/", + Request: request, + Status: expectedStatus, + CheckBody: nil, + }, &testingtools.TestOptions{ + Path: "/", + Name: "persistence outcome", + Method: "POST", + Handler: BaseHandler(testEnv(t).Interface, handler, false), + Middleware: gin.Recovery(), + Silent: true, + }) +} + func TestBaseHandler(t *testing.T) { testCases := []testingtools.TestCase{ { @@ -92,17 +140,14 @@ func TestBaseHandler(t *testing.T) { }, } - env := testingtools.GetEnvWithDB[testingtools.TestEnv]( - testingtools.SampleRequestModelMock(t, nil).DB, - testingtools.DefaultAPIList, - ) + env := testEnv(t) handler := BaseHandler( env.Interface, testHandlerType[testReq, testResp]{ Title: "test", Path: "/path/to/api", - Persistence: testPersister[testReq, testResp]{}, + Persistence: capturingPersister[testReq, testResp]{}, Mode: libRequest.JSON, VerifyHeader: true, }, @@ -120,48 +165,122 @@ func TestBaseHandler(t *testing.T) { func TestBaseHandlerPersistenceUpdate(t *testing.T) { var updateCalled atomic.Bool + var lastUpdated *HandlerRequest[testReq, testResp] - 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, - }) + runPersistenceHandlerTest(t, testHandlerType[testReq, testResp]{ + Title: "test", + Path: "/path/to/api", + Persistence: capturingPersister[testReq, testResp]{updateCalled: &updateCalled, lastUpdated: &lastUpdated}, + Mode: libRequest.JSON, + VerifyHeader: true, + }, testReq{ID: "1"}, 200) if !updateCalled.Load() { t.Fatal("expected Persistence.Update to be called after successful Insert") } + if lastUpdated == nil { + t.Fatal("expected captured HandlerRequest from Update") + } + if lastUpdated.Outcome.Error != nil { + t.Fatalf("expected nil Outcome.Error on success, got %v", lastUpdated.Outcome.Error) + } + if lastUpdated.Outcome.HTTPStatus != http.StatusOK { + t.Fatalf("expected HTTPStatus 200, got %d", lastUpdated.Outcome.HTTPStatus) + } +} + +func TestBaseHandlerPersistenceUpdateInitializerFailure(t *testing.T) { + var updateCalled atomic.Bool + var lastUpdated *HandlerRequest[testReq, testResp] + initErr := libError.NewWithDescription(status.BadRequest, "INIT_FAIL", "initializer failed") + + runPersistenceHandlerTest(t, testHandlerType[testReq, testResp]{ + Title: "test", + Path: "/path/to/api", + Persistence: capturingPersister[testReq, testResp]{updateCalled: &updateCalled, lastUpdated: &lastUpdated}, + Mode: libRequest.JSON, + VerifyHeader: true, + InitErr: initErr, + }, testReq{ID: "1"}, http.StatusBadRequest) + + if !updateCalled.Load() { + t.Fatal("expected Update after initializer failure") + } + if lastUpdated.Outcome.Error == nil { + t.Fatal("expected Outcome.Error after initializer failure") + } + if lastUpdated.Outcome.HTTPStatus != http.StatusBadRequest { + t.Fatalf("expected HTTPStatus 400, got %d", lastUpdated.Outcome.HTTPStatus) + } +} + +func TestBaseHandlerPersistenceUpdateHandlerFailure(t *testing.T) { + var updateCalled atomic.Bool + var lastUpdated *HandlerRequest[testReq, testResp] + handlerErr := libError.NewWithDescription(status.BadRequest, "HANDLER_FAIL", "handler failed") + + runPersistenceHandlerTest(t, testHandlerType[testReq, testResp]{ + Title: "test", + Path: "/path/to/api", + Persistence: capturingPersister[testReq, testResp]{updateCalled: &updateCalled, lastUpdated: &lastUpdated}, + Mode: libRequest.JSON, + VerifyHeader: true, + HandlerErr: handlerErr, + }, testReq{ID: "1"}, http.StatusBadRequest) + + if !updateCalled.Load() { + t.Fatal("expected Update after handler failure") + } + if lastUpdated.Outcome.Error == nil { + t.Fatal("expected Outcome.Error after handler failure") + } + if lastUpdated.Outcome.HTTPStatus != http.StatusBadRequest { + t.Fatalf("expected HTTPStatus 400, got %d", lastUpdated.Outcome.HTTPStatus) + } +} + +func TestBaseHandlerPersistenceParseFailureSkipsUpdate(t *testing.T) { + var updateCalled atomic.Bool + + runPersistenceHandlerTest(t, testHandlerType[testReq, testResp]{ + Title: "test", + Path: "/path/to/api", + Persistence: capturingPersister[testReq, testResp]{updateCalled: &updateCalled}, + Mode: libRequest.JSON, + VerifyHeader: true, + }, map[string]any{"ss": "a"}, http.StatusBadRequest) + + if updateCalled.Load() { + t.Fatal("expected Update not to be called when parse fails before Insert") + } +} + +func TestBaseHandlerPersistenceUpdatePanic(t *testing.T) { + var updateCalled atomic.Bool + var lastUpdated *HandlerRequest[testReq, testResp] + + runPersistenceHandlerTest(t, testHandlerType[testReq, testResp]{ + Title: "test", + Path: "/path/to/api", + Persistence: capturingPersister[testReq, testResp]{updateCalled: &updateCalled, lastUpdated: &lastUpdated}, + Mode: libRequest.JSON, + VerifyHeader: true, + PanicInHandler: true, + }, testReq{ID: "1"}, http.StatusInternalServerError) + + if !updateCalled.Load() { + t.Fatal("expected Update on panic path") + } + if lastUpdated.Outcome.Error == nil { + t.Fatal("expected Outcome.Error on panic path") + } + if lastUpdated.Outcome.HTTPStatus != http.StatusInternalServerError { + t.Fatalf("expected HTTPStatus 500, got %d", lastUpdated.Outcome.HTTPStatus) + } } func TestBaseHandlerNoPersistence(t *testing.T) { - env := testingtools.GetEnvWithDB[testingtools.TestEnv]( - testingtools.SampleRequestModelMock(t, nil).DB, - testingtools.DefaultAPIList, - ) + env := testEnv(t) handler := BaseHandler( env.Interface, diff --git a/handlers/persistence.go b/handlers/persistence.go index eee1dcd..c86c821 100644 --- a/handlers/persistence.go +++ b/handlers/persistence.go @@ -1,5 +1,7 @@ package handlers +import "github.com/hmmftg/requestCore/webFramework" + // RequestPersister optionally persists request lifecycle data for a handler. // When HandlerParameters.Persistence is nil, insert/update are not called. // @@ -10,3 +12,36 @@ type RequestPersister[Req, Resp any] interface { Insert(path string, req *HandlerRequest[Req, Resp]) error Update(path string, req *HandlerRequest[Req, Resp]) error } + +const PersistedRecordIDKey = "handlers.persisted_record_id" + +func SetPersistedRecordID(w webFramework.WebFramework, id any) { + w.Parser.SetLocal(PersistedRecordIDKey, id) +} + +func GetPersistedRecordID(w webFramework.WebFramework) (any, bool) { + v := w.Parser.GetLocal(PersistedRecordIDKey) + if v == nil { + return nil, false + } + return v, true +} + +type FuncPersister[Req, Resp any] struct { + InsertFn func(path string, req *HandlerRequest[Req, Resp]) error + UpdateFn func(path string, req *HandlerRequest[Req, Resp]) error +} + +func (p FuncPersister[Req, Resp]) Insert(path string, req *HandlerRequest[Req, Resp]) error { + if p.InsertFn == nil { + return nil + } + return p.InsertFn(path, req) +} + +func (p FuncPersister[Req, Resp]) Update(path string, req *HandlerRequest[Req, Resp]) error { + if p.UpdateFn == nil { + return nil + } + return p.UpdateFn(path, req) +} diff --git a/handlers/persistence_test.go b/handlers/persistence_test.go new file mode 100644 index 0000000..b017dc0 --- /dev/null +++ b/handlers/persistence_test.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "context" + "log/slog" + "net/http" + "testing" + + "github.com/hmmftg/requestCore/webFramework" + "go.opentelemetry.io/otel/trace" +) + +type mapParser struct { + locals map[string]any +} + +func (p *mapParser) GetMethod() string { return http.MethodPost } +func (p *mapParser) GetPath() string { return "/" } +func (p *mapParser) GetHeader(webFramework.HeaderInterface) error { + return nil +} +func (p *mapParser) GetHeaderValue(string) string { return "" } +func (p *mapParser) GetHttpHeader() http.Header { return nil } +func (p *mapParser) GetBody(any) error { return nil } +func (p *mapParser) GetUri(any) error { return nil } +func (p *mapParser) GetUrlQuery(any) error { return nil } +func (p *mapParser) GetRawUrlQuery() string { return "" } +func (p *mapParser) GetLocal(name string) any { return p.locals[name] } +func (p *mapParser) GetLocalString(name string) string { return "" } +func (p *mapParser) GetUrlParam(string) string { return "" } +func (p *mapParser) GetUrlParams() map[string]string { return nil } +func (p *mapParser) CheckUrlParam(string) (string, bool) { return "", false } +func (p *mapParser) SetLocal(name string, value any) { p.locals[name] = value } +func (p *mapParser) SetReqHeader(string, string) {} +func (p *mapParser) SetRespHeader(string, string) {} +func (p *mapParser) GetArgs(...any) map[string]string { return nil } +func (p *mapParser) ParseCommand(string, string, webFramework.RecordData, webFramework.FieldParser) string { + return "" +} +func (p *mapParser) SendJSONRespBody(int, any) error { return nil } +func (p *mapParser) Next() error { return nil } +func (p *mapParser) Abort() error { return nil } +func (p *mapParser) FormValue(string) string { return "" } +func (p *mapParser) SaveFile(string, string) error { return nil } +func (p *mapParser) FileAttachment(string, string) {} +func (p *mapParser) AddCustomAttributes(slog.Attr) {} +func (p *mapParser) GetTraceContext() trace.SpanContext { + return trace.SpanContext{} +} +func (p *mapParser) SetTraceContext(trace.SpanContext) {} +func (p *mapParser) StartSpan(string, ...trace.SpanStartOption) (context.Context, trace.Span) { + return context.Background(), nil +} +func (p *mapParser) AddSpanAttribute(string, string) {} +func (p *mapParser) AddSpanAttributes(map[string]string) {} +func (p *mapParser) AddSpanEvent(string, map[string]string) {} +func (p *mapParser) RecordSpanError(error, map[string]string) {} +func (p *mapParser) GetContext() context.Context { return context.Background() } +func (p *mapParser) SetContext(context.Context) {} + +func TestPersistedRecordIDHelpers(t *testing.T) { + w := webFramework.WebFramework{ + Parser: &mapParser{locals: make(map[string]any)}, + } + + SetPersistedRecordID(w, int64(42)) + id, ok := GetPersistedRecordID(w) + if !ok { + t.Fatal("expected persisted record id to be present") + } + if id.(int64) != 42 { + t.Fatalf("expected id 42, got %v", id) + } + + SetPersistedRecordID(w, "uuid-abc") + id, ok = GetPersistedRecordID(w) + if !ok || id.(string) != "uuid-abc" { + t.Fatalf("expected uuid-abc, got %v ok=%v", id, ok) + } +} + +func TestFuncPersister(t *testing.T) { + var insertCalled, updateCalled bool + p := FuncPersister[testReq, testResp]{ + InsertFn: func(path string, req *HandlerRequest[testReq, testResp]) error { + insertCalled = true + if path != "/path" { + t.Fatalf("unexpected path %q", path) + } + return nil + }, + UpdateFn: func(path string, req *HandlerRequest[testReq, testResp]) error { + updateCalled = true + return nil + }, + } + + trx := &HandlerRequest[testReq, testResp]{} + if err := p.Insert("/path", trx); err != nil { + t.Fatalf("Insert: %v", err) + } + if err := p.Update("/path", trx); err != nil { + t.Fatalf("Update: %v", err) + } + if !insertCalled || !updateCalled { + t.Fatal("expected Insert and Update fns to be called") + } + + nop := FuncPersister[testReq, testResp]{} + if err := nop.Insert("/path", trx); err != nil { + t.Fatalf("nil InsertFn: %v", err) + } + if err := nop.Update("/path", trx); err != nil { + t.Fatalf("nil UpdateFn: %v", err) + } +} diff --git a/handlers/recovery.go b/handlers/recovery.go index b4fe150..26167c2 100644 --- a/handlers/recovery.go +++ b/handlers/recovery.go @@ -22,9 +22,39 @@ func Recovery[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( trx *HandlerRequest[Req, Resp], core requestCore.RequestCoreInterface, requestInserted bool, + panicVal any, ) { + elapsed := time.Since(start) + trx.Duration = elapsed webFramework.AddLogTag(w, webFramework.HandlerLogTag, slog.String("elapsed", elapsed.String())) + + var panicErr error + if panicVal != nil { + if params.RecoveryHandler != nil { + params.RecoveryHandler(panicVal) + } else { + slog.Error("panic recovered", slog.String("handler", params.Title), slog.Any("panic", panicVal)) + switch data := panicVal.(type) { + case error: + panicErr = errors.Join(data, + libError.NewWithDescription( + status.InternalServerError, + response.SYSTEM_FAULT, + "panic in %s", + params.Title, + )) + default: + panicErr = libError.NewWithDescription( + http.StatusInternalServerError, + response.SYSTEM_FAULT, + "panic in %s", + params.Title) + } + trx.SetOutcome(panicErr, http.StatusInternalServerError) + } + } + libTracing.TraceVoid(handler.Finalizer, *trx) webFramework.CollectLogTags(w, webFramework.HandlerLogTag) webFramework.CollectLogArrays(w, webFramework.HandlerLogTag) @@ -45,32 +75,10 @@ func Recovery[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( slog.Any("error", errUpdate)) } } - if r := recover(); r != nil { - if params.RecoveryHandler != nil { - params.RecoveryHandler(r) - } else { - // Log full panic value for debugging; client only sees SYSTEM_FAULT localized text. - slog.Error("panic recovered", slog.String("handler", params.Title), slog.Any("panic", r)) - switch data := r.(type) { - case error: - core.Responder().Error(w, - errors.Join(data, - libError.NewWithDescription( - status.InternalServerError, - response.SYSTEM_FAULT, - "panic in %s", - params.Title, - ))) - default: - core.Responder().Error(w, - libError.NewWithDescription( - http.StatusInternalServerError, - response.SYSTEM_FAULT, - "panic in %s", - params.Title), - ) - } + if panicVal != nil { + if panicErr != nil { + core.Responder().Error(w, panicErr) } - panic(r) + panic(panicVal) } } diff --git a/response/webHandler.go b/response/webHandler.go index 9d25287..17a2495 100644 --- a/response/webHandler.go +++ b/response/webHandler.go @@ -11,11 +11,23 @@ import ( "github.com/hmmftg/requestCore/webFramework" ) +const LastHTTPStatusLocal = "response.http_status" + type WebHanlder struct { MessageDesc map[string]string ErrorDesc map[string]string } +// LastHTTPStatus returns the HTTP status code recorded by the most recent respond call. +func LastHTTPStatus(w webFramework.WebFramework) int { + if v := w.Parser.GetLocal(LastHTTPStatusLocal); v != nil { + if code, ok := v.(int); ok { + return code + } + } + return 0 +} + func getError[Result error](err error) *Result { newError := new(Result) if errors.As(err, newError) { @@ -122,6 +134,7 @@ func (m WebHanlder) respond(data RespData, abort bool, w webFramework.WebFramewo var resp WsResponse resp.Status = data.Status + w.Parser.SetLocal(LastHTTPStatusLocal, data.Code) webFramework.AddLogTag(w, webFramework.HandlerLogTag, slog.Int("status", data.Code)) if data.Code == http.StatusOK { resp.Description = m.MessageDesc[data.Message]