diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 0bf29ec..0000000 --- a/.coveragerc +++ /dev/null @@ -1,96 +0,0 @@ -# RestMan - Coverage Configuration - -This file explains how to use coverage in RestMan. - -## Why this file exists - -RestMan has a particular test structure: tests are in separate `/test/` folders instead of being co-located with source code (e.g., `router_test.go` next to `router.go`). - -This structure is intentional as it clearly separates production code from tests, but it requires a special command for coverage. - -## The problem - -If you simply run `go test -cover ./...`, Go only calculates coverage for packages **containing tests**, not the source code. Result: 0% coverage displayed even though tests pass. - -## The solution - -Use the `-coverpkg=./...` flag which tells Go: "calculate coverage for ALL packages, not just those with tests". - -## Commands to use - -### Basic coverage (terminal) -```bash -go test -coverprofile=coverage.out ./... -coverpkg=./... -go tool cover -func=coverage.out -``` - -Displays something like: -``` -github.com/philiphil/restman/router/get.go:15: Get 100.0% -github.com/philiphil/restman/router/post.go:20: Post 85.7% -total: (statements) 69.5% -``` - -### Visual coverage (HTML) -```bash -go test -coverprofile=coverage.out ./... -coverpkg=./... -go tool cover -html=coverage.out -o coverage.html -open coverage.html # macOS -``` - -Opens an interactive HTML report showing line by line what is tested (green) or not (red). - -### Coverage for a specific package -```bash -# Router only -go test -coverprofile=coverage.out ./test/router/... -coverpkg=./... -go tool cover -func=coverage.out - -# Serializer only -go test -coverprofile=coverage.out ./test/serializer/... -coverpkg=./... -go tool cover -func=coverage.out -``` - -### Clean cache before testing -```bash -go clean -testcache && go test -coverprofile=coverage.out ./... -coverpkg=./... -``` - -Useful if tests seem "cached" or if changes are not being picked up. - -## For CI/CD - -GitHub Actions example: -```yaml -- name: Test with coverage - run: | - go test -race -coverprofile=coverage.out -covermode=atomic ./... -coverpkg=./... - go tool cover -func=coverage.out - -- name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - files: ./coverage.out -``` - -## Coverage targets - -- **Core packages** (router, orm, serializer): > 80% -- **Utilities** (configuration, format, errors): > 70% -- **Global**: > 65% - -## Current results (Jan 2025) - -- `router`: **69.5%** ✅ -- `orm/gormrepository`: **~8%** ⚠️ (tests exist but incomplete) -- `serializer`: **~23%** ⚠️ - -## Why `-coverpkg=./...`? - -Without this flag: -- `go test ./test/router/...` → calculates coverage of `test/router` (which only has tests, 0 LOC of business code) - -With this flag: -- `go test ./test/router/... -coverpkg=./...` → calculates coverage of the ENTIRE project, even if tests are elsewhere - -It's the equivalent of saying "run tests from test/router/, but measure coverage of router/, orm/, serializer/, etc." diff --git a/README.md b/README.md index 383bdef..f4b4387 100644 --- a/README.md +++ b/README.md @@ -460,7 +460,6 @@ go test ./test/router/... - [ ] Filtering implementation - [ ] Groups override parameter - [ ] UUID compatibility for entity.ID -- [ ] Performance optimization for JSON serialization - [ ] Force lowercase option for JSON keys - [ ] Automatic Redis caching integration in router - [ ] GraphQL support diff --git a/configuration/configuration.go b/configuration/configuration.go index 996b098..9cf71a2 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -70,6 +70,9 @@ 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 + // Unimplemented configuration types - reserved for future use BatchLimitType // Will limit the number of items in batch operations TypeEnabledType // Will enable/disable specific route types diff --git a/go.sum b/go.sum index 0522a39..ff27266 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-redis/redismock/v9 v9.2.0 h1:ZrMYQeKPECZPjOj5u9eyOjg8Nnb0BS9lkVIZ6IpsKLw= github.com/go-redis/redismock/v9 v9.2.0/go.mod h1:18KHfGDK4Y6c2R0H38EUGWAdc7ZQS9gfYxc94k7rWT0= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -41,6 +43,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -52,6 +56,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -81,8 +87,12 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= +github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -113,24 +123,36 @@ go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFX go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= @@ -143,6 +165,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -151,14 +175,20 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/router/apirouter.go b/router/apirouter.go index d6dddac..aa58f8d 100644 --- a/router/apirouter.go +++ b/router/apirouter.go @@ -91,22 +91,25 @@ func (r *ApiRouter[T]) AllowRoutes(router *gin.Engine) { // ConvertToSnakeCase converts a camelCase or PascalCase string to snake_case. // Example: "BookTitle" becomes "book_title". func ConvertToSnakeCase(input string) string { - runes := []rune(input) - if len(runes) == 0 { + if input == "" { return "" } - runes[0] = unicode.ToLower(runes[0]) + var builder strings.Builder + builder.Grow(len(input) + 5) - for i := 1; i < len(runes); i++ { - if unicode.IsUpper(runes[i]) { - runes[i] = unicode.ToLower(runes[i]) - runes = append(runes[:i], append([]rune{'_'}, runes[i:]...)...) - i++ + for i, r := range input { + if unicode.IsUpper(r) { + if i > 0 { + builder.WriteRune('_') + } + builder.WriteRune(unicode.ToLower(r)) + } else { + builder.WriteRune(r) } } - return string(runes) + return builder.String() } // This function return either the router wide configuration or the route specific configuration @@ -114,12 +117,12 @@ func ConvertToSnakeCase(input string) string { // 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(configuration configuration.ConfigurationType, routeType ...route.RouteType) (configuration.Configuration, error) { - routerValue, found := r.Configuration[configuration] +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[configuration] + routeValue, exists := route_.Configuration[configurationType] if exists { return routeValue, nil } @@ -164,17 +167,26 @@ func TrimSlash(s string) string { } // Route is a function that returns the route name for a given route type -func (r *ApiRouter[T]) Route(routeType ...route.RouteType) (name string) { - name = "/" +func (r *ApiRouter[T]) Route(routeType ...route.RouteType) string { prefixs, _ := r.GetConfiguration(configuration.RoutePrefixType, routeType...) + routeName, _ := r.GetConfiguration(configuration.RouteNameType, routeType...) + + var builder strings.Builder + estimatedLen := 1 for _, v := range prefixs.Values { - name += TrimSlash(v) + "/" + estimatedLen += len(v) + 1 } + estimatedLen += len(routeName.Values[0]) + builder.Grow(estimatedLen) - routeName, _ := r.GetConfiguration(configuration.RouteNameType, routeType...) + builder.WriteRune('/') + for _, v := range prefixs.Values { + builder.WriteString(TrimSlash(v)) + builder.WriteRune('/') + } + builder.WriteString(TrimSlash(routeName.Values[0])) - name += TrimSlash(routeName.Values[0]) - return name + return builder.String() } // AddFirewall adds one or more firewalls to this ApiRouter for authentication and authorization. diff --git a/router/header.go b/router/header.go index ab5d808..5ed0f2f 100644 --- a/router/header.go +++ b/router/header.go @@ -4,6 +4,7 @@ import ( "sort" "strconv" "strings" + "sync" "github.com/philiphil/restman/errors" "github.com/philiphil/restman/format" @@ -15,13 +16,37 @@ type MediaType struct { Weight float64 } +var ( + acceptHeaderCache sync.Map + mediaTypePool = sync.Pool{ + New: func() interface{} { + return make([]MediaType, 0, 8) + }, + } +) + // ParseAcceptHeader parses the Accept HTTP header and returns the most preferred supported format. func ParseAcceptHeader(acceptHeader string) (format.Format, error) { if acceptHeader == "" { return format.JSON, nil } + + if cached, ok := acceptHeaderCache.Load(acceptHeader); ok { + return cached.(format.Format), nil + } + mediaTypes := strings.Split(acceptHeader, ",") - mediaTypesWithQ := make([]MediaType, len(mediaTypes)) + mediaTypesWithQ := mediaTypePool.Get().([]MediaType) + defer func() { + mediaTypesWithQ = mediaTypesWithQ[:0] + mediaTypePool.Put(mediaTypesWithQ) + }() + + if cap(mediaTypesWithQ) < len(mediaTypes) { + mediaTypesWithQ = make([]MediaType, len(mediaTypes)) + } else { + mediaTypesWithQ = mediaTypesWithQ[:len(mediaTypes)] + } for i, mediaType := range mediaTypes { parts := strings.Split(strings.TrimSpace(mediaType), ";") @@ -45,15 +70,16 @@ func ParseAcceptHeader(acceptHeader string) (format.Format, error) { return mediaTypesWithQ[i].Weight > mediaTypesWithQ[j].Weight }) - sortedMediaTypes := make([]string, len(mediaTypesWithQ)) - for i, mediaType := range mediaTypesWithQ { - sortedMediaTypes[i] = mediaType.Type + for _, mediaType := range mediaTypesWithQ { if f := ParseTypeFromString(mediaType.Type); f != format.Undefined && f != format.Unknown { + acceptHeaderCache.Store(acceptHeader, f) return f, nil } else if mediaType.Type == "*/*" { + acceptHeaderCache.Store(acceptHeader, format.JSON) return format.JSON, nil } - } //default + } + return format.Undefined, errors.ErrNotAcceptable } @@ -62,16 +88,17 @@ func ParseTypeFromString(str string) format.Format { if str == "" { return format.Undefined } - if strings.Contains(strings.ToLower(str), "ld+json") { + lower := strings.ToLower(str) + if strings.Contains(lower, "ld+json") { return format.JSONLD } - if strings.Contains(strings.ToLower(str), "json") { + if strings.Contains(lower, "json") { return format.JSON } - if strings.Contains(strings.ToLower(str), "xml") { + if strings.Contains(lower, "xml") { return format.XML } - if strings.Contains(strings.ToLower(str), "csv") { + if strings.Contains(lower, "csv") { return format.CSV } return format.Unknown diff --git a/router/renderer.go b/router/renderer.go index c98c56e..f4167d5 100644 --- a/router/renderer.go +++ b/router/renderer.go @@ -2,6 +2,7 @@ package router import ( "net/http" + "sync" "github.com/philiphil/restman/format" "github.com/philiphil/restman/serializer" @@ -21,10 +22,53 @@ var ( messagepackContentType = []string{"application/msgpack"} ) +var serializerPools = map[format.Format]*sync.Pool{ + format.JSON: { + New: func() interface{} { + return serializer.NewSerializer(format.JSON) + }, + }, + format.JSONLD: { + New: func() interface{} { + return serializer.NewSerializer(format.JSONLD) + }, + }, + format.XML: { + New: func() interface{} { + return serializer.NewSerializer(format.XML) + }, + }, + format.CSV: { + New: func() interface{} { + return serializer.NewSerializer(format.CSV) + }, + }, + format.MESSAGEPACK: { + New: func() interface{} { + return serializer.NewSerializer(format.MESSAGEPACK) + }, + }, +} + +func getSerializer(f format.Format) *serializer.Serializer { + if pool, ok := serializerPools[f]; ok { + return pool.Get().(*serializer.Serializer) + } + return serializer.NewSerializer(f) +} + +func putSerializer(f format.Format, s *serializer.Serializer) { + if pool, ok := serializerPools[f]; ok { + pool.Put(s) + } +} + // Render func (r SerializerRenderer) Render(w http.ResponseWriter) (err error) { r.WriteContentType(w) - s := serializer.NewSerializer(r.Format) + s := getSerializer(r.Format) + defer putSerializer(r.Format, s) + str, err := s.Serialize(r.Data, r.Groups...) if err != nil { return err diff --git a/serializer/filter/cache.go b/serializer/filter/cache.go new file mode 100644 index 0000000..0dfd20a --- /dev/null +++ b/serializer/filter/cache.go @@ -0,0 +1,73 @@ +package filter + +import ( + "reflect" + "sort" + "strings" + "sync" +) + +type cacheKey struct { + typeName string + groups string +} + +type fieldMapping struct { + srcIndex int + destIndex int +} + +type typeCacheEntry struct { + filteredType reflect.Type + fieldMappings []fieldMapping +} + +type typeCache struct { + mu sync.RWMutex + cache map[cacheKey]*typeCacheEntry +} + +var globalCache = &typeCache{ + cache: make(map[cacheKey]*typeCacheEntry), +} + +func makeGroupKey(groups []string) string { + if len(groups) == 0 { + return "" + } + sorted := make([]string, len(groups)) + copy(sorted, groups) + sort.Strings(sorted) + return strings.Join(sorted, ",") +} + +func makeCacheKey(t reflect.Type, groups []string) cacheKey { + pkgPath := t.PkgPath() + name := t.Name() + var typeName string + if pkgPath != "" { + typeName = pkgPath + "." + name + } else { + typeName = name + } + + return cacheKey{ + typeName: typeName, + groups: makeGroupKey(groups), + } +} + +func (tc *typeCache) Get(t reflect.Type, groups []string) (*typeCacheEntry, bool) { + key := makeCacheKey(t, groups) + tc.mu.RLock() + entry, ok := tc.cache[key] + tc.mu.RUnlock() + return entry, ok +} + +func (tc *typeCache) Set(t reflect.Type, groups []string, entry *typeCacheEntry) { + key := makeCacheKey(t, groups) + tc.mu.Lock() + tc.cache[key] = entry + tc.mu.Unlock() +} diff --git a/serializer/filter/filter_struct.go b/serializer/filter/filter_struct.go index b38f7e9..66056c2 100644 --- a/serializer/filter/filter_struct.go +++ b/serializer/filter/filter_struct.go @@ -16,23 +16,34 @@ func filterByGroupsStruct[T any](obj T, groups ...string) T { value = DereferenceValueIfPointer(value) } + if cachedEntry, ok := globalCache.Get(elemType, groups); ok { + newValue := reflect.New(cachedEntry.filteredType).Elem() + if len(cachedEntry.fieldMappings) > 0 { + populateStructFieldsFast(newValue, value, cachedEntry.fieldMappings) + } else { + populateStructFields(newValue, value, cachedEntry.filteredType, groups) + } + return newValue.Interface().(T) + } + var newFields []reflect.StructField + var mappings []fieldMapping originalTypeName := elemType.Name() if value.IsValid() { + destIdx := 0 for i := range value.NumField() { field := elemType.Field(i) - if isFieldExported(field) && IsFieldIncluded(field, groups) { + if isFieldExported(field) && IsFieldIncluded(field, groups) && !isAnonymous(field) { fieldValue := value.Field(i) - if IsStruct(field.Type) && !isAnonymous(field) { + if IsStruct(field.Type) { filteredElem := FilterByGroups(fieldValue.Interface(), groups...) newField := reflect.StructField{ Name: field.Name, Type: reflect.TypeOf(filteredElem), Tag: field.Tag, } - // Add xml tag with original field name if not present if newField.Tag.Get("xml") == "" { if newField.Tag == "" { newField.Tag = reflect.StructTag(`xml:"` + field.Name + `"`) @@ -41,9 +52,10 @@ func filterByGroupsStruct[T any](obj T, groups ...string) T { } } newFields = append(newFields, newField) + mappings = append(mappings, fieldMapping{srcIndex: i, destIndex: destIdx}) + destIdx++ } else { newField := field - // Add xml tag if not present if newField.Tag.Get("xml") == "" { if newField.Tag == "" { newField.Tag = reflect.StructTag(`xml:"` + field.Name + `"`) @@ -52,30 +64,55 @@ func filterByGroupsStruct[T any](obj T, groups ...string) T { } } newFields = append(newFields, newField) + mappings = append(mappings, fieldMapping{srcIndex: i, destIndex: destIdx}) + destIdx++ } } } anonymousFields := filterAnonymousFields(value, groups...) + if len(anonymousFields) > 0 { + mappings = nil + } newFields = append(newFields, anonymousFields...) } newStructType := reflect.StructOf(newFields) + entry := &typeCacheEntry{ + filteredType: newStructType, + fieldMappings: mappings, + } + globalCache.Set(elemType, groups, entry) newValue := reflect.New(newStructType).Elem() - // Set XMLName if this was a named struct if originalTypeName != "" && len(newFields) > 0 { - // Try to add XMLName field at the beginning for proper XML marshaling - // Note: reflect.StructOf doesn't support adding XMLName after creation } - for i, field := range newFields { + if len(mappings) > 0 { + populateStructFieldsFast(newValue, value, mappings) + } else { + populateStructFields(newValue, value, newStructType, groups) + } + + return newValue.Interface().(T) +} + +func populateStructFieldsFast(newValue, value reflect.Value, mappings []fieldMapping) { + for _, mapping := range mappings { + srcField := value.Field(mapping.srcIndex) + destField := newValue.Field(mapping.destIndex) + destFieldType := newValue.Type().Field(mapping.destIndex) + assignFieldValue(destFieldType, destField, srcField) + } +} + +func populateStructFields(newValue, value reflect.Value, structType reflect.Type, groups []string) { + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) fieldName := field.Name fieldValue := value.FieldByName(fieldName) newFieldValue := newValue.Field(i) assignFieldValue(field, newFieldValue, fieldValue) } - - return newValue.Interface().(T) } func filterAnonymousFields(value reflect.Value, groups ...string) []reflect.StructField { diff --git a/serializer/pools.go b/serializer/pools.go new file mode 100644 index 0000000..6621d92 --- /dev/null +++ b/serializer/pools.go @@ -0,0 +1,35 @@ +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{} + }, +} + +func getBuffer() *bytes.Buffer { + return bufferPool.Get().(*bytes.Buffer) +} + +func putBuffer(buf *bytes.Buffer) { + buf.Reset() + bufferPool.Put(buf) +} diff --git a/serializer/serialize.go b/serializer/serialize.go index 5c639cb..ced44e5 100644 --- a/serializer/serialize.go +++ b/serializer/serialize.go @@ -119,26 +119,39 @@ func (s *Serializer) Serialize(obj any, groups ...string) (string, error) { func (s *Serializer) serializeJSON(obj any, groups ...string) (string, error) { data := filter.FilterByGroups(obj, groups...) - //data = renameFieldsToLower(data) - jsonBytes, err := json.MarshalIndent(data, "", " ") - if err != nil { + buf := getBuffer() + defer putBuffer(buf) + + encoder := json.NewEncoder(buf) + if !s.Compact { + encoder.SetIndent("", " ") + } + + if err := encoder.Encode(data); err != nil { return "", err } - return string(jsonBytes), nil + + result := buf.String() + if len(result) > 0 && result[len(result)-1] == '\n' { + result = result[:len(result)-1] + } + return result, nil } func (s *Serializer) serializeXML(obj any, groups ...string) (string, error) { - // Use custom XML marshaler that filters by groups - var sb strings.Builder - sb.WriteString(xml.Header) + buf := getBuffer() + defer putBuffer(buf) - encoder := xml.NewEncoder(&sb) - encoder.Indent("", " ") + buf.WriteString(xml.Header) + + encoder := xml.NewEncoder(buf) + if !s.Compact { + encoder.Indent("", " ") + } value := reflect.ValueOf(obj) typ := value.Type() - // Determine root element name rootName := "root" if typ.Kind() == reflect.Struct { rootName = typ.Name() @@ -158,7 +171,7 @@ func (s *Serializer) serializeXML(obj any, groups ...string) (string, error) { return "", err } - return sb.String(), nil + return buf.String(), nil } func (s *Serializer) serializeCSV(obj any, groups ...string) (string, error) { @@ -223,8 +236,10 @@ func (s *Serializer) serializeCSV(obj any, groups ...string) (string, error) { } func writeCSVToString(rows [][]string) (string, error) { - sb := strings.Builder{} - writer := csv.NewWriter(&sb) + buf := getBuffer() + defer putBuffer(buf) + + writer := csv.NewWriter(buf) if err := writer.WriteAll(rows); err != nil { return "", err } @@ -232,7 +247,7 @@ func writeCSVToString(rows [][]string) (string, error) { if err := writer.Error(); err != nil { return "", err } - return sb.String(), nil + return buf.String(), nil } func (s *Serializer) serializeMessagePack(obj any, groups ...string) (string, error) { diff --git a/serializer/serializer.go b/serializer/serializer.go index 0d1d687..e5706db 100644 --- a/serializer/serializer.go +++ b/serializer/serializer.go @@ -9,7 +9,8 @@ import ( // Serializer is responsible for serializing and deserializing objects type Serializer struct { - Format format.Format + Format format.Format + Compact bool } // NewSerializer creates a new Serializer instance with the specified format.