diff --git a/go.mod b/go.mod index c3af6e81c3de5b40d39a91d6f911aac9a9542fc8..a5fdb90f73c080d2d74fd4738f725dc9ed913699 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitlab.sessia.com/sdk/api-response-request go 1.23.3 require ( + github.com/go-playground/validator/v10 v10.26.0 github.com/gofiber/fiber/v2 v2.52.6 github.com/stretchr/testify v1.10.0 ) @@ -10,8 +11,12 @@ require ( require ( github.com/andybalholm/brotli v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -19,6 +24,9 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.59.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index adee7327cf18a59f1db3b83b15d350f00e96f1f8..f66153b47d55747fff8b99f34485ff0129ca3553 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,24 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -27,9 +39,15 @@ github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDp github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/request/filter.go b/request/filter.go index 2fbdcc47b0dfa38b485cf90d609a987962a79f59..bed03674d12af3401b5fb91866313e573da667c3 100644 --- a/request/filter.go +++ b/request/filter.go @@ -1,95 +1,38 @@ package request import ( - "fmt" "net/url" "strings" - "time" ) -type FilterOperation interface { - Field() string - Operation() string -} - -type SimpleFilter struct { - FieldName string - Op string +type FilterOperation struct { + Field string + Operation string Value string } -func (f SimpleFilter) Field() string { return f.FieldName } -func (f SimpleFilter) Operation() string { return f.Op } - -type RangeFilter struct { - FieldName string - Range DateRange -} - -func (r RangeFilter) Field() string { return r.FieldName } -func (r RangeFilter) Operation() string { return "range" } - -type DateRange struct { - Begin *time.Time - End *time.Time -} - type FilterSet struct { Operations []FilterOperation } func (fs *FilterSet) ParseFromQueries(values url.Values) error { - const layout = time.RFC3339 - - rangeMap := make(map[string]*RangeFilter) - for key, valArr := range values { if !strings.HasPrefix(key, "filter[") || !strings.HasSuffix(key, "]") || len(valArr) == 0 { continue } trimmed := strings.TrimSuffix(strings.TrimPrefix(key, "filter["), "]") - parts := strings.Split(trimmed, "][") - - switch len(parts) { - case 2: - fs.Operations = append(fs.Operations, SimpleFilter{ - FieldName: parts[0], - Op: parts[1], - Value: valArr[0], - }) - case 3: - field := parts[0] - op := parts[1] - bound := parts[2] - - if op != "range" { - continue - } - - rf, exists := rangeMap[field] - if !exists { - rf = &RangeFilter{FieldName: field} - rangeMap[field] = rf - } - - parsedTime, err := time.Parse(layout, valArr[0]) - if err != nil { - return fmt.Errorf("invalid time format for field %s %s: %w", field, bound, err) - } - switch bound { - case "begin": - rf.Range.Begin = &parsedTime - case "end": - rf.Range.End = &parsedTime - } + parts := strings.Split(trimmed, "][") + if len(parts) != 2 { + continue } - } - for _, rf := range rangeMap { - fs.Operations = append(fs.Operations, rf) + fs.Operations = append(fs.Operations, FilterOperation{ + Field: parts[0], + Operation: parts[1], + Value: valArr[0], + }) } - return nil } diff --git a/request/filter_test.go b/request/filter_test.go index 91b971bca6aaf450fbc8df95f56d12970c098de2..ffd0fcae8fcdcd1d152d9637b9ac8959f4101e12 100644 --- a/request/filter_test.go +++ b/request/filter_test.go @@ -3,65 +3,86 @@ package request import ( "net/url" "testing" - "time" "github.com/stretchr/testify/assert" ) -func TestFilterSet_ParseFromQueries(t *testing.T) { +func TestFilterSet_ParseFromQueries_CreatedAtGte(t *testing.T) { + values := url.Values{} + values.Set("filter[createdAt][gte]", "2024-01-01") + + var fs FilterSet + err := fs.ParseFromQueries(values) + + assert.NoError(t, err) + assert.Len(t, fs.Operations, 1) + assert.Contains(t, fs.Operations, FilterOperation{ + Field: "createdAt", + Operation: "gte", + Value: "2024-01-01", + }) +} + +func TestFilterSet_ParseFromQueries_CreatedAtLte(t *testing.T) { + values := url.Values{} + values.Set("filter[createdAt][lte]", "2024-02-01") + + var fs FilterSet + err := fs.ParseFromQueries(values) + + assert.NoError(t, err) + assert.Len(t, fs.Operations, 1) + assert.Contains(t, fs.Operations, FilterOperation{ + Field: "createdAt", + Operation: "lte", + Value: "2024-02-01", + }) +} + +func TestFilterSet_ParseFromQueries_ApproveEq(t *testing.T) { + values := url.Values{} + values.Set("filter[approve][eq]", "true") + + var fs FilterSet + err := fs.ParseFromQueries(values) + + assert.NoError(t, err) + assert.Len(t, fs.Operations, 1) + assert.Contains(t, fs.Operations, FilterOperation{ + Field: "approve", + Operation: "eq", + Value: "true", + }) +} + +func TestFilterSet_ParseFromQueries_AmountLte(t *testing.T) { values := url.Values{} - values.Set("filter[createdAt][gte]", "2024-01-01T00:00:00Z") values.Set("filter[amount][lte]", "1000") + + var fs FilterSet + err := fs.ParseFromQueries(values) + + assert.NoError(t, err) + assert.Len(t, fs.Operations, 1) + assert.Contains(t, fs.Operations, FilterOperation{ + Field: "amount", + Operation: "lte", + Value: "1000", + }) +} + +func TestFilterSet_ParseFromQueries_StatusEq(t *testing.T) { + values := url.Values{} values.Set("filter[status][eq]", "approved") - values.Set("filter[decisionEvaluatedAt][range][begin]", "2025-05-01T00:00:00Z") - values.Set("filter[decisionEvaluatedAt][range][end]", "2025-05-02T00:00:00Z") var fs FilterSet err := fs.ParseFromQueries(values) assert.NoError(t, err) - assert.Len(t, fs.Operations, 4) - - var foundCreatedAt, foundAmount, foundStatus, foundRange bool - - for _, op := range fs.Operations { - switch f := op.(type) { - case SimpleFilter: - switch f.FieldName { - case "createdAt": - assert.Equal(t, "gte", f.Op) - assert.Equal(t, "2024-01-01T00:00:00Z", f.Value) - foundCreatedAt = true - case "amount": - assert.Equal(t, "lte", f.Op) - assert.Equal(t, "1000", f.Value) - foundAmount = true - case "status": - assert.Equal(t, "eq", f.Op) - assert.Equal(t, "approved", f.Value) - foundStatus = true - default: - t.Errorf("Unexpected SimpleFilter field: %s", f.FieldName) - } - case *RangeFilter: - assert.Equal(t, "decisionEvaluatedAt", f.FieldName) - assert.NotNil(t, f.Range.Begin) - assert.NotNil(t, f.Range.End) - - beginExpected, _ := time.Parse(time.RFC3339, "2025-05-01T00:00:00Z") - endExpected, _ := time.Parse(time.RFC3339, "2025-05-02T00:00:00Z") - - assert.True(t, f.Range.Begin.Equal(beginExpected)) - assert.True(t, f.Range.End.Equal(endExpected)) - - foundRange = true - default: - t.Errorf("Unknown filter operation type %T", op) - } - } - - assert.True(t, foundCreatedAt, "createdAt filter not found") - assert.True(t, foundAmount, "amount filter not found") - assert.True(t, foundStatus, "status filter not found") - assert.True(t, foundRange, "range filter not found") + assert.Len(t, fs.Operations, 1) + assert.Contains(t, fs.Operations, FilterOperation{ + Field: "status", + Operation: "eq", + Value: "approved", + }) } diff --git a/request/group_test.go b/request/group_test.go index b748d36ac109b1f4db3836c7dd6033a1e466ddac..705edb3f533635b510a71abc86b0f3e77502e442 100644 --- a/request/group_test.go +++ b/request/group_test.go @@ -1,26 +1,37 @@ package request import ( - "github.com/stretchr/testify/assert" "net/url" "testing" + + "github.com/stretchr/testify/assert" ) -func TestGroupingSet_ParseFromQueries(t *testing.T) { +func TestGroupingSet_ParseFromQueries_CreatedAtMonth(t *testing.T) { values := url.Values{} values.Set("group[createdAt]", "month") - values.Set("group[status]", "raw") var gs GroupingSet err := gs.ParseFromQueries(values) assert.NoError(t, err) - assert.Len(t, gs.Groups, 2) + assert.Len(t, gs.Groups, 1) assert.Contains(t, gs.Groups, GroupingMethod{ Field: "createdAt", Method: "month", }) +} + +func TestGroupingSet_ParseFromQueries_StatusRaw(t *testing.T) { + values := url.Values{} + values.Set("group[status]", "raw") + + var gs GroupingSet + err := gs.ParseFromQueries(values) + + assert.NoError(t, err) + assert.Len(t, gs.Groups, 1) assert.Contains(t, gs.Groups, GroupingMethod{ Field: "status", diff --git a/request/pagination.go b/request/pagination.go new file mode 100644 index 0000000000000000000000000000000000000000..fccf52dfc7c99b294ecb8f50e88434649dac0ba3 --- /dev/null +++ b/request/pagination.go @@ -0,0 +1,21 @@ +package request + +import "github.com/go-playground/validator/v10" + +type Pagination struct { + Page int `query:"page" validate:"min=1"` + PageSize int `query:"page_size" validate:"min=1,max=100"` +} + +func (p *Pagination) Validate() error { + if p.Page == 0 { + p.Page = 1 + } + + if p.PageSize == 0 { + p.PageSize = 10 + } + + validate := validator.New() + return validate.Struct(p) +} diff --git a/request/pagination_test.go b/request/pagination_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b6f46738a6c087c7cf7e17b57f77700cd229d517 --- /dev/null +++ b/request/pagination_test.go @@ -0,0 +1,69 @@ +package request + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPagination_Validate_DefaultValues(t *testing.T) { + p := Pagination{Page: 0, PageSize: 0} + + err := p.Validate() + + assert.NoError(t, err) + assert.Equal(t, 1, p.Page) + assert.Equal(t, 10, p.PageSize) +} + +func TestPagination_Validate_ValidInput(t *testing.T) { + p := Pagination{Page: 2, PageSize: 50} + + err := p.Validate() + + assert.NoError(t, err) + assert.Equal(t, 2, p.Page) + assert.Equal(t, 50, p.PageSize) +} + +func TestPagination_Validate_PageLessThanMinimum(t *testing.T) { + p := Pagination{Page: 0, PageSize: 10} + + err := p.Validate() + + assert.NoError(t, err) + assert.Equal(t, 1, p.Page) + assert.Equal(t, 10, p.PageSize) +} + +func TestPagination_Validate_PageSizeLessThanMinimum(t *testing.T) { + p := Pagination{Page: 1, PageSize: 0} + + err := p.Validate() + + assert.NoError(t, err) + assert.Equal(t, 1, p.Page) + assert.Equal(t, 10, p.PageSize) +} + +func TestPagination_Validate_PageSizeTooLarge(t *testing.T) { + p := Pagination{Page: 1, PageSize: 101} + + err := p.Validate() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "PageSize") + assert.Equal(t, 1, p.Page) + assert.Equal(t, 101, p.PageSize) +} + +func TestPagination_Validate_PageNegative(t *testing.T) { + p := Pagination{Page: -1, PageSize: 10} + + err := p.Validate() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "Page") + assert.Equal(t, -1, p.Page) + assert.Equal(t, 10, p.PageSize) +} diff --git a/response/model.go b/response/error.go similarity index 64% rename from response/model.go rename to response/error.go index b0ff9af4d88783ed54cbc852d0d292fd796b5b36..5171d2bc38fc22c11a796e44cf327e638d1bc9d3 100644 --- a/response/model.go +++ b/response/error.go @@ -1,39 +1,6 @@ package response -import ( - "github.com/gofiber/fiber/v2" - "math" -) - -func NewResponse(c *fiber.Ctx, data any) error { - return c.JSON(data) -} - -type PaginationResponse struct { - Data interface{} `json:"data"` - Pagination Pagination `json:"pagination"` -} - -type Pagination struct { - TotalPages int `json:"totalPages"` - CurrentPage int `json:"currentPage"` - TotalItems int64 `json:"totalItems"` - PageSize int `json:"pageSize"` -} - -func NewPaginationResponse(c *fiber.Ctx, data any, page int, pageSize int, total int64) error { - totalPages := int(math.Ceil(float64(total) / float64(pageSize))) - - return c.JSON(PaginationResponse{ - Data: data, - Pagination: Pagination{ - TotalPages: totalPages, - CurrentPage: page, - TotalItems: total, - PageSize: pageSize, - }, - }) -} +import "github.com/gofiber/fiber/v2" type ErrorResponse struct { Code CodeErrorResponse `json:"code"` diff --git a/response/pagination.go b/response/pagination.go new file mode 100644 index 0000000000000000000000000000000000000000..6534e93c13a3fce55c280cd019767022b01bc3cb --- /dev/null +++ b/response/pagination.go @@ -0,0 +1,32 @@ +package response + +import ( + "github.com/gofiber/fiber/v2" + "math" +) + +type PaginationResponse struct { + Data interface{} `json:"data"` + Pagination Pagination `json:"pagination"` +} + +type Pagination struct { + TotalPages int `json:"totalPages"` + CurrentPage int `json:"currentPage"` + TotalItems int64 `json:"totalItems"` + PageSize int `json:"pageSize"` +} + +func NewPaginationResponse(c *fiber.Ctx, data any, page int, pageSize int, total int64) error { + totalPages := int(math.Ceil(float64(total) / float64(pageSize))) + + return c.JSON(PaginationResponse{ + Data: data, + Pagination: Pagination{ + TotalPages: totalPages, + CurrentPage: page, + TotalItems: total, + PageSize: pageSize, + }, + }) +} diff --git a/response/simple.go b/response/simple.go new file mode 100644 index 0000000000000000000000000000000000000000..7085c12530c2aaef26e75bd3ce406ddf896c823e --- /dev/null +++ b/response/simple.go @@ -0,0 +1,9 @@ +package response + +import ( + "github.com/gofiber/fiber/v2" +) + +func NewResponse(c *fiber.Ctx, data any) error { + return c.JSON(data) +}