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
16 changes: 3 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions configuration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 45 additions & 10 deletions configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)}}
}
16 changes: 13 additions & 3 deletions configuration/default_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
}
}
4 changes: 2 additions & 2 deletions example/custom_serialization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion example/router_conf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
135 changes: 135 additions & 0 deletions example/serialization_groups_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
32 changes: 16 additions & 16 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -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
Expand All @@ -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
)
Loading
Loading