From b3f0856f19bef20f44f8a349d0b13be76ee2dbf3 Mon Sep 17 00:00:00 2001 From: Hamid MalekMohammadi Date: Sat, 3 Oct 2026 12:44:59 +0330 Subject: [PATCH 1/2] feat(libRequest): add optional JSON body binding modes Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- handlers/README.md | 51 ++++++++++++ libContext/init_test.go | 71 +++++++++++++++++ libContext/test-context.go | 39 +++++++++- libFiber/parser.go | 4 + libFiber/parser_test.go | 37 +++++++++ libGin/parser.go | 9 ++- libGin/parser_test.go | 30 +++++++ libNetHttp/parser.go | 5 +- libNetHttp/parser_test.go | 54 +++++++++++++ libRequest/handler.go | 25 ++++++ libRequest/handler_test.go | 143 ++++++++++++++++++++++++++++++++++ libRequest/requestTypeEnum.go | 53 ++++++++----- webFramework/errors.go | 11 +++ webFramework/fakeParser.go | 20 ++++- 14 files changed, 523 insertions(+), 29 deletions(-) create mode 100644 webFramework/errors.go diff --git a/handlers/README.md b/handlers/README.md index 0b668f2..1ab953b 100644 --- a/handlers/README.md +++ b/handlers/README.md @@ -316,6 +316,57 @@ This allows consistent visibility across request pipelines. --- +# Request body binding modes + +`HandlerParameters[Req, Resp].Body` selects how `libRequest` binds the incoming +request into `Req`: + +| Mode | Body | URI params | Empty body | +|---|---|---|---| +| `libRequest.NoBinding` | — | — | accepted (nothing bound) | +| `libRequest.JSON` | required JSON | — | 400 `ERROR_IN_GET_REQUEST_BODY` | +| `libRequest.JSONWithURI` | required JSON | bound (mandatory) | 400 `ERROR_IN_GET_REQUEST_BODY` | +| `libRequest.JSONOptional` | optional JSON | — | accepted; zero-valued `Req` | +| `libRequest.JSONWithURIOptional` | optional JSON | bound (mandatory) | accepted; `Req` has URI fields only | +| `libRequest.Query` | query params | — | n/a | +| `libRequest.QueryWithURI` | query params | bound | n/a | +| `libRequest.QueryWithPagination` | query + pagination | — | n/a | +| `libRequest.URI` | — | bound (mandatory) | n/a | +| `libRequest.URIAndPagination` | — | bound + pagination | n/a | + +The optional modes are for endpoints whose contract allows `Content-Length: 0` — +e.g. `POST /api/card/:cardNumber/harim` takes all required input from the path and +only reads `cvv2`/`expiry` when a body is present: + +```go +handlers.HandlerParameters[Req, Resp]{ + Body: libRequest.JSONWithURIOptional, // body optional; URI params still bound +} +``` + +Notes: + +- **Validation still runs.** An absent body produces a zero-valued `Req`, which is + then passed through `libValidate.ValidateStruct` like any other request. Fields + that are truly optional should use `validate:"omitempty"` (or no `required` tag); + an empty body against a `required` body field fails with `VALIDATION_FAILED` (400). +- **URI binding stays mandatory** in `JSONWithURIOptional`. +- **Malformed bodies are still rejected** — only absent/empty bodies are tolerated; + a JSON syntax error is a 400. + +## `ErrEmptyBody` contract for `RequestParser` implementers + +Custom `webFramework.RequestParser` implementations must return +`webFramework.ErrEmptyBody` (wrapping is allowed; callers use `errors.Is`) from +`GetBody` when the request has no body or a zero-length body. Returning `nil` for +an empty body is wrong: it makes an absent body indistinguishable from a +successful bind, and the optional modes rely on the sentinel. The built-in +parsers (`libGin`, `libFiber`, `libNetHttp`) and test fakes +(`webFramework.FakeParser`, `libContext.TestingParser`) already honor the +contract. + +--- + # Request persistence (optional) Handlers support optional request/result persistence via `RequestPersister[Req, Resp]` on generic `HandlerParameters[Req, Resp]`. diff --git a/libContext/init_test.go b/libContext/init_test.go index f7f39cd..7f6f5d5 100644 --- a/libContext/init_test.go +++ b/libContext/init_test.go @@ -1,10 +1,12 @@ package libContext import ( + "errors" "net/http/httptest" "testing" "github.com/hmmftg/requestCore/libNetHttp" + "github.com/hmmftg/requestCore/webFramework" ) func TestInitNetHTTPContext(t *testing.T) { @@ -43,6 +45,75 @@ func TestInitNetHTTPContext(t *testing.T) { } } +func TestTestingParserGetBody(t *testing.T) { + type SampleBody struct { + ID string `json:"id"` + } + + t.Run("nil body returns ErrEmptyBody", func(t *testing.T) { + parser := TestingParser{} + var target SampleBody + err := parser.GetBody(&target) + if !errors.Is(err, webFramework.ErrEmptyBody) { + t.Fatalf("want ErrEmptyBody, got %v", err) + } + }) + + t.Run("configured BodyError takes precedence", func(t *testing.T) { + parser := TestingParser{BodyError: errors.New("boom")} + var target SampleBody + err := parser.GetBody(&target) + if err == nil || err.Error() != "boom" { + t.Fatalf("want configured BodyError, got %v", err) + } + }) + + t.Run("body fixture populates target", func(t *testing.T) { + parser := TestingParser{Body: SampleBody{ID: "42"}} + var target SampleBody + if err := parser.GetBody(&target); err != nil { + t.Fatal(err) + } + if target.ID != "42" { + t.Fatalf("want id 42, got %q", target.ID) + } + }) +} + +func TestTestingParserNilFixtures(t *testing.T) { + // nil header fixture must not panic; a non-nil fixture must bind. + bound := RequestHeaderStub{} + parser := TestingParser{Header: &RequestHeaderStub{ID: "7"}} + if err := parser.GetHeader(&bound); err != nil { + t.Fatal(err) + } + if bound.ID != "7" { + t.Fatalf("want id 7, got %q", bound.ID) + } + parser = TestingParser{} + if err := parser.GetHeader(&RequestHeaderStub{}); err != nil { + t.Fatal(err) + } + var target map[string]string + if err := parser.GetURI(&target); err != nil { + t.Fatal(err) + } +} + +type RequestHeaderStub struct { + ID string +} + +func (h *RequestHeaderStub) GetID() string { return h.ID } +func (h *RequestHeaderStub) GetUser() string { return h.ID } +func (h *RequestHeaderStub) GetProgram() string { return h.ID } +func (h *RequestHeaderStub) GetModule() string { return h.ID } +func (h *RequestHeaderStub) GetMethod() string { return h.ID } +func (h *RequestHeaderStub) SetUser(string) {} +func (h *RequestHeaderStub) SetProgram(string) {} +func (h *RequestHeaderStub) SetModule(string) {} +func (h *RequestHeaderStub) SetMethod(string) {} + func TestInitNetHTTPContextWithUnknownUser(t *testing.T) { // Create a test request req := httptest.NewRequest("GET", "/test", nil) diff --git a/libContext/test-context.go b/libContext/test-context.go index dfbc51e..7fb889f 100644 --- a/libContext/test-context.go +++ b/libContext/test-context.go @@ -81,12 +81,31 @@ func (t TestingParser) GetPath() string { func setTarget(target any, value any) { targetPtr := reflect.ValueOf(target) - targetPtr.Set(reflect.ValueOf(value)) + if targetPtr.Kind() != reflect.Ptr || targetPtr.IsNil() { + return + } + targetVal := targetPtr.Elem() + if !targetVal.CanSet() { + return + } + v := reflect.ValueOf(value) + if !v.IsValid() { + return + } + if v.Type().AssignableTo(targetVal.Type()) { + targetVal.Set(v) + return + } + if v.Kind() == reflect.Ptr && !v.IsNil() && v.Elem().Type().AssignableTo(targetVal.Type()) { + targetVal.Set(v.Elem()) + } } // GetHeader populates the target struct with the test header value. func (t TestingParser) GetHeader(target webFramework.HeaderInterface) error { - setTarget(target, t.Header) + if t.Header != nil { + setTarget(target, t.Header) + } return t.HeaderError } @@ -110,20 +129,32 @@ func (t TestingParser) GetHTTPHeader() http.Header { } // GetBody populates the target with the test request body. +// Returns webFramework.ErrEmptyBody when the test body fixture is nil or +// empty and no explicit BodyError is configured. func (t TestingParser) GetBody(target any) error { + if t.Body == nil { + if t.BodyError != nil { + return t.BodyError + } + return webFramework.ErrEmptyBody + } setTarget(target, t.Body) return t.BodyError } // GetURI populates the target with the test URI parameters. func (t TestingParser) GetURI(target any) error { - setTarget(target, t.Uri) + if t.Uri != nil { + setTarget(target, t.Uri) + } return t.UriError } // GetURLQuery populates the target with the test URL query parameters. func (t TestingParser) GetURLQuery(target any) error { - setTarget(target, t.UrlQuery) + if t.UrlQuery != nil { + setTarget(target, t.UrlQuery) + } return t.UrlQueryError } diff --git a/libFiber/parser.go b/libFiber/parser.go index 5dbd624..6cb7e24 100644 --- a/libFiber/parser.go +++ b/libFiber/parser.go @@ -48,7 +48,11 @@ func (c FiberParser) GetHeaderValue(name string) string { } // GetBody parses the request body into the target struct. +// Returns webFramework.ErrEmptyBody when the request body is empty. func (c FiberParser) GetBody(target any) error { + if len(c.Ctx.Body()) == 0 { + return webFramework.ErrEmptyBody + } return c.Ctx.BodyParser(target) } diff --git a/libFiber/parser_test.go b/libFiber/parser_test.go index ebe2318..ad2eb3f 100644 --- a/libFiber/parser_test.go +++ b/libFiber/parser_test.go @@ -2,6 +2,7 @@ package libFiber import ( "encoding/json" + "errors" "testing" "github.com/gofiber/fiber/v2" @@ -53,6 +54,42 @@ func TestGetBody(t *testing.T) { } } +func TestGetBody_Empty(t *testing.T) { + type SampleType struct { + ID string `json:"id"` + } + var target SampleType + app := fiber.New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + c.Request().Header.SetContentType(fiber.MIMEApplicationJSON) + defer app.ReleaseCtx(c) + ctx := InitContext(c) + err := ctx.GetBody(&target) + if !errors.Is(err, webFramework.ErrEmptyBody) { + t.Fatalf("want ErrEmptyBody, got %v", err) + } +} + +func TestGetBody_MalformedNotEmptyBody(t *testing.T) { + type SampleType struct { + ID string `json:"id"` + } + var target SampleType + app := fiber.New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + c.Request().Header.SetContentType(fiber.MIMEApplicationJSON) + c.Request().SetBody([]byte(`{invalid`)) + defer app.ReleaseCtx(c) + ctx := InitContext(c) + err := ctx.GetBody(&target) + if err == nil { + t.Fatal("expected error for malformed body") + } + if errors.Is(err, webFramework.ErrEmptyBody) { + t.Fatalf("malformed body must not map to ErrEmptyBody, got %v", err) + } +} + type Header struct { ID string `header:"id" reqHeader:"id" json:"id"` } diff --git a/libGin/parser.go b/libGin/parser.go index b2893f7..bd445aa 100644 --- a/libGin/parser.go +++ b/libGin/parser.go @@ -3,6 +3,8 @@ package libGin import ( "context" + "errors" + "io" "log/slog" "net/http" @@ -47,8 +49,13 @@ func (c GinParser) GetRawURLQuery() string { } // GetBody binds the JSON request body to the given target. +// Returns webFramework.ErrEmptyBody when the request body is empty. func (c GinParser) GetBody(target any) error { - return c.Ctx.ShouldBindJSON(target) + err := c.Ctx.ShouldBindJSON(target) + if errors.Is(err, io.EOF) { + return webFramework.ErrEmptyBody + } + return err } // GetURI binds URI parameters to the given target. diff --git a/libGin/parser_test.go b/libGin/parser_test.go index 4b907cf..6b6984d 100644 --- a/libGin/parser_test.go +++ b/libGin/parser_test.go @@ -2,6 +2,7 @@ package libGin import ( "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -49,6 +50,35 @@ func TestGetBody(t *testing.T) { } } +func TestGetBody_Empty(t *testing.T) { + type SampleType struct { + ID string `json:"id"` + } + var target SampleType + c := gin.Context{Request: &http.Request{Body: io.NopCloser(strings.NewReader(""))}} + ctx := InitContext(&c) + err := ctx.GetBody(&target) + if !errors.Is(err, webFramework.ErrEmptyBody) { + t.Fatalf("want ErrEmptyBody, got %v", err) + } +} + +func TestGetBody_MalformedNotEmptyBody(t *testing.T) { + type SampleType struct { + ID string `json:"id"` + } + var target SampleType + c := gin.Context{Request: &http.Request{Body: io.NopCloser(strings.NewReader(`{invalid`))}} + ctx := InitContext(&c) + err := ctx.GetBody(&target) + if err == nil { + t.Fatal("expected error for malformed body") + } + if errors.Is(err, webFramework.ErrEmptyBody) { + t.Fatalf("malformed body must not map to ErrEmptyBody, got %v", err) + } +} + type Header struct { ID string `header:"id" reqHeader:"id" json:"id"` } diff --git a/libNetHttp/parser.go b/libNetHttp/parser.go index c399b0b..174aaba 100644 --- a/libNetHttp/parser.go +++ b/libNetHttp/parser.go @@ -81,9 +81,10 @@ func (c NetHTTPParser) GetHTTPHeader() http.Header { } // GetBody reads and unmarshals the request body into the target. +// Returns webFramework.ErrEmptyBody when the request body is absent or empty. func (c NetHTTPParser) GetBody(target any) error { if c.Request.Body == nil { - return nil + return webFramework.ErrEmptyBody } body, err := io.ReadAll(c.Request.Body) @@ -92,7 +93,7 @@ func (c NetHTTPParser) GetBody(target any) error { } if len(body) == 0 { - return nil + return webFramework.ErrEmptyBody } return json.Unmarshal(body, target) diff --git a/libNetHttp/parser_test.go b/libNetHttp/parser_test.go index ea46791..77b5f6c 100644 --- a/libNetHttp/parser_test.go +++ b/libNetHttp/parser_test.go @@ -3,9 +3,14 @@ package libNetHttp import ( "context" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" + "strings" "testing" + + "github.com/hmmftg/requestCore/webFramework" ) func TestNetHTTPParser(t *testing.T) { @@ -87,6 +92,55 @@ func TestSetContext_PropagatesToGetContext(t *testing.T) { } } +func TestGetBody(t *testing.T) { + type SampleType struct { + ID string `json:"id"` + } + type TestCase struct { + Name string + Body io.ReadCloser + WantErrEmpty bool + WantOtherErr bool + WantID string + } + table := []TestCase{ + {Name: "nil body", Body: nil, WantErrEmpty: true}, + {Name: "empty body", Body: io.NopCloser(strings.NewReader("")), WantErrEmpty: true}, + {Name: "malformed body", Body: io.NopCloser(strings.NewReader(`{invalid`)), WantOtherErr: true}, + {Name: "valid body", Body: io.NopCloser(strings.NewReader(`{"id":"1"}`)), WantID: "1"}, + } + for _, v := range table { + t.Run(v.Name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/test", nil) + req.Body = v.Body + parser := InitContext(req, httptest.NewRecorder()) + + var target SampleType + err := parser.GetBody(&target) + switch { + case v.WantErrEmpty: + if !errors.Is(err, webFramework.ErrEmptyBody) { + t.Fatalf("want ErrEmptyBody, got %v", err) + } + case v.WantOtherErr: + if err == nil { + t.Fatal("expected error") + } + if errors.Is(err, webFramework.ErrEmptyBody) { + t.Fatalf("malformed body must not map to ErrEmptyBody, got %v", err) + } + default: + if err != nil { + t.Fatal(err) + } + if target.ID != v.WantID { + t.Fatalf("want id %q, got %q", v.WantID, target.ID) + } + } + }) + } +} + func TestMiddleware(t *testing.T) { // Test CORS middleware corsHandler := CORSMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/libRequest/handler.go b/libRequest/handler.go index ac31ada..16f9cf6 100644 --- a/libRequest/handler.go +++ b/libRequest/handler.go @@ -51,6 +51,10 @@ const ( URI // URIAndPagination indicates URI parameter and pagination binding. URIAndPagination + // JSONOptional indicates JSON body binding where an empty body is accepted. + JSONOptional + // JSONWithURIOptional indicates optional JSON body and URI parameter binding. + JSONWithURIOptional ) // PaginationLocalTag is the local-storage key for pagination data. @@ -155,6 +159,27 @@ func parseRequest[Req any](params ParseParams) (*ParseResult[Req], error) { desc = fmt.Sprintf(ErrorInGetRequest, "URI") err = libError.NewWithDescription(status.BadRequest, desc, "%s[GetURI](fails)", params.Name) } + case JSONOptional: + err = params.W.Parser.GetBody(&request) + if errors.Is(err, webFramework.ErrEmptyBody) { + err = nil + } else if err != nil { + desc = fmt.Sprintf(ErrorInGetRequest, "BODY") + err = libError.NewWithDescription(status.BadRequest, desc, "%s[GetBody](fails)", params.Name) + } + case JSONWithURIOptional: + err = params.W.Parser.GetBody(&request) + if errors.Is(err, webFramework.ErrEmptyBody) { + err = nil + } else if err != nil { + desc = fmt.Sprintf(ErrorInGetRequest, "BODY") + err = libError.NewWithDescription(status.BadRequest, desc, "%s[GetBody](fails)", params.Name) + } + errURI := params.W.Parser.GetURI(&request) + if errURI != nil { + desc = fmt.Sprintf(ErrorInGetRequest, "URI") + err = libError.NewWithDescription(status.BadRequest, desc, "%s[GetURI](fails)", params.Name) + } default: err = nil } diff --git a/libRequest/handler_test.go b/libRequest/handler_test.go index d53eb1d..7ebb051 100644 --- a/libRequest/handler_test.go +++ b/libRequest/handler_test.go @@ -15,6 +15,7 @@ import ( "github.com/hmmftg/requestCore/libContext" "github.com/hmmftg/requestCore/libError" "github.com/hmmftg/requestCore/libLogger" + "github.com/hmmftg/requestCore/status" "github.com/hmmftg/requestCore/webFramework" ) @@ -109,6 +110,148 @@ func TestGinReq_ValidationFailureSetsSlogRequestBody(t *testing.T) { } } +func newGinRequestCtx(body string, params map[string]string) *gin.Context { + c := &gin.Context{ + Request: &http.Request{ + Method: "POST", + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, + } + c.Request.Header.Add("Request-Id", "1111111111") + c.Request.Header.Add("User-Id", "tester") + for k, v := range params { + c.Params = append(c.Params, gin.Param{Key: k, Value: v}) + } + return c +} + +func assertErrorDescription(t *testing.T, err error, wantDesc string) { + t.Helper() + var errData libError.ErrorData + if !errors.As(err, &errData) { + t.Fatalf("expected libError.ErrorData, got %T (%v)", err, err) + } + if errData.ActionData.Description != wantDesc { + t.Fatalf("want description %q, got %q", wantDesc, errData.ActionData.Description) + } + if errData.ActionData.Status != status.BadRequest { + t.Fatalf("want status %d, got %d", status.BadRequest, errData.ActionData.Status) + } +} + +func TestGinReq_JSONOptional(t *testing.T) { + type optionalBody struct { + Cvv2 string `json:"cvv2" validate:"omitempty,len=4"` + Expiry string `json:"expiry" validate:"omitempty"` + } + type TestCase struct { + Name string + Body string + WantErr string + WantCvv2 string + } + table := []TestCase{ + {Name: "empty body accepted", Body: "", WantCvv2: ""}, + {Name: "valid body bound", Body: `{"cvv2":"1234","expiry":"2603"}`, WantCvv2: "1234"}, + {Name: "bound body still validated", Body: `{"cvv2":"12"}`, WantErr: "VALIDATION_FAILED"}, + {Name: "malformed body rejected", Body: `{invalid`, WantErr: "ERROR_IN_GET_REQUEST_BODY"}, + } + for _, v := range table { + t.Run(v.Name, func(t *testing.T) { + w := libContext.InitContext(newGinRequestCtx(v.Body, nil)) + + result, err := Req[optionalBody, RequestHeader](ParseParams{W: w, Mode: JSONOptional, ValidateHeader: false}) + if v.WantErr != "" { + assertErrorDescription(t, err, v.WantErr) + return + } + if err != nil { + t.Fatal(err) + } + if result.Request.Cvv2 != v.WantCvv2 { + t.Fatalf("want cvv2 %q, got %q", v.WantCvv2, result.Request.Cvv2) + } + }) + } +} + +func TestGinReq_JSONWithURIOptional(t *testing.T) { + type optionalBodyWithURI struct { + CardNumber string `uri:"cardNumber" validate:"required"` + Cvv2 string `json:"cvv2" validate:"omitempty"` + } + type TestCase struct { + Name string + Body string + Params map[string]string + WantErr string + WantCardNumber string + WantCvv2 string + } + table := []TestCase{ + { + Name: "empty body with uri param accepted", + Body: "", + Params: map[string]string{"cardNumber": "5022291044441234"}, + WantCardNumber: "5022291044441234", + }, + { + Name: "body and uri param bound", + Body: `{"cvv2":"1234"}`, + Params: map[string]string{"cardNumber": "5022291044441234"}, + WantCardNumber: "5022291044441234", + WantCvv2: "1234", + }, + { + Name: "missing uri param rejected", + Body: "", + Params: nil, + WantErr: "VALIDATION_FAILED", + }, + { + Name: "malformed body rejected", + Body: `{invalid`, + Params: map[string]string{"cardNumber": "5022291044441234"}, + WantErr: "ERROR_IN_GET_REQUEST_BODY", + }, + } + for _, v := range table { + t.Run(v.Name, func(t *testing.T) { + w := libContext.InitContext(newGinRequestCtx(v.Body, v.Params)) + + result, err := Req[optionalBodyWithURI, RequestHeader](ParseParams{W: w, Mode: JSONWithURIOptional, ValidateHeader: false}) + if v.WantErr != "" { + assertErrorDescription(t, err, v.WantErr) + return + } + if err != nil { + t.Fatal(err) + } + if result.Request.CardNumber != v.WantCardNumber { + t.Fatalf("want cardNumber %q, got %q", v.WantCardNumber, result.Request.CardNumber) + } + if result.Request.Cvv2 != v.WantCvv2 { + t.Fatalf("want cvv2 %q, got %q", v.WantCvv2, result.Request.Cvv2) + } + }) + } +} + +func TestGinReq_RequiredModesRejectEmptyBody(t *testing.T) { + type sampleBody struct { + ID string `json:"id"` + } + for _, mode := range []Type{JSON, JSONWithURI} { + t.Run(mode.String(), func(t *testing.T) { + w := libContext.InitContext(newGinRequestCtx("", map[string]string{"id": "1"})) + + _, err := Req[sampleBody, RequestHeader](ParseParams{W: w, Mode: mode, ValidateHeader: false}) + assertErrorDescription(t, err, "ERROR_IN_GET_REQUEST_BODY") + }) + } +} + func TestFiberReq(t *testing.T) { type TestCase struct { Name string diff --git a/libRequest/requestTypeEnum.go b/libRequest/requestTypeEnum.go index 7d13328..6794896 100644 --- a/libRequest/requestTypeEnum.go +++ b/libRequest/requestTypeEnum.go @@ -8,11 +8,11 @@ import ( "strings" ) -const _TypeName = "NoBindingJSONJSONWithURIQueryQueryWithURIQueryWithPaginationURIURIAndPagination" +const _TypeName = "NoBindingJSONJSONWithURIQueryQueryWithURIQueryWithPaginationURIURIAndPaginationJSONOptionalJSONWithURIOptional" -var _TypeIndex = [...]uint8{0, 9, 13, 24, 29, 41, 60, 63, 79} +var _TypeIndex = [...]uint8{0, 9, 13, 24, 29, 41, 60, 63, 79, 91, 110} -const _TypeLowerName = "nobindingjsonjsonwithuriqueryquerywithuriquerywithpaginationuriuriandpagination" +const _TypeLowerName = "nobindingjsonjsonwithuriqueryquerywithuriquerywithpaginationuriuriandpaginationjsonoptionaljsonwithurioptional" func (i Type) String() string { if i < 0 || i >= Type(len(_TypeIndex)-1) { @@ -33,27 +33,36 @@ func _TypeNoOp() { _ = x[QueryWithPagination-(5)] _ = x[URI-(6)] _ = x[URIAndPagination-(7)] + _ = x[JSONOptional-(8)] + _ = x[JSONWithURIOptional-(9)] } -var _TypeValues = []Type{NoBinding, JSON, JSONWithURI, Query, QueryWithURI, QueryWithPagination, URI, URIAndPagination} +var _TypeValues = []Type{NoBinding, JSON, JSONWithURI, Query, QueryWithURI, QueryWithPagination, URI, URIAndPagination, JSONOptional, JSONWithURIOptional} var _TypeNameToValueMap = map[string]Type{ - _TypeName[0:9]: NoBinding, - _TypeLowerName[0:9]: NoBinding, - _TypeName[9:13]: JSON, - _TypeLowerName[9:13]: JSON, - _TypeName[13:24]: JSONWithURI, - _TypeLowerName[13:24]: JSONWithURI, - _TypeName[24:29]: Query, - _TypeLowerName[24:29]: Query, - _TypeName[29:41]: QueryWithURI, - _TypeLowerName[29:41]: QueryWithURI, - _TypeName[41:60]: QueryWithPagination, - _TypeLowerName[41:60]: QueryWithPagination, - _TypeName[60:63]: URI, - _TypeLowerName[60:63]: URI, - _TypeName[63:79]: URIAndPagination, - _TypeLowerName[63:79]: URIAndPagination, + _TypeName[0:9]: NoBinding, + _TypeName[9:13]: JSON, + _TypeName[13:24]: JSONWithURI, + _TypeName[24:29]: Query, + _TypeName[29:41]: QueryWithURI, + _TypeName[41:60]: QueryWithPagination, + _TypeName[60:63]: URI, + _TypeName[63:79]: URIAndPagination, + _TypeName[79:91]: JSONOptional, + _TypeName[91:110]: JSONWithURIOptional, +} + +var _TypeLowerNameToValueMap = map[string]Type{ + _TypeLowerName[0:9]: NoBinding, + _TypeLowerName[9:13]: JSON, + _TypeLowerName[13:24]: JSONWithURI, + _TypeLowerName[24:29]: Query, + _TypeLowerName[29:41]: QueryWithURI, + _TypeLowerName[41:60]: QueryWithPagination, + _TypeLowerName[60:63]: URI, + _TypeLowerName[63:79]: URIAndPagination, + _TypeLowerName[79:91]: JSONOptional, + _TypeLowerName[91:110]: JSONWithURIOptional, } var _TypeNames = []string{ @@ -65,6 +74,8 @@ var _TypeNames = []string{ _TypeName[41:60], _TypeName[60:63], _TypeName[63:79], + _TypeName[79:91], + _TypeName[91:110], } // TypeString retrieves an enum value from the enum constants string name. @@ -74,7 +85,7 @@ func TypeString(s string) (Type, error) { return val, nil } - if val, ok := _TypeNameToValueMap[strings.ToLower(s)]; ok { + if val, ok := _TypeLowerNameToValueMap[strings.ToLower(s)]; ok { return val, nil } return 0, fmt.Errorf("%s does not belong to Type values", s) diff --git a/webFramework/errors.go b/webFramework/errors.go new file mode 100644 index 0000000..d982f27 --- /dev/null +++ b/webFramework/errors.go @@ -0,0 +1,11 @@ +package webFramework + +import "errors" + +// ErrEmptyBody is returned by RequestParser.GetBody when the request has +// no body or a body of length zero. It is distinguishable from a +// successful bind: optional binding modes (libRequest.JSONOptional and +// libRequest.JSONWithURIOptional) treat it as success, while +// required-body modes (libRequest.JSON and libRequest.JSONWithURI) +// report it as ERROR_IN_GET_REQUEST_BODY. +var ErrEmptyBody = errors.New("empty request body") diff --git a/webFramework/fakeParser.go b/webFramework/fakeParser.go index 388df4d..e7ed0f2 100644 --- a/webFramework/fakeParser.go +++ b/webFramework/fakeParser.go @@ -19,6 +19,7 @@ type FakeParser struct { ReqHeader map[string]string RespHeader map[string]string Body any + BodyError error URI any URLQuery any RawUrlQuery string @@ -53,9 +54,26 @@ func (f FakeParser) GetHTTPHeader() http.Header { return f.HttpHeader } -// GetBody is a no-op that always returns nil for the fake parser. +// GetBody returns the configured fake body error, or ErrEmptyBody when +// the fake body is nil or empty. A non-empty body is a no-op that +// returns nil, matching the fake parser's historical behavior. func (f FakeParser) GetBody(target any) error { _ = target + if f.BodyError != nil { + return f.BodyError + } + switch body := f.Body.(type) { + case nil: + return ErrEmptyBody + case string: + if len(body) == 0 { + return ErrEmptyBody + } + case []byte: + if len(body) == 0 { + return ErrEmptyBody + } + } return nil } From 72330c4e6cb3d6772f8549c766dff5386f4896bb Mon Sep 17 00:00:00 2001 From: Hamid Malek Mohammadi Date: Sat, 3 Oct 2026 12:57:40 +0330 Subject: [PATCH 2/2] test(libContext): detect empty string and byte slice as ErrEmptyBody in TestingParser Extend TestingParser.GetBody to return webFramework.ErrEmptyBody for empty string and empty byte slice fixtures, matching the nil body behavior. Add table-driven test cases for both empty string and empty []byte. Replace if-nil check with type switch to handle string/[]byte length checks before setTarget. Preserve existing precedence: BodyError takes priority over empty-body detection. --- go.work.sum | 15 +++++++++++++++ libContext/init_test.go | 14 ++++++++++++++ libContext/test-context.go | 19 ++++++++++++++----- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/go.work.sum b/go.work.sum index 0045cbe..f494bd6 100644 --- a/go.work.sum +++ b/go.work.sum @@ -44,10 +44,25 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/libContext/init_test.go b/libContext/init_test.go index 7f6f5d5..81c7ea6 100644 --- a/libContext/init_test.go +++ b/libContext/init_test.go @@ -59,6 +59,20 @@ func TestTestingParserGetBody(t *testing.T) { } }) + for name, body := range map[string]any{ + "empty string returns ErrEmptyBody": "", + "empty byte slice returns ErrEmptyBody": []byte{}, + } { + t.Run(name, func(t *testing.T) { + parser := TestingParser{Body: body} + var target SampleBody + err := parser.GetBody(&target) + if !errors.Is(err, webFramework.ErrEmptyBody) { + t.Fatalf("want ErrEmptyBody, got %v", err) + } + }) + } + t.Run("configured BodyError takes precedence", func(t *testing.T) { parser := TestingParser{BodyError: errors.New("boom")} var target SampleBody diff --git a/libContext/test-context.go b/libContext/test-context.go index 7fb889f..8f434c7 100644 --- a/libContext/test-context.go +++ b/libContext/test-context.go @@ -132,14 +132,23 @@ func (t TestingParser) GetHTTPHeader() http.Header { // Returns webFramework.ErrEmptyBody when the test body fixture is nil or // empty and no explicit BodyError is configured. func (t TestingParser) GetBody(target any) error { - if t.Body == nil { - if t.BodyError != nil { - return t.BodyError - } + if t.BodyError != nil { + return t.BodyError + } + switch body := t.Body.(type) { + case nil: return webFramework.ErrEmptyBody + case string: + if len(body) == 0 { + return webFramework.ErrEmptyBody + } + case []byte: + if len(body) == 0 { + return webFramework.ErrEmptyBody + } } setTarget(target, t.Body) - return t.BodyError + return nil } // GetURI populates the target with the test URI parameters.