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]