Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
51 changes: 51 additions & 0 deletions handlers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`.
Expand Down
85 changes: 85 additions & 0 deletions libContext/init_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -43,6 +45,89 @@ 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)
}
})

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
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)
Expand Down
50 changes: 45 additions & 5 deletions libContext/test-context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -110,20 +129,41 @@ 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.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.
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
}

Expand Down
4 changes: 4 additions & 0 deletions libFiber/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
37 changes: 37 additions & 0 deletions libFiber/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package libFiber

import (
"encoding/json"
"errors"
"testing"

"github.com/gofiber/fiber/v2"
Expand Down Expand Up @@ -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"`
}
Expand Down
9 changes: 8 additions & 1 deletion libGin/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package libGin

import (
"context"
"errors"
"io"
"log/slog"
"net/http"

Expand Down Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions libGin/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package libGin

import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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"`
}
Expand Down
Loading
Loading