diff --git a/README.md b/README.md index f4b4387..70d19c4 100644 --- a/README.md +++ b/README.md @@ -252,13 +252,13 @@ Override settings for specific operations: routes := route.DefaultApiRoutes() getConfig := configuration.DefaultRouteConfiguration(). - SerializationGroups("read", "public"). + InputSerializationGroups("read", "public"). ItemPerPage(100) routes.Get.Configure(getConfig) postConfig := configuration.DefaultRouteConfiguration(). - SerializationGroups("write") + InputSerializationGroups("write") routes.Post.Configure(postConfig) @@ -457,8 +457,8 @@ go test ./test/router/... ## Roadmap ### TODO/ IDEAS +- [ ] Add random configuration to clarify behavior, suggest best practices and allow flexibility (right now clarifying backup configuration) - [ ] Filtering implementation -- [ ] Groups override parameter - [ ] UUID compatibility for entity.ID - [ ] Force lowercase option for JSON keys - [ ] Automatic Redis caching integration in router @@ -474,16 +474,6 @@ go test ./test/router/... - [ ] Graphql like PageInfo object after, before, first, last, pageof -### Completed -- [x] MongoDB repository implementation -- [x] Redis caching library (manual usage) -- [x] XML serialization -- [x] CSV serialization -- [x] MessagePack support -- [x] Subresource routing -- [x] Batch operations -- [x] JSON-LD with Hydra collections - ## License MIT License - see [LICENSE](LICENSE) file for details diff --git a/configuration/README.md b/configuration/README.md index 04c3888..cd1d455 100644 --- a/configuration/README.md +++ b/configuration/README.md @@ -7,6 +7,7 @@ The Configuration package provides the default behavior for ApiRouter by definin ## Purpose When processing requests (e.g., `GET /api/item`), ApiRouter determines how to respond (e.g., with `GetList`) based on configuration settings for: +- router global behavior - Sorting - Pagination - Filtering diff --git a/configuration/configuration.go b/configuration/configuration.go index 9cf71a2..ac5a5b6 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -20,9 +20,12 @@ const ( // Value in seconds. Default: 0 (no caching) NetworkCachingPolicyType - // SerializationGroupsType defines which field groups to include in serialization - // Used with struct tags: `groups:"read,write"` - SerializationGroupsType + // InputSerializationGroupsType defines which field groups to include in serialization + // Used with struct tags: `groups:"read,read_public,read_plus"` + InputSerializationGroupsType + + // OutputSerializationGroupsType defines which field groups to include in output serialization + OutputSerializationGroupsType // MaxItemPerPageType sets the maximum allowed items per page (default: 1000) // Prevents clients from requesting too many items at once @@ -70,12 +73,24 @@ const ( // Whitelist to prevent sorting on sensitive or non-indexed fields SortableFieldsType - GroupOverwriteClientControlType // Allows clients to overwrite serialization groups - GroupOverwriteParameterNameType // Query parameter name for overwriting serialization groups + OutputSerializationGroupOverwriteClientControlType // Allows clients to overwrite serialization groups + OutputSerializationGroupOverwriteParameterNameType // Query parameter name for overwriting serialization groups // Unimplemented configuration types - reserved for future use - BatchLimitType // Will limit the number of items in batch operations - TypeEnabledType // Will enable/disable specific route types + + // Whether write routes default to read output serialization + //seems weird at first but what should be the output of POST if POST has no specific output serialization groups configured? + //logicaly it should be the same as GET (read) + //this set as true allows this behavior for POST, PUT, PATCH routes + WriteRouteOutputShouldDefaultToReadOutputType + + // Whether batch routes default to single entity route configuration + // Batch route configurations (e.g., BatchGet, BatchPatch) can be configured dirrectly or otherwise fallback to router wide configuration + // this will allow then to fallback to single entity route configuration + BatchRouteConfigurationDefaultToSingleRouteConfigurationType + + BatchLimitType // Will limit the number of items in batch operations ... + FormatEnabledType // Will enable/disable specific format DefaultFilteringType // Will add default filters to queries InMemoryCachingPolicyType // Will configure in-memory caching ) @@ -130,9 +145,13 @@ func RouteName(name string) Configuration { // // Example: // -// configuration.SerializationGroups("read", "public") -func SerializationGroups(groups ...string) Configuration { - return Configuration{Type: SerializationGroupsType, Values: groups} +// configuration.InputSerializationGroups("read", "public") +func InputSerializationGroups(groups ...string) Configuration { + return Configuration{Type: InputSerializationGroupsType, Values: groups} +} + +func OutputSerializationGroups(groups ...string) Configuration { + return Configuration{Type: OutputSerializationGroupsType, Values: groups} } // MaxItemPerPage sets the maximum allowed items per page. Default is 1000. @@ -257,3 +276,19 @@ func SortingClientControl(enabled bool) Configuration { func SortableFields(fields ...string) Configuration { return Configuration{Type: SortableFieldsType, Values: fields} } + +func OutputSerializationGroupOverwriteClientControl(enabled bool) Configuration { + return Configuration{Type: OutputSerializationGroupOverwriteClientControlType, Values: []string{strconv.FormatBool(enabled)}} +} + +func OutputSerializationGroupOverwriteParameterName(name string) Configuration { + return Configuration{Type: OutputSerializationGroupOverwriteParameterNameType, Values: []string{name}} +} + +func WriteRouteOutputShouldDefaultToReadOutput(enabled bool) Configuration { + return Configuration{Type: WriteRouteOutputShouldDefaultToReadOutputType, Values: []string{strconv.FormatBool(enabled)}} +} + +func BatchRouteConfigurationDefaultToSingleRouteConfiguration(enabled bool) Configuration { + return Configuration{Type: BatchRouteConfigurationDefaultToSingleRouteConfigurationType, Values: []string{strconv.FormatBool(enabled)}} +} diff --git a/configuration/default_configuration.go b/configuration/default_configuration.go index b7aefe1..e69be3d 100644 --- a/configuration/default_configuration.go +++ b/configuration/default_configuration.go @@ -3,9 +3,12 @@ package configuration // DefaultConfiguration returns the default configuration map used by an ApiRouter. func DefaultConfiguration() map[ConfigurationType]Configuration { return map[ConfigurationType]Configuration{ - RoutePrefixType: RoutePrefix("api"), - NetworkCachingPolicyType: NetworkCachingPolicy(0), - SerializationGroupsType: SerializationGroups(), + RoutePrefixType: RoutePrefix("api"), + NetworkCachingPolicyType: NetworkCachingPolicy(0), + + InputSerializationGroupsType: InputSerializationGroups(), + OutputSerializationGroupsType: OutputSerializationGroups(), + PaginationType: Pagination(true), PageParameterNameType: PageParameterName("page"), PaginationClientControlType: PaginationClientControl(false), @@ -19,5 +22,12 @@ func DefaultConfiguration() map[ConfigurationType]Configuration { SortingType: Sorting(map[string]string{"id": "asc"}), SortingParameterNameType: SortingParameterName("sort"), SortableFieldsType: SortableFields("id"), + + OutputSerializationGroupOverwriteClientControlType: OutputSerializationGroupOverwriteClientControl(false), + OutputSerializationGroupOverwriteParameterNameType: OutputSerializationGroupOverwriteParameterName("groupOverwrite"), + + //not implemented yet + WriteRouteOutputShouldDefaultToReadOutputType: WriteRouteOutputShouldDefaultToReadOutput(true), + BatchRouteConfigurationDefaultToSingleRouteConfigurationType: BatchRouteConfigurationDefaultToSingleRouteConfiguration(true), } } diff --git a/example/custom_serialization_test.go b/example/custom_serialization_test.go index fc19425..a2fec30 100644 --- a/example/custom_serialization_test.go +++ b/example/custom_serialization_test.go @@ -31,7 +31,7 @@ func (u User) SetId(id any) entity.Entity { u.Id = entity.CastId(id) return u } -func (u User) ToEntity() User { return u } +func (u User) ToEntity() User { return u } func (u User) FromEntity(e User) any { return e } func getSerializationDB() *gorm.DB { @@ -57,7 +57,7 @@ func TestCustomSerialization(t *testing.T) { userRouter := router.NewApiRouter( *orm.NewORM(gormrepository.NewRepository[User](db)), routes, - configuration.SerializationGroups("read", "public"), + configuration.InputSerializationGroups("read", "public"), ) userRouter.AllowRoutes(r) diff --git a/example/router_conf_test.go b/example/router_conf_test.go index c23e3e9..464dd4b 100644 --- a/example/router_conf_test.go +++ b/example/router_conf_test.go @@ -35,7 +35,7 @@ func TestRouterConfiguration(t *testing.T) { route.DefaultApiRoutes(), configuration.RoutePrefix("api"), configuration.NetworkCachingPolicy(0), - configuration.SerializationGroups(), + configuration.InputSerializationGroups(), configuration.Pagination(true), configuration.PaginationClientControl(false), configuration.ItemPerPage(100), diff --git a/example/serialization_groups_test.go b/example/serialization_groups_test.go new file mode 100644 index 0000000..fec6d06 --- /dev/null +++ b/example/serialization_groups_test.go @@ -0,0 +1,135 @@ +package example_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/philiphil/restman/configuration" + "github.com/philiphil/restman/orm" + "github.com/philiphil/restman/orm/entity" + "github.com/philiphil/restman/orm/gormrepository" + "github.com/philiphil/restman/route" + "github.com/philiphil/restman/router" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +//Serialization groups are an easy way to control which fields are included in API responses and requests based on context. +// In this example, we define "read" and "write" groups to manage field visibility during GET and POST/PUT operations respectively. + +// With SerializationGroups configuration , as with any configuration we can set router-wide defaults that can be overridden on a operation level +// we also provide client control over group overwriting via query parameters. + +type SerialProduct struct { + entity.BaseEntity + Name string `json:"name" groups:"read,write"` + Price float64 `json:"price" groups:"write"` + InternalSKU string `json:"internal_sku" groups:"read"` //should be never editable + CostPrice float64 `json:"cost_price" groups:"private_stuff"` //should be never visible via api +} + +func (p SerialProduct) GetId() entity.ID { return p.Id } +func (p SerialProduct) SetId(id any) entity.Entity { + p.Id = entity.CastId(id) + return p +} +func (p SerialProduct) ToEntity() SerialProduct { return p } +func (p SerialProduct) FromEntity(e SerialProduct) any { return e } + +func getSerialProductDB() *gorm.DB { + db, err := gorm.Open(sqlite.Open("file:serial_product_test?mode=memory&cache=shared&_fk=1"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + CreateBatchSize: 1000, + }) + if err != nil { + panic(err) + } + return db +} + +func TestSerializationGroupsReadWrite(t *testing.T) { + db := getSerialProductDB() + db.AutoMigrate(&SerialProduct{}) + + r := gin.New() + r.Use(gin.Recovery()) + + routes := route.DefaultApiRoutes() + routes[route.Post].Configuration[configuration.InputSerializationGroupsType] = configuration.InputSerializationGroups("write") + + productRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[SerialProduct](db)), + routes, + configuration.OutputSerializationGroups("read"), //default router-wide serialization group should be "read" + ) + + productRouter.AllowRoutes(r) + + postData := SerialProduct{ + Name: "Laptop", + Price: 999.99, + CostPrice: 700.00, + InternalSKU: "SKU-IGNORED", + } + jsonData, _ := json.Marshal(postData) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/serial_product", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d. Body: %s", w.Code, w.Body.String()) + } + + var createdProduct SerialProduct + db.First(&createdProduct, 1) + + if createdProduct.Name != "Laptop" { + t.Errorf("Name should have been written, got: %s", createdProduct.Name) + } + + if createdProduct.Price != 999.99 { + t.Errorf("Price should have been written to DB, got: %f", createdProduct.Price) + } + + if createdProduct.InternalSKU == "SKU-IGNORED" { + t.Error("InternalSKU should not have been written (not in write group)") + } + + if createdProduct.CostPrice == 700.00 { + t.Error("CostPrice should not have been written (not in write group)") + } + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/api/serial_product/1", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response SerialProduct + json.Unmarshal(w.Body.Bytes(), &response) + + if response.Name != "Laptop" { + t.Error("Name should be visible in GET response (read group)") + } + + if response.Price != 0 { + t.Errorf("Price should NOT be visible in GET response (write group only), got: %f", response.Price) + } + + if response.InternalSKU != "" { + t.Errorf("InternalSKU should be empty in GET (read group but never written), got: %s", response.InternalSKU) + } + + if response.CostPrice != 0 { + t.Errorf("CostPrice should be zero in GET (private_stuff group not included), got: %f", response.CostPrice) + } +} diff --git a/go.mod b/go.mod index 71a6472..202c2f7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,8 @@ go 1.24.0 require ( github.com/gin-gonic/gin v1.11.0 github.com/go-redis/redismock/v9 v9.2.0 - github.com/redis/go-redis/v9 v9.14.0 + github.com/redis/go-redis/v9 v9.16.0 + github.com/vmihailenco/msgpack/v5 v5.4.1 go.mongodb.org/mongo-driver v1.17.4 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.0 @@ -16,22 +17,21 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/golang/snappy v0.0.4 // indirect - github.com/klauspost/compress v1.16.7 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/onsi/gomega v1.38.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.0 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.uber.org/mock v0.5.0 // indirect - golang.org/x/mod v0.28.0 // indirect + go.uber.org/mock v0.6.0 // indirect + golang.org/x/mod v0.29.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/tools v0.38.0 // indirect ) require ( @@ -42,7 +42,7 @@ require ( github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -56,11 +56,11 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - golang.org/x/arch v0.21.0 // indirect - golang.org/x/crypto v0.42.0 // indirect - golang.org/x/exp v0.0.0-20250911091902-df9299821621 - golang.org/x/net v0.44.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 + golang.org/x/net v0.46.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/router/apirouter.go b/router/apirouter.go index aa58f8d..94c29c4 100644 --- a/router/apirouter.go +++ b/router/apirouter.go @@ -7,7 +7,6 @@ import ( "github.com/gin-gonic/gin" "github.com/philiphil/restman/configuration" - "github.com/philiphil/restman/errors" "github.com/philiphil/restman/orm" "github.com/philiphil/restman/orm/entity" "github.com/philiphil/restman/route" @@ -112,30 +111,6 @@ func ConvertToSnakeCase(input string) string { return builder.String() } -// This function return either the router wide configuration or the route specific configuration -// If the routeType is not provided, it will return the router wide configuration -// If the routeType is provided, it will return the route specific configuration -// error is returned if the configuration is not found -// by default error should always be nil if you use NewApiRouter -func (r *ApiRouter[T]) GetConfiguration(configurationType configuration.ConfigurationType, routeType ...route.RouteType) (configuration.Configuration, error) { - routerValue, found := r.Configuration[configurationType] - if len(routeType) == 1 { - for _, route_ := range r.Routes { - if route_.RouteType == routeType[0] { - routeValue, exists := route_.Configuration[configurationType] - if exists { - return routeValue, nil - } - } - } - } - if !found { - return routerValue, errors.ApiError{Code: errors.ErrInternal.Code, Message: errors.ErrInternal.Message} - } - - return routerValue, nil -} - // NewApiRouter is a function that creates a new ApiRouter // it should be the default way of creating an ApiRouter because it sets the default configuration func NewApiRouter[T entity.Entity](orm orm.ORM[T], routes map[route.RouteType]route.Route, conf ...configuration.Configuration) *ApiRouter[T] { diff --git a/router/batchGet.go b/router/batchGet.go index e9cb5b2..00e95af 100644 --- a/router/batchGet.go +++ b/router/batchGet.go @@ -1,65 +1,12 @@ package router import ( - "strings" - "github.com/gin-gonic/gin" - "github.com/philiphil/restman/configuration" "github.com/philiphil/restman/errors" "github.com/philiphil/restman/orm/entity" "github.com/philiphil/restman/route" ) -// IsBatchGetOrGetList determines whether the request is a BatchGet or GetList operation. -// BatchGet returns a list of objects using given ids, while GetList returns paginated results. -func (r *ApiRouter[T]) IsBatchGetOrGetList(c *gin.Context) route.RouteType { - //first if BatchGet or GetList is not allowed, it is not a BatchGet or GetList - if _, ok := r.Routes[route.BatchGet]; !ok { - return route.GetList - } - if _, ok := r.Routes[route.GetList]; !ok { - return route.BatchGet - } - - ids, _ := r.GetConfiguration(configuration.BatchIdsParameterNameType, route.BatchGet) - idsParameter := ids.Values[0] - exists := false - if _, exists = c.GetQuery(idsParameter); !exists { - _, exists = c.GetQuery(idsParameter + "[]") - } - if exists { - return route.BatchGet - } - return route.GetList -} - -// GetListOrBatchGet routes the request to either BatchGet or GetList based on query parameters. -func (r *ApiRouter[T]) GetListOrBatchGet(c *gin.Context) { - rr := r.IsBatchGetOrGetList(c) - if rr == route.BatchGet { - r.BatchGet(c) - } else { - r.GetList(c) - } -} - -// GetIds extracts the list of IDs from query parameters for batch operations. -// Supports both array notation (ids[]=1&ids[]=2) and comma-separated (ids=1,2,3). -func (r *ApiRouter[T]) GetIds(c *gin.Context) []string { - ids, _ := r.GetConfiguration(configuration.BatchIdsParameterNameType, route.BatchGet) - idsParameter := ids.Values[0] - exists := false - if _, exists = c.GetQuery(idsParameter + "[]"); exists { - return c.QueryArray(idsParameter + "[]") - } - - idsValues := c.QueryArray(ids.Values[0]) - if len(idsValues) == 1 && len(strings.Split(idsValues[0], ",")) > 1 { - return strings.Split(idsValues[0], ",") - } - return idsValues -} - // BatchGet handles GET requests for multiple entities by their IDs. func (r *ApiRouter[T]) BatchGet(c *gin.Context) { idsValues := r.GetIds(c) @@ -87,7 +34,7 @@ func (r *ApiRouter[T]) BatchGet(c *gin.Context) { return } - groups, err := r.GetConfiguration(configuration.SerializationGroupsType, route.Get) + groups, err := r.GetEffectiveOutputSerializationGroups(c, route.BatchGet) if err != nil { c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return @@ -96,6 +43,6 @@ func (r *ApiRouter[T]) BatchGet(c *gin.Context) { c.Render(200, SerializerRenderer{ Data: objects, Format: responseFormat, - Groups: groups.Values, + Groups: groups, }) } diff --git a/router/batchPatch.go b/router/batchPatch.go index 7131795..aa6731d 100644 --- a/router/batchPatch.go +++ b/router/batchPatch.go @@ -73,7 +73,7 @@ func (r *ApiRouter[T]) BatchPatch(c *gin.Context) { return } - groups, err := r.GetConfiguration(configuration.SerializationGroupsType, route.Post) + groups, err := r.GetConfiguration(configuration.InputSerializationGroupsType, route.Post) if err != nil { c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return diff --git a/router/batchPut.go b/router/batchPut.go index e8f74e0..33d0a13 100644 --- a/router/batchPut.go +++ b/router/batchPut.go @@ -63,7 +63,7 @@ func (r *ApiRouter[T]) BatchPut(c *gin.Context) { return } - groups, err := r.GetConfiguration(configuration.SerializationGroupsType, route.Post) + groups, err := r.GetConfiguration(configuration.InputSerializationGroupsType, route.Post) if err != nil { c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return diff --git a/router/get.go b/router/get.go index 3435924..0ae2fb2 100644 --- a/router/get.go +++ b/router/get.go @@ -2,7 +2,6 @@ package router import ( "github.com/gin-gonic/gin" - "github.com/philiphil/restman/configuration" "github.com/philiphil/restman/errors" "github.com/philiphil/restman/route" ) @@ -27,7 +26,7 @@ func (r *ApiRouter[T]) Get(c *gin.Context) { return } - groups, err := r.GetConfiguration(configuration.SerializationGroupsType, route.Get) + groups, err := r.GetEffectiveOutputSerializationGroups(c, route.Get) if err != nil { c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return @@ -36,6 +35,6 @@ func (r *ApiRouter[T]) Get(c *gin.Context) { c.Render(200, SerializerRenderer{ Data: object, Format: responseFormat, - Groups: groups.Values, + Groups: groups, }) } diff --git a/router/getConfiguration.go b/router/getConfiguration.go new file mode 100644 index 0000000..1382bc3 --- /dev/null +++ b/router/getConfiguration.go @@ -0,0 +1,79 @@ +package router + +import ( + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/philiphil/restman/configuration" + "github.com/philiphil/restman/errors" + "github.com/philiphil/restman/route" +) + +// This function return either the router wide configuration or the route specific configuration +// If the routeType is not provided, it will return the router wide configuration +// If the routeType is provided, it will return the route specific configuration +// error is returned if the configuration is not found +// by default error should always be nil if you use NewApiRouter +func (r *ApiRouter[T]) GetConfiguration(configurationType configuration.ConfigurationType, routeType ...route.RouteType) (configuration.Configuration, error) { + routerValue, found := r.Configuration[configurationType] + if len(routeType) == 1 { + for _, route_ := range r.Routes { + if route_.RouteType == routeType[0] { + routeValue, exists := route_.Configuration[configurationType] + if exists { + return routeValue, nil + } + } + } + } + if !found { + return routerValue, errors.ApiError{Code: errors.ErrInternal.Code, Message: errors.ErrInternal.Message} + } + + return routerValue, nil +} + +func (r *ApiRouter[T]) IsOutputSerializationGroupOverwriteEnabled(c *gin.Context) (bool, error) { + groupOverwriteConf, err := r.GetConfiguration(configuration.OutputSerializationGroupOverwriteClientControlType, route.GetList) + if err != nil { + return false, err + } + return strconv.ParseBool(c.DefaultQuery(groupOverwriteConf.Values[0], "false")) +} + +func (r *ApiRouter[T]) GetOverwriteGroups(c *gin.Context, routeType route.RouteType) ([]string, error) { + groupsParamConf, err := r.GetConfiguration(configuration.OutputSerializationGroupOverwriteParameterNameType, routeType) + if err != nil { + return nil, err + } + groupsParam := c.Query(groupsParamConf.Values[0]) + if groupsParam == "" { + return []string{}, nil + } + groups := strings.Split(groupsParam, ",") + return groups, nil +} + +func (r *ApiRouter[T]) GetEffectiveOutputSerializationGroups(c *gin.Context, routeType route.RouteType) ([]string, error) { + groups, err := r.GetConfiguration(configuration.OutputSerializationGroupsType, routeType) + if err != nil { + return nil, err + } + effectiveGroups := groups.Values + + enabled, err := r.IsOutputSerializationGroupOverwriteEnabled(c) + if err != nil { + return nil, err + } + if enabled { + overwriteGroups, err := r.GetOverwriteGroups(c, routeType) + if err != nil { + return nil, err + } + if len(overwriteGroups) > 0 { + effectiveGroups = overwriteGroups + } + } + return effectiveGroups, nil +} diff --git a/router/getConfiguration_List.go b/router/getConfiguration_List.go new file mode 100644 index 0000000..f83de19 --- /dev/null +++ b/router/getConfiguration_List.go @@ -0,0 +1,184 @@ +package router + +import ( + "slices" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/philiphil/restman/configuration" + "github.com/philiphil/restman/errors" + "github.com/philiphil/restman/route" +) + +//this is for list and batch configurations + +// IsPaginationEnabled determines whether pagination should be enabled for the current request based on configuration and query parameters. +func (r *ApiRouter[T]) IsPaginationEnabled(c *gin.Context) (bool, error) { + paginationConf, err := r.GetConfiguration(configuration.PaginationType, route.GetList) + if err != nil { + return false, err + } + forcedPaginationConf, err := r.GetConfiguration(configuration.PaginationClientControlType, route.GetList) + if err != nil { + return false, err + } + clientCanForcePaginationUsingParameter, err := strconv.ParseBool(forcedPaginationConf.Values[0]) + if err != nil { + return false, err + } + basepaginationBool, err := strconv.ParseBool(paginationConf.Values[0]) + if err != nil { + return false, err + } + if !clientCanForcePaginationUsingParameter { + return basepaginationBool, nil + } + forcedParameterConf, err := r.GetConfiguration(configuration.PaginationParameterNameType, route.GetList) + if err != nil { + return false, err + } + return strconv.ParseBool(c.DefaultQuery(forcedParameterConf.Values[0], paginationConf.Values[0])) +} + +// GetPage extracts the page number from the request query parameters and returns it as a zero-indexed value. +func (r *ApiRouter[T]) GetPage(c *gin.Context) (int, error) { + pageParameter, err := r.GetConfiguration(configuration.PageParameterNameType, route.GetList) + if err != nil { + return 0, err + } + page, err := strconv.Atoi(c.DefaultQuery(pageParameter.Values[0], "1")) + if err != nil { + return 0, err + } + return page - 1, nil +} + +// GetItemPerPage extracts the items per page value from request query parameters, enforcing the configured maximum limit. +func (r *ApiRouter[T]) GetItemPerPage(c *gin.Context) (int, error) { + defaultItemPerPage, err := r.GetConfiguration(configuration.ItemPerPageType, route.GetList) + if err != nil { + return 0, err + } + maxItemPerPage, err := r.GetConfiguration(configuration.MaxItemPerPageType, route.GetList) + if err != nil { + return 0, err + } + itemPerPageParameter, err := r.GetConfiguration(configuration.ItemPerPageParameterNameType, route.GetList) + if err != nil { + return 0, err + } + itemPerPage, err := strconv.Atoi(c.DefaultQuery(itemPerPageParameter.Values[0], defaultItemPerPage.Values[0])) + if err != nil { + return 0, err + } + maxItemPerPageValue, err := strconv.Atoi(maxItemPerPage.Values[0]) + if itemPerPage > maxItemPerPageValue { + itemPerPage = maxItemPerPageValue + } + return itemPerPage, err +} + +// GetSortOrder extracts sorting parameters from the request, validating against allowed fields and returning a map of field names to sort directions. +func (r *ApiRouter[T]) GetSortOrder(c *gin.Context) (map[string]string, error) { + sortParams := make(map[string]string) + + sortEnabled, err := r.GetConfiguration(configuration.SortingClientControlType, route.GetList) + if err != nil { + return nil, err + } + enabled, parseErr := strconv.ParseBool(sortEnabled.Values[0]) + if parseErr != nil { + return nil, parseErr + } + + // I should replace this by a default map[string]string + defaultSortOrder, err := r.GetConfiguration(configuration.SortingType, route.GetList) + if err != nil { + return nil, err + } + + for i := 0; i < len(defaultSortOrder.Values); i += 2 { + if i+1 < len(defaultSortOrder.Values) { + sortParams[defaultSortOrder.Values[i]] = defaultSortOrder.Values[i+1] + } + } + + if !enabled { + return sortParams, nil + } + + // get the sort paramter name and allowed fields for sorting + sortParam, err := r.GetConfiguration(configuration.SortingParameterNameType, route.GetList) + if err != nil { + return nil, err + } + SortableFields, err := r.GetConfiguration(configuration.SortableFieldsType, route.GetList) + if err != nil { + return nil, err + } + + queryParams := c.QueryMap(sortParam.Values[0]) + for field, order := range queryParams { + order = strings.ToUpper(order) + if (order != "ASC" && order != "DESC") || !slices.Contains(SortableFields.Values, field) { + return nil, errors.ErrBadRequest + } + sortParams[field] = order + } + if len(sortParams) == 0 { + sortParams["id"] = defaultSortOrder.Values[0] + } + + return sortParams, nil +} + +// IsBatchGetOrGetList determines whether the request is a BatchGet or GetList operation. +// BatchGet returns a list of objects using given ids, while GetList returns paginated results. +func (r *ApiRouter[T]) IsBatchGetOrGetList(c *gin.Context) route.RouteType { + //first if BatchGet or GetList is not allowed, it is not a BatchGet or GetList + if _, ok := r.Routes[route.BatchGet]; !ok { + return route.GetList + } + if _, ok := r.Routes[route.GetList]; !ok { + return route.BatchGet + } + + ids, _ := r.GetConfiguration(configuration.BatchIdsParameterNameType, route.BatchGet) + idsParameter := ids.Values[0] + exists := false + if _, exists = c.GetQuery(idsParameter); !exists { + _, exists = c.GetQuery(idsParameter + "[]") + } + if exists { + return route.BatchGet + } + return route.GetList +} + +// GetListOrBatchGet routes the request to either BatchGet or GetList based on query parameters. +func (r *ApiRouter[T]) GetListOrBatchGet(c *gin.Context) { + rr := r.IsBatchGetOrGetList(c) + if rr == route.BatchGet { + r.BatchGet(c) + } else { + r.GetList(c) + } +} + +// GetIds extracts the list of IDs from query parameters for batch operations. +// Supports both array notation (ids[]=1&ids[]=2) and comma-separated (ids=1,2,3). +func (r *ApiRouter[T]) GetIds(c *gin.Context) []string { + ids, _ := r.GetConfiguration(configuration.BatchIdsParameterNameType, route.BatchGet) + idsParameter := ids.Values[0] + exists := false + if _, exists = c.GetQuery(idsParameter + "[]"); exists { + return c.QueryArray(idsParameter + "[]") + } + + idsValues := c.QueryArray(ids.Values[0]) + if len(idsValues) == 1 && len(strings.Split(idsValues[0], ",")) > 1 { + return strings.Split(idsValues[0], ",") + } + return idsValues +} diff --git a/router/getList.go b/router/getList.go index 44f8ba9..6655805 100644 --- a/router/getList.go +++ b/router/getList.go @@ -1,137 +1,12 @@ package router import ( - "slices" - "strconv" - "strings" - "github.com/gin-gonic/gin" - "github.com/philiphil/restman/configuration" "github.com/philiphil/restman/errors" "github.com/philiphil/restman/format" "github.com/philiphil/restman/route" ) -// IsPaginationEnabled determines whether pagination should be enabled for the current request based on configuration and query parameters. -func (r *ApiRouter[T]) IsPaginationEnabled(c *gin.Context) (bool, error) { - paginationConf, err := r.GetConfiguration(configuration.PaginationType, route.GetList) - if err != nil { - return false, err - } - forcedPaginationConf, err := r.GetConfiguration(configuration.PaginationClientControlType, route.GetList) - if err != nil { - return false, err - } - clientCanForcePaginationUsingParameter, err := strconv.ParseBool(forcedPaginationConf.Values[0]) - if err != nil { - return false, err - } - basepaginationBool, err := strconv.ParseBool(paginationConf.Values[0]) - if err != nil { - return false, err - } - if !clientCanForcePaginationUsingParameter { - return basepaginationBool, nil - } - forcedParameterConf, err := r.GetConfiguration(configuration.PaginationParameterNameType, route.GetList) - if err != nil { - return false, err - } - return strconv.ParseBool(c.DefaultQuery(forcedParameterConf.Values[0], paginationConf.Values[0])) -} - -// GetPage extracts the page number from the request query parameters and returns it as a zero-indexed value. -func (r *ApiRouter[T]) GetPage(c *gin.Context) (int, error) { - pageParameter, err := r.GetConfiguration(configuration.PageParameterNameType, route.GetList) - if err != nil { - return 0, err - } - page, err := strconv.Atoi(c.DefaultQuery(pageParameter.Values[0], "1")) - if err != nil { - return 0, err - } - return page - 1, nil -} - -// GetItemPerPage extracts the items per page value from request query parameters, enforcing the configured maximum limit. -func (r *ApiRouter[T]) GetItemPerPage(c *gin.Context) (int, error) { - defaultItemPerPage, err := r.GetConfiguration(configuration.ItemPerPageType, route.GetList) - if err != nil { - return 0, err - } - maxItemPerPage, err := r.GetConfiguration(configuration.MaxItemPerPageType, route.GetList) - if err != nil { - return 0, err - } - itemPerPageParameter, err := r.GetConfiguration(configuration.ItemPerPageParameterNameType, route.GetList) - if err != nil { - return 0, err - } - itemPerPage, err := strconv.Atoi(c.DefaultQuery(itemPerPageParameter.Values[0], defaultItemPerPage.Values[0])) - if err != nil { - return 0, err - } - maxItemPerPageValue, err := strconv.Atoi(maxItemPerPage.Values[0]) - if itemPerPage > maxItemPerPageValue { - itemPerPage = maxItemPerPageValue - } - return itemPerPage, err -} - -// GetSortOrder extracts sorting parameters from the request, validating against allowed fields and returning a map of field names to sort directions. -func (r *ApiRouter[T]) GetSortOrder(c *gin.Context) (map[string]string, error) { - sortParams := make(map[string]string) - - sortEnabled, err := r.GetConfiguration(configuration.SortingClientControlType, route.GetList) - if err != nil { - return nil, err - } - enabled, parseErr := strconv.ParseBool(sortEnabled.Values[0]) - if parseErr != nil { - return nil, parseErr - } - - // I should replace this by a default map[string]string - defaultSortOrder, err := r.GetConfiguration(configuration.SortingType, route.GetList) - if err != nil { - return nil, err - } - - for i := 0; i < len(defaultSortOrder.Values); i += 2 { - if i+1 < len(defaultSortOrder.Values) { - sortParams[defaultSortOrder.Values[i]] = defaultSortOrder.Values[i+1] - } - } - - if !enabled { - return sortParams, nil - } - - // get the sort paramter name and allowed fields for sorting - sortParam, err := r.GetConfiguration(configuration.SortingParameterNameType, route.GetList) - if err != nil { - return nil, err - } - SortableFields, err := r.GetConfiguration(configuration.SortableFieldsType, route.GetList) - if err != nil { - return nil, err - } - - queryParams := c.QueryMap(sortParam.Values[0]) - for field, order := range queryParams { - order = strings.ToUpper(order) - if (order != "ASC" && order != "DESC") || !slices.Contains(SortableFields.Values, field) { - return nil, errors.ErrBadRequest - } - sortParams[field] = order - } - if len(sortParams) == 0 { - sortParams["id"] = defaultSortOrder.Values[0] - } - - return sortParams, nil -} - // GetList handles HTTP GET requests to retrieve a collection of entities with optional pagination and sorting. func (r *ApiRouter[T]) GetList(c *gin.Context) { paginate, err := r.IsPaginationEnabled(c) @@ -161,7 +36,7 @@ func (r *ApiRouter[T]) GetList(c *gin.Context) { return } - groups, err := r.GetConfiguration(configuration.SerializationGroupsType, route.GetList) + groups, err := r.GetEffectiveOutputSerializationGroups(c, route.GetList) if err != nil { c.AbortWithStatusJSON(errors.ErrInternal.Code, errors.ErrInternal.Message) return @@ -190,7 +65,7 @@ func (r *ApiRouter[T]) GetList(c *gin.Context) { SerializerRenderer{ Data: JsonldCollection(objects, c.Request.URL.String(), page+1, params, int((count+int64(itemPerPage)-1)/int64(itemPerPage))), Format: responseFormat, - Groups: groups.Values, + Groups: groups, }, ) return @@ -206,7 +81,7 @@ func (r *ApiRouter[T]) GetList(c *gin.Context) { SerializerRenderer{ Data: objects, Format: responseFormat, - Groups: groups.Values, + Groups: groups, }) } diff --git a/router/head.go b/router/head.go index 7f0e277..e8c9416 100644 --- a/router/head.go +++ b/router/head.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/gin-gonic/gin" - "github.com/philiphil/restman/configuration" "github.com/philiphil/restman/errors" "github.com/philiphil/restman/route" "github.com/philiphil/restman/serializer" @@ -24,12 +23,12 @@ func (r *ApiRouter[T]) Head(c *gin.Context) { } s := serializer.NewSerializer(responseFormat) - groups, err := r.GetConfiguration(configuration.SerializationGroupsType, route.Get) + groups, err := r.GetEffectiveOutputSerializationGroups(c, route.Get) if err != nil { c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return } - str, err := s.Serialize(object, groups.Values...) + str, err := s.Serialize(object, groups...) if err != nil { c.AbortWithStatusJSON(errors.ErrInternal.Code, errors.ErrInternal.Message) return diff --git a/router/patch.go b/router/patch.go index 2d209b2..1fc02d3 100644 --- a/router/patch.go +++ b/router/patch.go @@ -2,8 +2,10 @@ package router import ( "github.com/gin-gonic/gin" + "github.com/philiphil/restman/configuration" "github.com/philiphil/restman/errors" "github.com/philiphil/restman/orm/entity" + "github.com/philiphil/restman/route" ) // Patch handles HTTP PATCH requests to partially update an existing entity. @@ -19,7 +21,14 @@ func (r *ApiRouter[T]) Patch(c *gin.Context) { c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return } - if err = UnserializeBodyAndMerge(c, obj); err != nil { + + groups, errGroups := r.GetConfiguration(configuration.InputSerializationGroupsType, route.Patch) + if errGroups != nil { + c.AbortWithStatusJSON(errors.ErrInternal.Code, errors.ErrInternal.Message) + return + } + + if err = UnserializeBodyAndMerge(c, obj, groups.Values...); err != nil { c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return } @@ -33,5 +42,22 @@ func (r *ApiRouter[T]) Patch(c *gin.Context) { return } - c.JSON(204, nil) + responseFormat, errParse := ParseAcceptHeader(c.GetHeader("Accept")) + if errParse != nil { + c.AbortWithStatusJSON(errParse.(errors.ApiError).Code, errParse.(errors.ApiError).Message) + return + } + + //what is sent back should use the "get" serialization groups + outputGroups, err := r.GetEffectiveOutputSerializationGroups(c, route.Get) + if err != nil { + c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) + return + } + + c.Render(200, SerializerRenderer{ + Data: obj, + Format: responseFormat, + Groups: outputGroups, + }) } diff --git a/router/post.go b/router/post.go index 198056a..69ea53e 100644 --- a/router/post.go +++ b/router/post.go @@ -18,10 +18,16 @@ func (r *ApiRouter[T]) Post(c *gin.Context) { return } - if err := UnserializeBodyAndMerge(c, &entity); err != nil { + groups, err := r.GetConfiguration(configuration.InputSerializationGroupsType, route.Post) + if err != nil { + c.AbortWithStatusJSON(errors.ErrInternal.Code, errors.ErrInternal.Message) + return + } + + if err := UnserializeBodyAndMerge(c, &entity, groups.Values...); err != nil { //if unserializable, might be array if _, ok := r.Routes[route.BatchPost]; ok { - if err := UnserializeBodyAndMerge_A(c, &entities); err != nil { + if err := UnserializeBodyAndMerge_A(c, &entities, groups.Values...); err != nil { //its still unserializable as an array c.AbortWithStatusJSON(errors.ErrBadFormat.Code, errors.ErrBadFormat.Message) return @@ -41,15 +47,16 @@ func (r *ApiRouter[T]) Post(c *gin.Context) { c.AbortWithStatusJSON(errors.ErrDatabaseIssue.Code, errors.ErrDatabaseIssue.Message) return } - responseFormat, err := ParseAcceptHeader(c.GetHeader("Accept")) - if err != nil { - c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) + responseFormat, errParse := ParseAcceptHeader(c.GetHeader("Accept")) + if errParse != nil { + c.AbortWithStatusJSON(errParse.(errors.ApiError).Code, errParse.(errors.ApiError).Message) return } - groups, err := r.GetConfiguration(configuration.SerializationGroupsType, route.Post) + //what is sent back should use the "get" serialization groups + outputGroups, err := r.GetEffectiveOutputSerializationGroups(c, route.Get) if err != nil { - c.AbortWithStatusJSON(errors.ErrInternal.Code, errors.ErrInternal.Message) + c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return } @@ -57,14 +64,14 @@ func (r *ApiRouter[T]) Post(c *gin.Context) { c.Render(201, SerializerRenderer{ Data: &entity, Format: responseFormat, - Groups: groups.Values, //what is sent shall be compliant to get + Groups: outputGroups, }) } else { //batch c.Render(201, SerializerRenderer{ Data: &entities, Format: responseFormat, - Groups: groups.Values, //what is sent shall be compliant to get + Groups: outputGroups, }) } diff --git a/router/put.go b/router/put.go index 42369ea..78febd3 100644 --- a/router/put.go +++ b/router/put.go @@ -2,8 +2,10 @@ package router import ( "github.com/gin-gonic/gin" + "github.com/philiphil/restman/configuration" "github.com/philiphil/restman/errors" "github.com/philiphil/restman/orm/entity" + "github.com/philiphil/restman/route" ) // Put handles HTTP PUT requests to replace or create an entity at a specific ID. @@ -20,7 +22,13 @@ func (r *ApiRouter[T]) Put(c *gin.Context) { return } - if err = UnserializeBodyAndMerge(c, obj); err != nil { + groups, errGroups := r.GetConfiguration(configuration.InputSerializationGroupsType, route.Put) + if errGroups != nil { + c.AbortWithStatusJSON(errors.ErrInternal.Code, errors.ErrInternal.Message) + return + } + + if err = UnserializeBodyAndMerge(c, obj, groups.Values...); err != nil { c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) return } @@ -36,5 +44,22 @@ func (r *ApiRouter[T]) Put(c *gin.Context) { return } - c.JSON(204, nil) + responseFormat, errParse := ParseAcceptHeader(c.GetHeader("Accept")) + if errParse != nil { + c.AbortWithStatusJSON(errParse.(errors.ApiError).Code, errParse.(errors.ApiError).Message) + return + } + + //what is sent back should use the "get" serialization groups + outputGroups, err := r.GetEffectiveOutputSerializationGroups(c, route.Get) + if err != nil { + c.AbortWithStatusJSON(err.(errors.ApiError).Code, err.(errors.ApiError).Message) + return + } + + c.Render(200, SerializerRenderer{ + Data: obj, + Format: responseFormat, + Groups: outputGroups, + }) } diff --git a/router/unserialize_body.go b/router/unserialize_body.go index c42cd51..16d1e8c 100644 --- a/router/unserialize_body.go +++ b/router/unserialize_body.go @@ -10,7 +10,8 @@ import ( ) // UnserializeBodyAndMerge deserializes the request body and merges it with the provided entity. -func UnserializeBodyAndMerge[T any](c *gin.Context, e *T) error { +// If groups are provided, only fields with matching group tags will be deserialized. +func UnserializeBodyAndMerge[T any](c *gin.Context, e *T, groups ...string) error { serializedData, err := io.ReadAll(c.Request.Body) bodyReader := bytes.NewReader(serializedData) c.Request.Body = io.NopCloser(bodyReader) @@ -18,7 +19,7 @@ func UnserializeBodyAndMerge[T any](c *gin.Context, e *T) error { return errors.ErrBadFormat } serializer_ := serializer.NewSerializer(ParseTypeFromString(c.GetHeader("Content-type"))) - err = serializer_.DeserializeAndMerge(string(serializedData), e) + err = serializer_.DeserializeAndMerge(string(serializedData), e, groups...) if err != nil { return errors.ErrBadFormat } @@ -26,7 +27,8 @@ func UnserializeBodyAndMerge[T any](c *gin.Context, e *T) error { } // UnserializeBodyAndMerge_A deserializes the request body as an array and merges it with the provided entity slice. -func UnserializeBodyAndMerge_A[T any](c *gin.Context, e *[]*T) error { +// If groups are provided, only fields with matching group tags will be deserialized. +func UnserializeBodyAndMerge_A[T any](c *gin.Context, e *[]*T, groups ...string) error { serializedData, err := io.ReadAll(c.Request.Body) bodyReader := bytes.NewReader(serializedData) c.Request.Body = io.NopCloser(bodyReader) @@ -34,7 +36,7 @@ func UnserializeBodyAndMerge_A[T any](c *gin.Context, e *[]*T) error { return errors.ErrBadFormat } serializer_ := serializer.NewSerializer(ParseTypeFromString(c.GetHeader("Content-type"))) - err = serializer_.DeserializeAndMerge(string(serializedData), &e) + err = serializer_.DeserializeAndMerge(string(serializedData), &e, groups...) if err != nil { return errors.ErrBadFormat } diff --git a/serializer/README.md b/serializer/README.md index d77402d..b837e2d 100644 --- a/serializer/README.md +++ b/serializer/README.md @@ -1,23 +1,19 @@ -It would be better to do something like this +About serialization groups. +They allows DTO on demand -Serialize(any serialized, any medium) +right now we use the same tag serializationGroup for input and output, +which is consufing. -1 What's serialized ? - A pointer - -> unpoint it and go to 1 - A slice - -> itt trought it and go to 1 - A struct - -> itt trough it and go to 1 IF field is not excluded - A map - -> itt trough it and go to 1 - A primitive - convert (2) +create and update routes are using read's config as an output +which is okay in theory but in practice sometimes we do weird thing +so we need to be able to give those a proper output serializationGoup for the sake of it + +OR ... +I would argue post/put output should be get compliant +So ... +allows POST proper output and use get as backup -I didnt did at first cause the only target was struct other cases came as edge case -now everything's there and implemeted and working -At first nothing was mastered, but now it is and now is time to create a better worflow ___________ Now, It is what's already happenning ... almost diff --git a/serializer/deserialize.go b/serializer/deserialize.go index d6f498d..0be9584 100644 --- a/serializer/deserialize.go +++ b/serializer/deserialize.go @@ -96,7 +96,8 @@ func (s *Serializer) deserializeXML(data string, obj any) error { } // MergeObjects merges source object fields into target object, both must be pointers. -func (s *Serializer) MergeObjects(target any, source any) error { +// If groups are provided, only fields with matching group tags will be merged. +func (s *Serializer) MergeObjects(target any, source any, groups ...string) error { targetValue := reflect.ValueOf(target) sourceValue := reflect.ValueOf(source) @@ -107,15 +108,14 @@ func (s *Serializer) MergeObjects(target any, source any) error { targetValue = targetValue.Elem() sourceValue = sourceValue.Elem() - mergeFields(targetValue, sourceValue) + mergeFields(targetValue, sourceValue, groups) return nil } -func mergeFields(target reflect.Value, source reflect.Value) { +func mergeFields(target reflect.Value, source reflect.Value, groups []string) { source = filter.DereferenceValueIfPointer(source) - //if target is nil or empty, lets create anew if (target.Kind() == reflect.Ptr || target.Kind() == reflect.Interface) && target.IsNil() { newTarget := reflect.New(source.Type()) if target.Kind() == reflect.Ptr { @@ -129,6 +129,7 @@ func mergeFields(target reflect.Value, source reflect.Value) { target = filter.DereferenceValueIfPointer(target) if target.Kind() == reflect.Struct && source.Kind() == reflect.Struct { + targetType := target.Type() for i := 0; i < target.NumField(); i++ { targetField := target.Field(i) sourceField := source.Field(i) @@ -137,9 +138,16 @@ func mergeFields(target reflect.Value, source reflect.Value) { continue } + if len(groups) > 0 { + structField := targetType.Field(i) + if !filter.IsFieldIncluded(structField, groups) { + continue + } + } + if targetField.CanSet() && !isEmpty(sourceField) { if targetField.Kind() == reflect.Struct && sourceField.Kind() == reflect.Struct { - mergeFields(targetField, sourceField) + mergeFields(targetField, sourceField, groups) } else { targetField.Set(sourceField) } @@ -154,9 +162,9 @@ func mergeFields(target reflect.Value, source reflect.Value) { if sourceElem.Kind() == reflect.Ptr || sourceElem.Kind() == reflect.Struct { mergedElem := reflect.New(sourceElem.Type()).Elem() if i < target.Len() { - mergeFields(target.Index(i), sourceElem) + mergeFields(target.Index(i), sourceElem, groups) } else { - mergeFields(mergedElem, sourceElem) + mergeFields(mergedElem, sourceElem, groups) target.Set(reflect.Append(target, mergedElem)) } } else { @@ -179,14 +187,15 @@ func shouldExclude(field reflect.Value) bool { } // DeserializeAndMerge deserializes data and merges it into the target object. -func (s *Serializer) DeserializeAndMerge(data string, target any) error { +// If groups are provided, only fields with matching group tags will be merged. +func (s *Serializer) DeserializeAndMerge(data string, target any, groups ...string) error { source := reflect.New(reflect.TypeOf(target).Elem()).Interface() if err := s.Deserialize(data, source); err != nil { return err } - return s.MergeObjects(target, source) + return s.MergeObjects(target, source, groups...) } // isEmpty checks if a value has the zero value of its type diff --git a/serializer/filter/field_utils.go b/serializer/filter/field_utils.go index fdf9b84..4f08d3a 100644 --- a/serializer/filter/field_utils.go +++ b/serializer/filter/field_utils.go @@ -5,13 +5,13 @@ import ( "strings" ) -// IsFieldIncluded checks if a struct field should be included based on its group tags and the provided groups. +// IsFieldIncluded checks if a struct field should be included based on its groups tags and the provided groups. func IsFieldIncluded(field reflect.StructField, groups []string) bool { if len(groups) == 0 { - return true //No filtration then + return true } - tag := field.Tag.Get("group") + tag := field.Tag.Get("groups") if tag == "" { return false } @@ -19,7 +19,7 @@ func IsFieldIncluded(field reflect.StructField, groups []string) bool { groupList := strings.Split(tag, ",") for _, group := range groups { for _, g := range groupList { - if group == g { + if strings.TrimSpace(group) == strings.TrimSpace(g) { return true } } diff --git a/serializer/pools.go b/serializer/pools.go index 6621d92..1e3f2bd 100644 --- a/serializer/pools.go +++ b/serializer/pools.go @@ -2,23 +2,9 @@ package serializer import ( "bytes" - "encoding/json" - "encoding/xml" "sync" ) -var jsonEncoderPool = sync.Pool{ - New: func() interface{} { - return json.NewEncoder(&bytes.Buffer{}) - }, -} - -var xmlEncoderPool = sync.Pool{ - New: func() interface{} { - return xml.NewEncoder(&bytes.Buffer{}) - }, -} - var bufferPool = sync.Pool{ New: func() interface{} { return &bytes.Buffer{} diff --git a/test/router/caching_test.go b/test/router/caching_test.go index 3e55d9e..adc73f4 100644 --- a/test/router/caching_test.go +++ b/test/router/caching_test.go @@ -39,8 +39,8 @@ func (e securedTest) GetReadingRights() security.AuthorizationFunction { } func TestApiRouter_HandleCaching(t *testing.T) { - repo := orm.NewORM[Test](gormrepository.NewRepository[Test, Test](getDB())) - test_ := NewApiRouter[Test]( + repo := orm.NewORM(gormrepository.NewRepository[Test](getDB())) + test_ := NewApiRouter( *repo, route.DefaultApiRoutes(), configuration.NetworkCachingPolicy(5), diff --git a/test/router/delete_test.go b/test/router/delete_test.go index daae64a..01ee5c0 100644 --- a/test/router/delete_test.go +++ b/test/router/delete_test.go @@ -20,8 +20,8 @@ func TestApiRouter_delete(t *testing.T) { getDB().Exec("DELETE FROM tests") r := SetupRouter() - repo := orm.NewORM[Test](gormrepository.NewRepository[Test, Test](getDB())) - test_ := NewApiRouter[Test]( + repo := orm.NewORM(gormrepository.NewRepository[Test](getDB())) + test_ := NewApiRouter( *repo, route.DefaultApiRoutes(), ) diff --git a/test/router/getList_test.go b/test/router/getList_test.go index b4d743f..a64a83f 100644 --- a/test/router/getList_test.go +++ b/test/router/getList_test.go @@ -31,8 +31,8 @@ func TestApiRouter_GetList(t *testing.T) { getDB().Exec("DELETE FROM tests") r := SetupRouter() - repo := orm.NewORM[Test](gormrepository.NewRepository[Test, Test](getDB())) - test_ := NewApiRouter[Test]( + repo := orm.NewORM(gormrepository.NewRepository[Test](getDB())) + test_ := NewApiRouter( *repo, route.DefaultApiRoutes(), configuration.Pagination(false), @@ -74,8 +74,8 @@ func TestApiRouter_GetListPaginated(t *testing.T) { getDB().AutoMigrate(&Test{}) r := SetupRouter() - repo := orm.NewORM[Test](gormrepository.NewRepository[Test, Test](getDB())) - test_ := NewApiRouter[Test]( + repo := orm.NewORM(gormrepository.NewRepository[Test](getDB())) + test_ := NewApiRouter( *repo, route.DefaultApiRoutes(), configuration.Pagination(true), @@ -164,8 +164,8 @@ func TestApiRouter_GetListJSONLD(t *testing.T) { getDB().AutoMigrate(&Test{}) r := SetupRouter() - repo := orm.NewORM[Test](gormrepository.NewRepository[Test, Test](getDB())) - test_ := NewApiRouter[Test]( + repo := orm.NewORM(gormrepository.NewRepository[Test](getDB())) + test_ := NewApiRouter( *repo, route.DefaultApiRoutes(), ) @@ -206,8 +206,8 @@ func TestApiRouter_GetListErrors(t *testing.T) { getDB().AutoMigrate(&Test{}) r := SetupRouter() - repo := orm.NewORM[Test](gormrepository.NewRepository[Test, Test](getDB())) - test_ := NewApiRouter[Test]( + repo := orm.NewORM(gormrepository.NewRepository[Test](getDB())) + test_ := NewApiRouter( *repo, route.DefaultApiRoutes(), ) diff --git a/test/router/get_test.go b/test/router/get_test.go index de72a7d..f7d5505 100644 --- a/test/router/get_test.go +++ b/test/router/get_test.go @@ -109,8 +109,8 @@ func TestApiRouter_GetListCSV(t *testing.T) { getDB().Exec("DELETE FROM tests") r := SetupRouter() - repo := orm.NewORM[Test](gormrepository.NewRepository[Test, Test](getDB())) - test_ := NewApiRouter[Test]( + repo := orm.NewORM(gormrepository.NewRepository[Test](getDB())) + test_ := NewApiRouter( *repo, route.DefaultApiRoutes(), ) diff --git a/test/router/head_test.go b/test/router/head_test.go index 0c2b0d8..9a0a474 100644 --- a/test/router/head_test.go +++ b/test/router/head_test.go @@ -23,8 +23,8 @@ func TestApiRouter_Head(t *testing.T) { getDB().Exec("DELETE FROM tests") r := SetupRouter() - repo := orm.NewORM[Test](gormrepository.NewRepository[Test, Test](getDB())) - test_ := NewApiRouter[Test]( + repo := orm.NewORM(gormrepository.NewRepository[Test](getDB())) + test_ := NewApiRouter( *repo, route.DefaultApiRoutes(), ) diff --git a/test/router/patch_test.go b/test/router/patch_test.go index 117c675..dd159c3 100644 --- a/test/router/patch_test.go +++ b/test/router/patch_test.go @@ -40,7 +40,7 @@ func TestApiRouter_patch(t *testing.T) { req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) - if w.Code != http.StatusNoContent { + if w.Code != http.StatusOK { t.Error("should be no content") } var medium Test diff --git a/test/router/put_test.go b/test/router/put_test.go index 4c2109c..fc36a2c 100644 --- a/test/router/put_test.go +++ b/test/router/put_test.go @@ -30,8 +30,8 @@ func TestApiRouter_put(t *testing.T) { req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) - if w.Code != http.StatusNoContent { - t.Error("should be no content") + if w.Code != http.StatusOK { + t.Error("should be OK") } w = httptest.NewRecorder() req, _ = http.NewRequest("PUT", "/api/test/2", bytes.NewBuffer([]byte(`{"name":"test2"}`))) diff --git a/test/serializer/deserialize_test.go b/test/serializer/deserialize_test.go index cdd6acf..4cd1591 100644 --- a/test/serializer/deserialize_test.go +++ b/test/serializer/deserialize_test.go @@ -10,28 +10,28 @@ import ( ) type Test struct { - Test0 int `group:"test"` - Test1 int `group:"testo"` - Test2 int `group:"test"` - Test3 int `group:"testo,test"` + Test0 int `groups:"test"` + Test1 int `groups:"testo"` + Test2 int `groups:"test"` + Test3 int `groups:"testo,test"` Test4 int test5 int - Test6 int `group:"test"` + Test6 int `groups:"test"` } type Recursive struct { - Test1 Hidden `group:"test"` + Test1 Hidden `groups:"test"` Test2 Hidden } type Hidden struct { - Test0 int `group:"test"` + Test0 int `groups:"test"` Test1 int } type Ptr struct { - Test0 int `group:"test"` - Test1 *int `group:"test"` - Test2 *Hidden `group:"test"` + Test0 int `groups:"test"` + Test1 *int `groups:"test"` + Test2 *Hidden `groups:"test"` Test3 *int Test4 *Hidden } @@ -551,10 +551,10 @@ func BenchmarkSerializer_Serialize(b *testing.B) { // todo we need better test type FilterStruct struct { - Test0 int `group:"test"` - Test1 int `group:"testo"` + Test0 int `groups:"test"` + Test1 int `groups:"testo"` Test2 int - Test3 int `group:"testo,test"` + Test3 int `groups:"testo,test"` } var testedFilterStruct = FilterStruct{ @@ -566,21 +566,21 @@ var testedFilterStructDeserializedResult = FilterStruct{ } type PrimitiveStruct struct { - Test0 int `group:"test"` - Test1 string `group:"test"` - Test2 bool `group:"test"` - Test3 float64 `group:"test"` - Test4 int64 `group:"test"` - Test5 rune `group:"test"` + Test0 int `groups:"test"` + Test1 string `groups:"test"` + Test2 bool `groups:"test"` + Test3 float64 `groups:"test"` + Test4 int64 `groups:"test"` + Test5 rune `groups:"test"` } type NestedStruct struct { - Test0 FilterStruct `group:"test"` + Test0 FilterStruct `groups:"test"` Test1 FilterStruct } type PtrStruct struct { - Test0 *FilterStruct `group:"test"` + Test0 *FilterStruct `groups:"test"` Test1 *FilterStruct } @@ -589,16 +589,16 @@ type AnonymousStruct struct { } type SliceStruct struct { - Test0 []FilterStruct `group:"test"` + Test0 []FilterStruct `groups:"test"` Test1 []FilterStruct } type MapStruct struct { - Test0 map[string]FilterStruct `group:"test"` + Test0 map[string]FilterStruct `groups:"test"` Test1 map[string]FilterStruct } type InterfaceStruct struct { - Test0 any `group:"test"` + Test0 any `groups:"test"` Test1 any } diff --git a/test/serializer/serializer_test.go b/test/serializer/serializer_test.go index b7f0345..92d4a80 100644 --- a/test/serializer/serializer_test.go +++ b/test/serializer/serializer_test.go @@ -94,10 +94,10 @@ func TestSerializer_SerializeCSVEmpty(t *testing.T) { // CSV serialization - slice with different types func TestSerializer_SerializeCSVPrimitives(t *testing.T) { type CSVTest struct { - Name string `group:"test"` - Age int `group:"test"` - Score float64 `group:"test"` - Pass bool `group:"test"` + Name string `groups:"test"` + Age int `groups:"test"` + Score float64 `groups:"test"` + Pass bool `groups:"test"` } s := NewSerializer(format.CSV)