From 0ebfe83c8b5e14b8a4fc5765ad4f2f2f4d1701bb Mon Sep 17 00:00:00 2001 From: theo sorriaux Date: Wed, 22 Oct 2025 12:21:17 +0200 Subject: [PATCH] AI autodocs --- .coveragerc | 96 +++++ .github/workflows/go.yml | 11 +- .gitignore | 41 ++ Abstract.md | 82 ---- README.md | 526 +++++++++++++++++++++++-- cache/cache.go | 6 + configuration/configuration.go | 223 ++++++++--- configuration/default_configuration | 21 - configuration/default_configuration.go | 2 +- errors/apiError.go | 1 + example/batch_operations_test.go | 91 +++++ example/custom_serialization_test.go | 112 ++++++ example/pagination_sorting_test.go | 109 +++++ example/subresources_test.go | 93 +++++ format/format.go | 1 + orm/entity/base_entity.go | 2 + orm/entity/id.go | 4 +- orm/gormrepository/gormrepository.go | 19 + orm/gormrepository/rest_adaptater.go | 7 + orm/gormrepository/specification.go | 15 + orm/gormrepository/utils.go | 1 + orm/mongorepository/mongorepository.go | 17 + orm/mongorepository/rest_adapter.go | 7 + orm/mongorepository/specification.go | 16 + orm/orm.go | 10 + orm/repository.go | 3 +- route/route_construct.go | 9 +- route/route_type.go | 2 + router/apirouter.go | 4 + router/batchGet.go | 9 +- router/batchPatch.go | 1 + router/batchPut.go | 1 + router/caching.go | 1 + router/delete.go | 1 + router/get.go | 1 + router/getList.go | 7 +- router/head.go | 1 + router/header.go | 3 + router/json_ld_collection.go | 2 + router/options.go | 1 + router/patch.go | 1 + router/post.go | 1 + router/put.go | 1 + router/security.go | 3 + router/unserialize_body.go | 2 + security/authorization.go | 3 +- security/security_interfaces.go | 4 +- serializer/deserialize.go | 6 +- serializer/filter/field_utils.go | 6 + serializer/filter/filter.go | 1 + serializer/serialize.go | 1 + serializer/serializer.go | 2 +- 52 files changed, 1384 insertions(+), 206 deletions(-) create mode 100644 .coveragerc create mode 100644 .gitignore delete mode 100644 Abstract.md delete mode 100644 configuration/default_configuration create mode 100644 example/batch_operations_test.go create mode 100644 example/custom_serialization_test.go create mode 100644 example/pagination_sorting_test.go create mode 100644 example/subresources_test.go diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..0bf29ec --- /dev/null +++ b/.coveragerc @@ -0,0 +1,96 @@ +# 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/.github/workflows/go.yml b/.github/workflows/go.yml index 730c96a..355cc0f 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,12 +17,19 @@ jobs: - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: + # Note: Project requires Go 1.24+ but using 1.23 for CI until 1.24 is stable go-version: '1.23' + check-latest: true - name: Build run: go build -v ./... - name: Test - run: go test -v ./... + run: go test -v -short ./... + + - name: Test with Coverage + run: | + go test -short -coverprofile=coverage.out ./... -coverpkg=./... + go tool cover -func=coverage.out | grep total | awk '{print "Total coverage: " $3}' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8bab70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.out +coverage.html + +# Dependency directories +vendor/ + +# Go workspace file +go.work +go.work.sum + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Temporary files +tmp/ +temp/ diff --git a/Abstract.md b/Abstract.md deleted file mode 100644 index 93e2db5..0000000 --- a/Abstract.md +++ /dev/null @@ -1,82 +0,0 @@ -Abstract. - -Api should provide an easy way, server and client wise to access subresources - -as a list should be paginated to respect system limitation -An object aggregating several lists should provide access to those aggregated lists (subresources). - -the trad way is -/api/user/{id}/items - -so, how to think this though - -Ideally I want to specify items in the user router and not create an item router - -the thing is, the router is heavily dependent on generics and I'm not sure there's a serverside easy way to handle this without having to create a new router explicitly - -items could be think as a simple router in itself with some kind of user_id=filter - - -something along the lines of this syntax looks like the most desirable server side syntax - - api := router.NewApiRouter( - *orm.NewORM(gormrepository.NewRepository[User](getDB())), - route.DefaultApiRoutes(), - route.Subresource(user.items), - ) - -From a client perspective ... -Should I hide subresources from the /user/{id} if route.Subresource(user.items), exists ? -Not sure, i should look at what competitors are doing. - -______ -Technical -Now, this highlights a new underlying problem, I dont want router.NewApiRouter to be too huge -A natural solution to this problem would be givin a factory - -factory := ApiRouterFactory(OrmBuilder, CustomSetOfRoute, CustomSetOfConfiguration) -ApiRouter := ApiRouterFactory.Create[Type](route.DefaultApiRoutes(), - route.Subresource(user.items), - SetOfConfiguration -) -Something along these lines, one thing, CustomSetOfRoute might not be a good idea -from personal experience, I want the route.DefaultApiRoutes() for most items except 2 - ________ - - api := router.NewApiRouter( - *orm.NewORM(gormrepository.NewRepository[User](getDB())), - route.DefaultApiRoutes(), - route.Subresource(user.items), - ) - The thing is with this sub resource, - How to add configuration to this sub resource ... - route.Subresource(user.items, ListOfConfiguration...), - - How to give an ORM to this sub resource. - Either I can use the "root level issue" orm, clone it and change the T - but I dont know if that's feasible and that's not a good idea - entity linked though relational logic should be in the same db 99% of the time but there's this 1% - Either, I have to pass it some way or another ... - - This should be the definitive syntax of a subroute item - - route.Subresource[Item]( - *orm.NewORM(gormrepository.NewRepository[Item](getDB())), - route.DefaultApiRoutes(), - //nested subrsource - route.Subresource[Item]( - *orm.NewORM(gormrepository.NewRepository[Item](getDB())), - route.DefaultApiRoutes(), - SetOfConfiguration, - ) - SetOfConfiguration, - ) - - I wonder if I can do something like this - api.AddSubRoute[Item]() instead. - I should, maybe not the generic syntax, but it will make the nested subresource complex. - also - *orm.NewORM(gormrepository.NewRepository[Item](getDB())) should really be - OrmFactory[Item].Create - __________ - diff --git a/README.md b/README.md index 0727ddb..ad88a9f 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,495 @@ -# RESTMAN -Restman takes Golang Structs and create REST routes. -Inspired by Symfony and Api Platform. -Built on top of Gin. - -Restman can be used with any database as long as you implement the builtin repository interface -It come with its own GORM based implementation, compatible with Entity/Model separation but also a more straighforward approach. - -## Features -Fully working structure to REST automated route generation using GIN, recursion and generics -Out of the box GORM based ORM -Firewall implementation allowing to filter who can access/edit which data -Serialization groups to control which property are allowed to be readed or wrote using the generated route - - -## TODO, Ideas for myself and for random contributors -Filtering -groupS override parameter -GraphQL-like PageInfo Object / after, before, first, last, pageof ? -entity.ID UUID compatiblility -InMemory cache with default redis integration -Mongo default Repository -Fix XML serialialization -fix CSV serialialization -Check current golang json serialization speed -check force lowercase for json ? (golang default serializer is like the only thing in the world who does nt force lowercase) -check messagepack -Serializer could be refactord -Somehow hooks could be nice ?? (meh) -The fireWall could have a builtin requireOwnership -performance evaluation could be nice (is there even a place for paralelilsm somewhere ??) \ No newline at end of file +# RestMan + +> A declarative REST API framework for Go that automatically generates routes from structs + +RestMan takes your Go structs and creates fully functional REST APIs with minimal boilerplate. Inspired by Symfony's API Platform, built on top of Gin, and designed with Go generics for type safety. + +[![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://go.dev/) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +## Features + +- 🚀 **Zero Boilerplate** - Full REST API from a single struct +- 🎯 **Type-Safe Generics** - Compile-time type checking with Go 1.23+ generics +- 🔄 **Multi-Format Support** - JSON, JSON-LD (Hydra), XML, CSV, MessagePack +- 🔒 **Security First** - Built-in firewall and fine-grained authorization +- 📦 **Multiple ORMs** - GORM and MongoDB out of the box, extensible for others +- 🎭 **Serialization Groups** - Control field visibility per context +- 🌳 **Nested Resources** - Unlimited subresource nesting +- ⚡ **Batch Operations** - Efficient bulk create/update/delete +- 📄 **Pagination** - Configurable pagination with Hydra metadata +- 🔍 **Sorting** - Multi-field sorting with client control +- 💾 **HTTP & Redis Caching** - Cache-Control headers and Redis cache library + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Core Concepts](#core-concepts) +- [Examples](#examples) +- [Configuration](#configuration) +- [Security](#security) +- [Advanced Usage](#advanced-usage) +- [Contributing](#contributing) +- [Roadmap](#roadmap) + +## Installation + +```bash +go get github.com/philiphil/restman +``` + +**Requirements:** +- Go 1.23 or higher +- A database (SQLite, PostgreSQL, MySQL via GORM, or MongoDB) + +## Quick Start + +### 1. Define Your Entity + +```go +package main + +import ( + "github.com/philiphil/restman/orm/entity" +) + +type Book struct { + entity.BaseEntity + Title string `json:"title" groups:"read,write"` + Author string `json:"author" groups:"read,write"` + ISBN string `json:"isbn" groups:"read"` + PublishedAt string `json:"published_at" groups:"read"` +} + +func (b Book) GetId() entity.ID { return b.Id } +func (b Book) SetId(id any) entity.Entity { + b.Id = entity.CastId(id) + return b +} +``` + +### 2. Create the API + +```go +package main + +import ( + "github.com/gin-gonic/gin" + "github.com/philiphil/restman/orm" + "github.com/philiphil/restman/orm/gormrepository" + "github.com/philiphil/restman/route" + "github.com/philiphil/restman/router" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func main() { + db, _ := gorm.Open(sqlite.Open("books.db"), &gorm.Config{}) + db.AutoMigrate(&Book{}) + + r := gin.Default() + + bookRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Book](db)), + route.DefaultApiRoutes(), + ) + + bookRouter.AllowRoutes(r) + + r.Run(":8080") +} +``` + +### 3. Use Your API + +```bash +# Create a book +curl -X POST http://localhost:8080/api/book \ + -H "Content-Type: application/json" \ + -d '{"title":"The Go Programming Language","author":"Alan Donovan"}' + +# Get all books +curl http://localhost:8080/api/book + +# Get a specific book +curl http://localhost:8080/api/book/1 + +# Update a book +curl -X PUT http://localhost:8080/api/book/1 \ + -H "Content-Type: application/json" \ + -d '{"title":"Updated Title","author":"Alan Donovan"}' + +# Delete a book +curl -X DELETE http://localhost:8080/api/book/1 +``` + +**That's it!** You now have a full REST API with: +- GET `/api/book` - List all books (paginated) +- GET `/api/book/:id` - Get a specific book +- POST `/api/book` - Create a book +- PUT `/api/book/:id` - Full update +- PATCH `/api/book/:id` - Partial update +- DELETE `/api/book/:id` - Delete a book +- HEAD `/api/book/:id` - Check existence +- OPTIONS `/api/book` - Available methods + +## Core Concepts + +### Entity Interface + +Every entity must implement the `entity.Entity` interface: + +```go +type Entity interface { + GetId() ID + SetId(any) Entity +} +``` + +Use `entity.BaseEntity` to get this for free, along with `CreatedAt`, `UpdatedAt`, and `DeletedAt`. + +### Serialization Groups + +Control field visibility using the `groups` tag: + +```go +type User struct { + entity.BaseEntity + Email string `json:"email" groups:"read,write"` + Password string `json:"password" groups:"write"` // Only for input + Token string `json:"token" groups:"admin"` // Only for admins +} +``` + +Groups are applied automatically: +- **POST/PUT/PATCH**: Uses `write` group +- **GET**: Uses `read` group +- Custom groups can be configured per route + +### Repository Pattern + +RestMan uses a repository abstraction, allowing you to swap databases easily: + +```go +// GORM (SQL databases) +gormRepo := gormrepository.NewRepository[Book](db) + +// MongoDB +mongoRepo := mongorepository.NewRepository[Book](collection) + +// Custom implementation +type MyRepo struct{} +func (r MyRepo) FindAll(ctx context.Context) ([]Book, error) { ... } +``` + +### Multi-Format Support + +RestMan automatically negotiates content type based on the `Accept` header: + +```bash +# JSON (default) +curl http://localhost:8080/api/book + +# XML +curl -H "Accept: text/xml" http://localhost:8080/api/book + +# CSV +curl -H "Accept: application/csv" http://localhost:8080/api/book + +# MessagePack +curl -H "Accept: application/msgpack" http://localhost:8080/api/book + +# JSON-LD with Hydra pagination +curl -H "Accept: application/ld+json" http://localhost:8080/api/book +``` + +## Examples + +See the [example/](example/) directory for complete working examples: + +- **[basic_router_test.go](example/basic_router_test.go)** - Minimal setup +- **[basic_firewall_test.go](example/basic_firewall_test.go)** - Authentication and authorization +- **[model_entity_separation_test.go](example/model_entity_separation_test.go)** - Separating database models from API entities +- **[router_conf_test.go](example/router_conf_test.go)** - Advanced configuration +- **[subresources_test.go](example/subresources_test.go)** - Nested resources +- **[batch_operations_test.go](example/batch_operations_test.go)** - Bulk operations +- **[custom_serialization_test.go](example/custom_serialization_test.go)** - Group-based serialization +- **[pagination_sorting_test.go](example/pagination_sorting_test.go)** - Pagination and sorting configuration + +## Configuration + +### Router-Level Configuration + +Set defaults for all routes: + +```go +import "github.com/philiphil/restman/configuration" + +bookRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Book](db)), + route.DefaultApiRoutes(), +) + +config := configuration.DefaultRouterConfiguration(). + ItemPerPage(50). + MaxItemPerPage(500). + RoutePrefix("v1/books"). + RouteName("library"). + AllowClientPagination(true). + AllowClientSorting(true). + DefaultSortOrder(configuration.SortAsc, "title"). + NetworkCachingPolicy(configuration.NetworkCachingPolicy{MaxAge: 3600}) + +bookRouter.Configure(config) +``` + +### Route-Level Configuration + +Override settings for specific operations: + +```go +routes := route.DefaultApiRoutes() + +getConfig := configuration.DefaultRouteConfiguration(). + SerializationGroups("read", "public"). + ItemPerPage(100) + +routes.Get.Configure(getConfig) + +postConfig := configuration.DefaultRouteConfiguration(). + SerializationGroups("write") + +routes.Post.Configure(postConfig) + +bookRouter := router.NewApiRouter(orm, routes) +``` + +### Pagination + +```bash +# Default pagination +GET /api/book?page=2 + +# Custom items per page (if allowed) +GET /api/book?page=1&itemsPerPage=50 +``` + +### Sorting + +```bash +# Sort by title ascending +GET /api/book?order[title]=asc + +# Multiple field sorting +GET /api/book?order[publishedAt]=desc&order[title]=asc +``` + +## Security + +### Authentication with Firewalls + +```go +import ( + "github.com/philiphil/restman/security" +) + +type MyFirewall struct{} + +func (f MyFirewall) ExtractUser(c *gin.Context) (any, *errors.ApiError) { + token := c.GetHeader("Authorization") + if token == "" { + return nil, errors.NewBlockingError(errors.ErrUnauthorized, "Missing token") + } + + user := validateToken(token) // Your validation logic + if user == nil { + return nil, errors.NewBlockingError(errors.ErrUnauthorized, "Invalid token") + } + + return user, nil +} + +bookRouter.SetFirewall(MyFirewall{}) +``` + +### Authorization + +```go +// Control read access +bookRouter.SetReadingRights(func(c *gin.Context, book Book, user any) bool { + if book.Private && book.AuthorID != user.(User).ID { + return false // User can't read this private book + } + return true +}) + +// Control write access +bookRouter.SetWritingRights(func(c *gin.Context, book Book, user any) bool { + return book.AuthorID == user.(User).ID // Only author can modify +}) +``` + +## Advanced Usage + +### Subresources + +Create nested resource routes: + +```go +// Creates routes like: /api/author/:id/books/:id +authorRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Author](db)), + route.DefaultApiRoutes(), +) + +bookRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Book](db)), + route.DefaultApiRoutes(), +) + +authorRouter.AddSubresource(bookRouter) +authorRouter.AllowRoutes(r) +``` + +### Batch Operations + +```bash +# Batch create +POST /api/book/batch +[ + {"title": "Book 1", "author": "Author 1"}, + {"title": "Book 2", "author": "Author 2"} +] + +# Batch get by IDs +GET /api/book/batch?ids=1,2,3 + +# Batch update +PUT /api/book/batch +[ + {"id": 1, "title": "Updated Book 1"}, + {"id": 2, "title": "Updated Book 2"} +] + +# Batch delete +DELETE /api/book/batch?ids=1,2,3 +``` + +### Caching + +**HTTP Caching (Headers)** + +RestMan supports HTTP caching via Cache-Control headers: + +```go +import "github.com/philiphil/restman/configuration" + +bookRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Book](db)), + route.DefaultApiRoutes(), + configuration.NetworkCachingPolicy(3600), // Cache for 1 hour +) +``` + +This automatically sets `Cache-Control: public, max-age=3600` headers on GET requests. + +### Model/Entity Separation + +Keep your database models separate from API representations: + +```go +// Database model (internal) +type BookModel struct { + ID uint + Title string + AuthorID uint + InternalRef string // Not exposed in API +} + +// API entity (external) +type Book struct { + entity.BaseEntity + Title string `json:"title" groups:"read,write"` + Author Author `json:"author" groups:"read"` +} + +func (b BookModel) ToEntity() Book { + return Book{ + BaseEntity: entity.BaseEntity{Id: b.ID}, + Title: b.Title, + Author: fetchAuthor(b.AuthorID), + } +} + +func (b BookModel) FromEntity(book Book) any { + return BookModel{ + ID: book.Id, + Title: book.Title, + AuthorID: book.Author.Id, + } +} +``` + +## Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +### Running Tests + +```bash +# Run all tests +go test ./... + +# Run tests with coverage +go test -cover ./... + +# Run specific test suite +go test ./test/router/... +``` + +## Roadmap + +### TODO/ IDEAS +- [ ] 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 +- [ ] Hooks system for lifecycle events +- [ ] Built-in `requireOwnership` for firewall or something +- [ ] Rate limiting middleware (Ai suggestion) +- [ ] Audit login middleware (Ai suggestion) +- [ ] Validation/constraints (Ai suggestion) +- [ ] Finishing redis implementation +- [ ] OpenAPI/Swagger documentation generation +- [ ] Some UI backoffice ? +- [ ] 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 + +## Acknowledgments + +Inspired by: +- [API Platform](https://api-platform.com/) (PHP/Symfony) \ No newline at end of file diff --git a/cache/cache.go b/cache/cache.go index d6a802f..9e38ff1 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -12,18 +12,21 @@ import ( "github.com/redis/go-redis/v9" ) +// Cache defines the interface for caching entity operations. type Cache[E entity.Entity] interface { Set(ent E) error Get(ent E) (E, error) Delete(ent E) error } +// RedisCache is a Redis-based implementation of the Cache interface. type RedisCache[E entity.Entity] struct { Client *redis.Client entityPrefix string lifetime time.Duration } +// NewRedisCache creates a new Redis cache instance with the specified connection parameters and lifetime. func NewRedisCache[E entity.Entity](addr, password string, db int, lifetime int) *RedisCache[E] { client := redis.NewClient(&redis.Options{ Addr: addr, @@ -45,6 +48,7 @@ func (r *RedisCache[E]) generateCacheKey(ent entity.Entity) string { return fmt.Sprintf("%s:%s", r.entityPrefix, ent.GetId().String()) } +// Set stores an entity in the Redis cache. func (r *RedisCache[E]) Set(ent E) error { key := r.generateCacheKey(ent) data, err := json.Marshal(ent) @@ -55,6 +59,7 @@ func (r *RedisCache[E]) Set(ent E) error { return r.Client.Set(context.Background(), key, data, r.lifetime).Err() } +// Get retrieves an entity from the Redis cache by its ID. func (r *RedisCache[E]) Get(ent E) (E, error) { var result E key := r.generateCacheKey(ent) @@ -70,6 +75,7 @@ func (r *RedisCache[E]) Get(ent E) (E, error) { return result, err } +// Delete removes an entity from the Redis cache. func (r *RedisCache[E]) Delete(ent E) error { key := r.generateCacheKey(ent) return r.Client.Del(context.Background(), key).Err() diff --git a/configuration/configuration.go b/configuration/configuration.go index 1d99767..996b098 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -4,108 +4,220 @@ import ( "strconv" ) +// ConfigurationType defines the type of configuration option being set type ConfigurationType int8 const ( - RouteNameType ConfigurationType = iota //ok - RoutePrefixType //ok - NetworkCachingPolicyType //ok - SerializationGroupsType //ok - - MaxItemPerPageType //ok - ItemPerPageType //ok - ItemPerPageParameterNameType //ok - PaginationType //ok - PaginationClientControlType //ok - PaginationParameterNameType //ok - BatchIdsParameterNameType //ok - PageParameterNameType //ok - - SortingClientControlType //ok - SortingType //ok - SortingParameterNameType //ok - SortableFieldsType //ok - - //unimplemented - TypeEnabledType - DefaultFilteringType - InMemoryCachingPolicyType + // RouteNameType sets the route name (default: entity name in snake_case) + // Example: RouteName("books") generates /api/books + RouteNameType ConfigurationType = iota + + // RoutePrefixType sets the URL prefix for routes (default: "api") + // Example: RoutePrefix("api", "v1") generates /api/v1/entity + RoutePrefixType + + // NetworkCachingPolicyType sets HTTP caching headers (Cache-Control max-age) + // 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 + + // MaxItemPerPageType sets the maximum allowed items per page (default: 1000) + // Prevents clients from requesting too many items at once + MaxItemPerPageType + + // ItemPerPageType sets the default number of items per page (default: 100) + ItemPerPageType + + // ItemPerPageParameterNameType sets the query parameter name for items per page (default: "itemsPerPage") + // Example: ?itemsPerPage=50 + ItemPerPageParameterNameType + + // PaginationType enables or disables pagination (default: enabled) + // When disabled, all results are returned + PaginationType + + // PaginationClientControlType allows clients to control pagination via query params (default: disabled) + // When enabled, clients can use ?page=2&itemsPerPage=50 + PaginationClientControlType + + // PaginationParameterNameType sets the query parameter name for pagination control (default: "pagination") + PaginationParameterNameType + + // BatchIdsParameterNameType sets the query parameter name for batch operations (default: "ids") + // Example: GET /api/entity?ids=1,2,3 + BatchIdsParameterNameType + + // PageParameterNameType sets the query parameter name for page number (default: "page") + // Example: ?page=2 + PageParameterNameType + + // SortingClientControlType allows clients to control sorting via query params (default: enabled) + // When enabled, clients can use ?order[field]=asc + SortingClientControlType + + // SortingType sets the default sort order + // Example: map[string]string{"id": "asc", "createdAt": "desc"} + SortingType + + // SortingParameterNameType sets the query parameter name for sorting (default: "order") + // Example: ?order[title]=asc + SortingParameterNameType + + // SortableFieldsType defines which fields are allowed for sorting (default: "id") + // Whitelist to prevent sorting on sensitive or non-indexed fields + SortableFieldsType + + // Unimplemented configuration types - reserved for future use + BatchLimitType // Will limit the number of items in batch operations + TypeEnabledType // Will enable/disable specific route types + DefaultFilteringType // Will add default filters to queries + InMemoryCachingPolicyType // Will configure in-memory caching ) +// Configuration represents a single configuration option with its type and values. +// Configurations are passed to NewApiRouter to customize router behavior. +// +// Example: +// +// router := router.NewApiRouter( +// orm, +// routes, +// configuration.ItemPerPage(50), +// configuration.MaxItemPerPage(500), +// ) type Configuration struct { Type ConfigurationType Values []string } -// default is 0, no caching -// if you set it to 0, it will be disabled -// Be careful with reading policy +// NetworkCachingPolicy sets the HTTP Cache-Control max-age header in seconds. +// Default is 0 (no caching). Use with caution for frequently changing data. +// +// Example: +// +// configuration.NetworkCachingPolicy(3600) // Cache for 1 hour func NetworkCachingPolicy(seconds int) Configuration { return Configuration{Type: NetworkCachingPolicyType, Values: []string{strconv.Itoa(seconds)}} } -// default is "api" do not enter / manualy -// for api/v1/ use RoutePrefix("api", "v1") +// RoutePrefix sets the URL prefix for all routes. Default is "api". +// Do not include leading or trailing slashes. +// +// Example: +// +// configuration.RoutePrefix("api", "v1") // Generates /api/v1/entity func RoutePrefix(prefix ...string) Configuration { return Configuration{Type: RoutePrefixType, Values: prefix} } -// by default, it is entity name +// RouteName sets the route name. By default, uses the entity name in snake_case. +// +// Example: +// +// configuration.RouteName("books") // Generates /api/books func RouteName(name string) Configuration { return Configuration{Type: RouteNameType, Values: []string{name}} } -// serialization groups +// SerializationGroups defines which field groups to include in serialization. +// Fields must have matching `groups:"group1,group2"` struct tags. +// +// Example: +// +// configuration.SerializationGroups("read", "public") func SerializationGroups(groups ...string) Configuration { return Configuration{Type: SerializationGroupsType, Values: groups} } -// default is 1000 per page +// MaxItemPerPage sets the maximum allowed items per page. Default is 1000. +// Prevents clients from requesting excessive data. +// +// Example: +// +// configuration.MaxItemPerPage(500) func MaxItemPerPage(max int) Configuration { return Configuration{Type: MaxItemPerPageType, Values: []string{strconv.Itoa(max)}} } -// default is 100 per page +// ItemPerPage sets the default number of items per page. Default is 100. +// +// Example: +// +// configuration.ItemPerPage(50) func ItemPerPage(defaultValue int) Configuration { return Configuration{Type: ItemPerPageType, Values: []string{strconv.Itoa(defaultValue)}} } -// default is Enabled -// use to enable/disable pagination -// it is recommended to use this option but you might want to disable it +// Pagination enables or disables pagination. Default is enabled (true). +// When disabled, all results are returned without pagination. +// +// Example: +// +// configuration.Pagination(false) // Disable pagination func Pagination(defaultValue bool) Configuration { return Configuration{Type: PaginationType, Values: []string{strconv.FormatBool(defaultValue)}} } -// default is disabled -// allow/disallow client to force pagination using query string +// PaginationClientControl allows clients to control pagination via query parameters. +// Default is disabled (false). When enabled, clients can use ?page=2&itemsPerPage=50. +// +// Example: +// +// configuration.PaginationClientControl(true) func PaginationClientControl(forced bool) Configuration { return Configuration{Type: PaginationClientControlType, Values: []string{strconv.FormatBool(forced)}} } -// default is "pagination" -// name of the query string parameter used to force pagination +// PaginationParameterName sets the query parameter name for pagination control. +// Default is "pagination". +// +// Example: +// +// configuration.PaginationParameterName("paginate") func PaginationParameterName(name string) Configuration { return Configuration{Type: PaginationParameterNameType, Values: []string{name}} } -// default is "page" +// PageParameterName sets the query parameter name for page number. Default is "page". +// +// Example: +// +// configuration.PageParameterName("p") // Use ?p=2 instead of ?page=2 func PageParameterName(name string) Configuration { return Configuration{Type: PageParameterNameType, Values: []string{name}} } -// default is "itemsPerPage" +// ItemPerPageParameterName sets the query parameter name for items per page. +// Default is "itemsPerPage". +// +// Example: +// +// configuration.ItemPerPageParameterName("limit") // Use ?limit=50 func ItemPerPageParameterName(name string) Configuration { return Configuration{Type: ItemPerPageParameterNameType, Values: []string{name}} } -// default is "ids" +// BatchIdsName sets the query parameter name for batch operations. Default is "ids". +// +// Example: +// +// configuration.BatchIdsName("id") // Use ?id=1,2,3 instead of ?ids=1,2,3 func BatchIdsName(name string) Configuration { return Configuration{Type: BatchIdsParameterNameType, Values: []string{name}} } -// Default is map[string]string{"id": "asc"} -// Converts the map into a slice of strings in the format []string{"id", "asc"} +// Sorting sets the default sort order as a map of field names to direction ("asc" or "desc"). +// Default is map[string]string{"id": "asc"}. +// +// Example: +// +// configuration.Sorting(map[string]string{ +// "createdAt": "desc", +// "title": "asc", +// }) func Sorting(sortingMap map[string]string) Configuration { values := []string{} for key, value := range sortingMap { @@ -114,20 +226,31 @@ func Sorting(sortingMap map[string]string) Configuration { return Configuration{Type: SortingType, Values: values} } -// Default is "sort" -// name of the query string parameter used to sort +// SortingParameterName sets the query parameter name for sorting. Default is "order". +// +// Example: +// +// configuration.SortingParameterName("sort") // Use ?sort[field]=asc func SortingParameterName(name string) Configuration { return Configuration{Type: SortingParameterNameType, Values: []string{name}} } -// Default is true -// allow/disallow client to sort using query string +// SortingClientControl allows clients to control sorting via query parameters. +// Default is enabled (true). When enabled, clients can use ?order[field]=asc. +// +// Example: +// +// configuration.SortingClientControl(false) // Disable client sorting func SortingClientControl(enabled bool) Configuration { return Configuration{Type: SortingClientControlType, Values: []string{strconv.FormatBool(enabled)}} } -// Default is "id" -// name of the field allowed to be used to sort +// SortableFields defines which fields are allowed for sorting. Default is ["id"]. +// Acts as a whitelist to prevent sorting on sensitive or non-indexed fields. +// +// Example: +// +// configuration.SortableFields("id", "title", "createdAt") func SortableFields(fields ...string) Configuration { return Configuration{Type: SortableFieldsType, Values: fields} } diff --git a/configuration/default_configuration b/configuration/default_configuration deleted file mode 100644 index 42ad2a0..0000000 --- a/configuration/default_configuration +++ /dev/null @@ -1,21 +0,0 @@ -package configuration - -// This is the default configuration used by an ApiRouter -func DefaultConfiguration() map[ConfigurationType]Configuration { - return map[ConfigurationType]Configuration{ - RoutePrefixType: RoutePrefix("api"), - NetworkCachingPolicyType: NetworkCachingPolicy(0), - SerializationGroupsType: SerializationGroups(), - PaginationType: Pagination(true), - PageParameterNameType: PageParameterName("page"), - PaginationClientControlType: ForcedPagination(false), - PaginationParameterNameType: PaginationParameterName("pagination"), - ItemPerPageType: ItemPerPage(100), - MaxItemPerPageType: MaxItemPerPage(1000), - BatchIdsParameterNameType: BatchIdsName("ids"), - ItemPerPageParameterNameType: ItemPerPageParameterName("itemsPerPage"), - SortingClientControlType: SortingClientControl(true), - SortingType: Sorting("ASC"), - SortingParameterNameType: SortingParameterName("sort"), - } -} diff --git a/configuration/default_configuration.go b/configuration/default_configuration.go index b48f92a..b7aefe1 100644 --- a/configuration/default_configuration.go +++ b/configuration/default_configuration.go @@ -1,6 +1,6 @@ package configuration -// This is the default configuration used by an ApiRouter +// DefaultConfiguration returns the default configuration map used by an ApiRouter. func DefaultConfiguration() map[ConfigurationType]Configuration { return map[ConfigurationType]Configuration{ RoutePrefixType: RoutePrefix("api"), diff --git a/errors/apiError.go b/errors/apiError.go index 2cdb56d..3c3ad93 100644 --- a/errors/apiError.go +++ b/errors/apiError.go @@ -13,6 +13,7 @@ type ApiError struct { Blocking bool } +// Error implements the error interface for ApiError. func (f ApiError) Error() string { return f.Message } diff --git a/example/batch_operations_test.go b/example/batch_operations_test.go new file mode 100644 index 0000000..daab7de --- /dev/null +++ b/example/batch_operations_test.go @@ -0,0 +1,91 @@ +package example_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "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" +) + +type Product struct { + entity.BaseEntity + Name string `json:"name" groups:"read,write"` + Price float64 `json:"price" groups:"read,write"` + Stock int `json:"stock" groups:"read,write"` +} + +func (p Product) GetId() entity.ID { return p.Id } +func (p Product) SetId(id any) entity.Entity { + p.Id = entity.CastId(id) + return p +} +func (p Product) ToEntity() Product { return p } +func (p Product) FromEntity(e Product) any { return e } + +func getBatchDB() *gorm.DB { + db, err := gorm.Open(sqlite.Open("file:batch_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 TestBatchOperations(t *testing.T) { + db := getBatchDB() + db.AutoMigrate(&Product{}) + + r := gin.New() + r.Use(gin.Recovery()) + + productRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Product](db)), + route.DefaultApiRoutes(), + ) + + productRouter.AllowRoutes(r) + + products := []Product{ + {Name: "Product 1", Price: 10.99, Stock: 100}, + {Name: "Product 2", Price: 20.99, Stock: 50}, + {Name: "Product 3", Price: 30.99, Stock: 25}, + } + + body, _ := json.Marshal(products) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/product/batch", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Batch POST failed: expected 201, got %d", w.Code) + } + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/api/product/batch?ids=1,2,3", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Batch GET failed: expected 200, got %d", w.Code) + } + + var results []Product + json.Unmarshal(w.Body.Bytes(), &results) + + if len(results) != 3 { + t.Errorf("Expected 3 products, got %d", len(results)) + } +} diff --git a/example/custom_serialization_test.go b/example/custom_serialization_test.go new file mode 100644 index 0000000..fc19425 --- /dev/null +++ b/example/custom_serialization_test.go @@ -0,0 +1,112 @@ +package example_test + +import ( + "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" +) + +type User struct { + entity.BaseEntity + Email string `json:"email" groups:"read,write,public"` + Password string `json:"password" groups:"write"` + Token string `json:"token" groups:"admin"` + Name string `json:"name" groups:"read,write,public"` + InternalNotes string `json:"internal_notes" groups:"admin"` +} + +func (u User) GetId() entity.ID { return u.Id } +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) FromEntity(e User) any { return e } + +func getSerializationDB() *gorm.DB { + db, err := gorm.Open(sqlite.Open("file:serialization_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 TestCustomSerialization(t *testing.T) { + db := getSerializationDB() + db.AutoMigrate(&User{}) + + r := gin.New() + r.Use(gin.Recovery()) + + routes := route.DefaultApiRoutes() + + userRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[User](db)), + routes, + configuration.SerializationGroups("read", "public"), + ) + + userRouter.AllowRoutes(r) + + user := User{ + Email: "user@example.com", + Password: "secret123", + Token: "admin-token-xyz", + Name: "John Doe", + InternalNotes: "This is an internal note", + } + db.Create(&user) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/user/1", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + response := w.Body.String() + + if contains(response, "password") || contains(response, "secret123") { + t.Error("Password should not be visible in GET response") + } + + if contains(response, "token") || contains(response, "admin-token-xyz") { + t.Error("Token should not be visible with public groups") + } + + if contains(response, "internal_notes") { + t.Error("Internal notes should not be visible with public groups") + } + + if !contains(response, "email") || !contains(response, "name") { + t.Error("Email and name should be visible with public groups") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && hasSubstring(s, substr)) +} + +func hasSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/example/pagination_sorting_test.go b/example/pagination_sorting_test.go new file mode 100644 index 0000000..95c9c0d --- /dev/null +++ b/example/pagination_sorting_test.go @@ -0,0 +1,109 @@ +package example_test + +import ( + "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" +) + +type Article struct { + entity.BaseEntity + Title string `json:"title" groups:"read,write"` + Content string `json:"content" groups:"read,write"` + PublishedAt string `json:"published_at" groups:"read,write"` + Views int `json:"views" groups:"read"` +} + +func (a Article) GetId() entity.ID { return a.Id } +func (a Article) SetId(id any) entity.Entity { + a.Id = entity.CastId(id) + return a +} +func (a Article) ToEntity() Article { return a } +func (a Article) FromEntity(e Article) any { return e } + +func getPaginationDB() *gorm.DB { + db, err := gorm.Open(sqlite.Open("file:pagination_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 TestPaginationAndSorting(t *testing.T) { + db := getPaginationDB() + db.AutoMigrate(&Article{}) + + r := gin.New() + r.Use(gin.Recovery()) + + articleRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Article](db)), + route.DefaultApiRoutes(), + configuration.ItemPerPage(5), + configuration.MaxItemPerPage(20), + configuration.PaginationClientControl(true), + configuration.SortingClientControl(true), + configuration.Sorting(map[string]string{"published_at": "desc"}), + ) + + articleRouter.AllowRoutes(r) + + for i := 1; i <= 15; i++ { + article := Article{ + Title: "Article " + string(rune(i+'0')), + Content: "Content " + string(rune(i+'0')), + PublishedAt: "2024-01-" + string(rune(i+'0')), + Views: i * 100, + } + db.Create(&article) + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/article?page=1", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var results []Article + json.Unmarshal(w.Body.Bytes(), &results) + + if len(results) != 5 { + t.Errorf("Expected 5 articles per page, got %d", len(results)) + } + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/api/article?page=1&itemsPerPage=10", nil) + r.ServeHTTP(w, req) + + json.Unmarshal(w.Body.Bytes(), &results) + + if len(results) != 10 { + t.Errorf("Expected 10 articles with custom page size, got %d", len(results)) + } + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/api/article?order[title]=asc", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Sorting request failed: expected 200, got %d", w.Code) + } +} diff --git a/example/subresources_test.go b/example/subresources_test.go new file mode 100644 index 0000000..c328eb9 --- /dev/null +++ b/example/subresources_test.go @@ -0,0 +1,93 @@ +package example_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "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" +) + +type Author struct { + entity.BaseEntity + Name string `json:"name" groups:"read,write"` + Email string `json:"email" groups:"read,write"` + Books []Book `json:"books,omitempty" groups:"read" gorm:"foreignKey:AuthorID"` +} + +func (a Author) GetId() entity.ID { return a.Id } +func (a Author) SetId(id any) entity.Entity { + a.Id = entity.CastId(id) + return a +} +func (a Author) ToEntity() Author { return a } +func (a Author) FromEntity(e Author) any { return e } + +type Book struct { + entity.BaseEntity + Title string `json:"title" groups:"read,write"` + ISBN string `json:"isbn" groups:"read,write"` + AuthorID uint `json:"author_id" groups:"write"` +} + +func (b Book) GetId() entity.ID { return b.Id } +func (b Book) SetId(id any) entity.Entity { + b.Id = entity.CastId(id) + return b +} +func (b Book) ToEntity() Book { return b } +func (b Book) FromEntity(e Book) any { return e } + +func getSubresourceDB() *gorm.DB { + db, err := gorm.Open(sqlite.Open("file:subresource_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 TestSubresources(t *testing.T) { + db := getSubresourceDB() + db.AutoMigrate(&Author{}, &Book{}) + + r := gin.New() + r.Use(gin.Recovery()) + + authorRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Author](db)), + route.DefaultApiRoutes(), + ) + + bookRouter := router.NewApiRouter( + *orm.NewORM(gormrepository.NewRepository[Book](db)), + route.DefaultApiRoutes(), + ) + + authorRouter.AddSubresource(bookRouter) + authorRouter.AllowRoutes(r) + + author := Author{Name: "J.K. Rowling", Email: "jk@example.com"} + db.Create(&author) + + book := Book{Title: "Harry Potter", ISBN: "123-456", AuthorID: uint(author.Id)} + db.Create(&book) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/author/1/book/1", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} diff --git a/format/format.go b/format/format.go index 2533f26..cdcec18 100644 --- a/format/format.go +++ b/format/format.go @@ -1,5 +1,6 @@ package format +// Format represents a content type format string. type Format string // Const of default formats handled by RestMan diff --git a/orm/entity/base_entity.go b/orm/entity/base_entity.go index ffadf9f..6d0b1f2 100644 --- a/orm/entity/base_entity.go +++ b/orm/entity/base_entity.go @@ -16,10 +16,12 @@ type BaseEntity struct { Description string `json:"description"` } +// GetId returns the entity's ID. func (e BaseEntity) GetId() ID { return e.Id } +// SetId sets the entity's ID and returns the entity. func (e BaseEntity) SetId(id any) Entity { e.Id = CastId(id) return e diff --git a/orm/entity/id.go b/orm/entity/id.go index a8fdbc5..d36b49e 100644 --- a/orm/entity/id.go +++ b/orm/entity/id.go @@ -7,13 +7,15 @@ import ( // ID is a type that represents an entity's primary identifier type ID uint -// create a const null id at 0 +// NullId represents a null or unset ID value. const NullId ID = 0 +// String converts the ID to its string representation. func (e ID) String() string { return strconv.FormatUint(uint64(e), 10) } +// CastId converts various types to an ID, returning NullId if conversion fails. func CastId(id any) ID { switch v := id.(type) { case ID: diff --git a/orm/gormrepository/gormrepository.go b/orm/gormrepository/gormrepository.go index b508e97..d325db6 100644 --- a/orm/gormrepository/gormrepository.go +++ b/orm/gormrepository/gormrepository.go @@ -11,12 +11,14 @@ import ( "gorm.io/gorm/schema" ) +// NewRepository creates a new GormRepository instance with the provided database connection. func NewRepository[M entity.DatabaseModel[E], E entity.Entity](db *gorm.DB) *GormRepository[M, E] { return &GormRepository[M, E]{ db: db, } } +// GormRepository is a GORM-based implementation of the RestRepository interface. type GormRepository[M entity.DatabaseModel[E], E entity.Entity] struct { db *gorm.DB assocationsLoaded bool @@ -24,15 +26,18 @@ type GormRepository[M entity.DatabaseModel[E], E entity.Entity] struct { associations []string } +// EnablePreloadAssociations enables automatic preloading of entity associations. func (r *GormRepository[M, E]) EnablePreloadAssociations() *GormRepository[M, E] { r.preloadAssocations = true return r } +// DisablePreloadAssociations disables automatic preloading of entity associations. func (r *GormRepository[M, E]) DisablePreloadAssociations() *GormRepository[M, E] { r.preloadAssocations = false return r } +// SetPreloadAssociations sets whether associations should be preloaded. func (r *GormRepository[M, E]) SetPreloadAssociations(association bool) *GormRepository[M, E] { if association { r.EnablePreloadAssociations() @@ -51,6 +56,7 @@ func (r *GormRepository[M, E]) setAssociations(model *M) *GormRepository[M, E] { return r } +// Insert creates a new entity in the database. func (r *GormRepository[M, E]) Insert(ctx context.Context, entity *E) error { var start M model := start.FromEntity(*entity).(M) @@ -64,6 +70,7 @@ func (r *GormRepository[M, E]) Insert(ctx context.Context, entity *E) error { return nil } +// DeleteByID removes an entity from the database by its ID. func (r *GormRepository[M, E]) DeleteByID(ctx context.Context, id entity.ID) error { var start M err := r.db.WithContext(ctx).Delete(&start, &id).Error @@ -74,6 +81,7 @@ func (r *GormRepository[M, E]) DeleteByID(ctx context.Context, id entity.ID) err return nil } +// Upsert creates or updates an entity in the database. func (r *GormRepository[M, E]) Upsert(ctx context.Context, entity *E) error { var start M model := start.FromEntity(*entity).(M) @@ -87,6 +95,7 @@ func (r *GormRepository[M, E]) Upsert(ctx context.Context, entity *E) error { return nil } +// FindByID retrieves an entity by its ID. func (r *GormRepository[M, E]) FindByID(ctx context.Context, id entity.ID) (E, error) { var model M @@ -98,6 +107,7 @@ func (r *GormRepository[M, E]) FindByID(ctx context.Context, id entity.ID) (E, e return model.ToEntity(), nil } +// Find retrieves entities matching the provided specifications. func (r *GormRepository[M, E]) Find(ctx context.Context, specifications ...Specification) ([]E, error) { return r.FindWithLimit(ctx, -1, -1, specifications...) } @@ -126,6 +136,7 @@ func (r *GormRepository[M, E]) getPreWarmDbForSelect(ctx context.Context, specif return dbPrewarm } +// FindWithLimit retrieves entities matching the provided specifications with pagination. func (r *GormRepository[M, E]) FindWithLimit(ctx context.Context, limit int, offset int, specifications ...Specification) ([]E, error) { var models []M @@ -145,10 +156,12 @@ func (r *GormRepository[M, E]) FindWithLimit(ctx context.Context, limit int, off return result, nil } +// FindAll retrieves all entities matching the provided specifications. func (r *GormRepository[M, E]) FindAll(ctx context.Context, specification ...Specification) ([]E, error) { return r.FindWithLimit(ctx, -1, -1, specification...) } +// FindByIDs retrieves multiple entities by their IDs. func (r *GormRepository[M, E]) FindByIDs(ctx context.Context, ids []entity.ID) ([]*E, error) { var models []M err := r.db.WithContext(ctx).Find(&models, ids).Error @@ -164,6 +177,7 @@ func (r *GormRepository[M, E]) FindByIDs(ctx context.Context, ids []entity.ID) ( return result, nil } +// DeleteByIDs removes multiple entities by their IDs. func (r *GormRepository[M, E]) DeleteByIDs(ctx context.Context, ids []entity.ID) error { var start M err := r.db.WithContext(ctx).Delete(&start, ids).Error @@ -173,6 +187,7 @@ func (r *GormRepository[M, E]) DeleteByIDs(ctx context.Context, ids []entity.ID) return nil } +// BatchDelete removes multiple entities in a single operation. func (r *GormRepository[M, E]) BatchDelete(ctx context.Context, entities []*E) error { ids := make([]entity.ID, 0, len(entities)) for _, entity := range entities { @@ -181,6 +196,7 @@ func (r *GormRepository[M, E]) BatchDelete(ctx context.Context, entities []*E) e return r.DeleteByIDs(ctx, ids) } +// BatchUpdate updates multiple entities in a transaction. func (r *GormRepository[M, E]) BatchUpdate(ctx context.Context, entities []*E) error { var models []M for _, entity := range entities { @@ -200,6 +216,7 @@ func (r *GormRepository[M, E]) BatchUpdate(ctx context.Context, entities []*E) e }) } +// BatchInsert creates multiple entities in a transaction. func (r *GormRepository[M, E]) BatchInsert(ctx context.Context, entities []*E) error { var models []M for _, entity := range entities { @@ -219,10 +236,12 @@ func (r *GormRepository[M, E]) BatchInsert(ctx context.Context, entities []*E) e }) } +// GetDB returns the underlying GORM database connection. func (r GormRepository[M, E]) GetDB() *gorm.DB { return r.db } +// NewEntity creates a new empty entity instance. func (r GormRepository[M, E]) NewEntity() E { var entity E return entity diff --git a/orm/gormrepository/rest_adaptater.go b/orm/gormrepository/rest_adaptater.go index 801fb49..0fef50e 100644 --- a/orm/gormrepository/rest_adaptater.go +++ b/orm/gormrepository/rest_adaptater.go @@ -6,22 +6,27 @@ import ( "github.com/philiphil/restman/orm/entity" ) +// Create implements RestRepository.Create by inserting entities. func (r *GormRepository[M, E]) Create(entities []*E) error { return r.BatchInsert(context.Background(), entities) } +// Update implements RestRepository.Update by updating entities. func (r *GormRepository[M, E]) Update(entities []*E) error { return r.BatchUpdate(context.Background(), entities) } +// Read implements RestRepository.Read by finding entities by IDs. func (r *GormRepository[M, E]) Read(ids []entity.ID) ([]*E, error) { return r.FindByIDs(context.Background(), ids) } +// Delete implements RestRepository.Delete by deleting entities. func (r *GormRepository[M, E]) Delete(entities []*E) error { return r.BatchDelete(context.Background(), entities) } +// List implements RestRepository.List by finding entities with pagination and sorting. func (r *GormRepository[M, E]) List(limit int, offset int, order map[string]string) ([]E, error) { orderSpecification := make([]Specification, 0, len(order)) for k, v := range order { @@ -30,10 +35,12 @@ func (r *GormRepository[M, E]) List(limit int, offset int, order map[string]stri return r.FindWithLimit(context.Background(), limit, offset, orderSpecification...) } +// New implements RestRepository.New by creating a new entity instance. func (r *GormRepository[M, E]) New() E { return r.NewEntity() } +// Count implements RestRepository.Count by returning the total number of entities. func (r *GormRepository[M, E]) Count() (i int64, err error) { model := new(M) err = r.getPreWarmDbForSelect(context.TODO()).Model(model).Count(&i).Error diff --git a/orm/gormrepository/specification.go b/orm/gormrepository/specification.go index 35e299d..893ae8d 100644 --- a/orm/gormrepository/specification.go +++ b/orm/gormrepository/specification.go @@ -5,6 +5,7 @@ import ( "strings" ) +// Specification defines the interface for query specifications used to filter database queries. type Specification interface { GetQuery() string GetValues() []any @@ -35,6 +36,7 @@ func (s joinSpecification) GetValues() []any { return values } +// And combines multiple specifications with the AND logical operator. func And(specifications ...Specification) Specification { return joinSpecification{ specifications: specifications, @@ -42,6 +44,7 @@ func And(specifications ...Specification) Specification { } } +// Or combines multiple specifications with the OR logical operator. func Or(specifications ...Specification) Specification { return joinSpecification{ specifications: specifications, @@ -57,6 +60,7 @@ func (s notSpecification) GetQuery() string { return fmt.Sprintf(" NOT (%s)", s.Specification.GetQuery()) } +// Not negates a specification with the NOT logical operator. func Not(specification Specification) Specification { return notSpecification{ specification, @@ -77,6 +81,7 @@ func (s binaryOperatorSpecification[T]) GetValues() []any { return []any{s.value} } +// Equal creates a specification for equality comparison. func Equal[T any](field string, value T) Specification { return binaryOperatorSpecification[T]{ field: field, @@ -85,6 +90,7 @@ func Equal[T any](field string, value T) Specification { } } +// GreaterThan creates a specification for greater-than comparison. func GreaterThan[T comparable](field string, value T) Specification { return binaryOperatorSpecification[T]{ field: field, @@ -93,6 +99,7 @@ func GreaterThan[T comparable](field string, value T) Specification { } } +// GreaterOrEqual creates a specification for greater-than-or-equal comparison. func GreaterOrEqual[T comparable](field string, value T) Specification { return binaryOperatorSpecification[T]{ field: field, @@ -101,6 +108,7 @@ func GreaterOrEqual[T comparable](field string, value T) Specification { } } +// LessThan creates a specification for less-than comparison. func LessThan[T comparable](field string, value T) Specification { return binaryOperatorSpecification[T]{ field: field, @@ -109,6 +117,7 @@ func LessThan[T comparable](field string, value T) Specification { } } +// LessOrEqual creates a specification for less-than-or-equal comparison. func LessOrEqual[T comparable](field string, value T) Specification { return binaryOperatorSpecification[T]{ field: field, @@ -117,6 +126,7 @@ func LessOrEqual[T comparable](field string, value T) Specification { } } +// In creates a specification for checking if a field value is in a list. func In[T any](field string, value []T) Specification { return binaryOperatorSpecification[[]T]{ field: field, @@ -125,6 +135,7 @@ func In[T any](field string, value []T) Specification { } } +// Like creates a specification for case-sensitive pattern matching. func Like[T any](field string, value T) Specification { return binaryOperatorSpecification[T]{ field: field, @@ -133,6 +144,7 @@ func Like[T any](field string, value T) Specification { } } +// Ilike creates a specification for case-insensitive pattern matching. func Ilike[T any](field string, value T) Specification { return binaryOperatorSpecification[T]{ field: field, @@ -151,9 +163,11 @@ func (s stringSpecification) GetValues() []any { return nil } +// IsNull creates a specification for checking if a field is NULL. func IsNull(field string) Specification { return stringSpecification(fmt.Sprintf("%s IS NULL", field)) } +// IsNotNull creates a specification for checking if a field is NOT NULL. func IsNotNull(field string) Specification { return stringSpecification(fmt.Sprintf("%s IS NOT NULL", field)) } @@ -171,6 +185,7 @@ func (s orderSpecification) GetValues() []any { return nil } +// OrderBy creates a specification for sorting results by a field and direction. func OrderBy(field string, direction string) Specification { return orderSpecification{ field: field, diff --git a/orm/gormrepository/utils.go b/orm/gormrepository/utils.go index bd8ce36..590b728 100644 --- a/orm/gormrepository/utils.go +++ b/orm/gormrepository/utils.go @@ -1,5 +1,6 @@ package gormrepository +// ChunkSlice splits a slice into smaller chunks of the specified size. func ChunkSlice[T any](slice []T, chunkSize int) [][]T { var chunks [][]T for i := 0; i < len(slice); i += chunkSize { diff --git a/orm/mongorepository/mongorepository.go b/orm/mongorepository/mongorepository.go index 66ca21f..c4b8290 100644 --- a/orm/mongorepository/mongorepository.go +++ b/orm/mongorepository/mongorepository.go @@ -9,16 +9,19 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) +// NewRepository creates a new MongoRepository instance with the provided MongoDB collection. func NewRepository[M entity.DatabaseModel[E], E entity.Entity](collection *mongo.Collection) *MongoRepository[M, E] { return &MongoRepository[M, E]{ collection: collection, } } +// MongoRepository is a MongoDB-based implementation of the RestRepository interface. type MongoRepository[M entity.DatabaseModel[E], E entity.Entity] struct { collection *mongo.Collection } +// Insert creates a new entity in the MongoDB collection. func (r *MongoRepository[M, E]) Insert(ctx context.Context, entity *E) error { var start M model := start.FromEntity(*entity).(M) @@ -33,12 +36,14 @@ func (r *MongoRepository[M, E]) Insert(ctx context.Context, entity *E) error { return nil } +// DeleteByID removes an entity from the MongoDB collection by its ID. func (r *MongoRepository[M, E]) DeleteByID(ctx context.Context, id entity.ID) error { filter := bson.M{"_id": id} _, err := r.collection.DeleteOne(ctx, filter) return err } +// Upsert creates or updates an entity in the MongoDB collection. func (r *MongoRepository[M, E]) Upsert(ctx context.Context, entity *E) error { var start M model := start.FromEntity(*entity).(M) @@ -55,6 +60,7 @@ func (r *MongoRepository[M, E]) Upsert(ctx context.Context, entity *E) error { return nil } +// FindByID retrieves an entity by its ID from the MongoDB collection. func (r *MongoRepository[M, E]) FindByID(ctx context.Context, id entity.ID) (E, error) { var model M filter := bson.M{"_id": id} @@ -67,6 +73,7 @@ func (r *MongoRepository[M, E]) FindByID(ctx context.Context, id entity.ID) (E, return model.ToEntity(), nil } +// Find retrieves entities matching the provided specifications. func (r *MongoRepository[M, E]) Find(ctx context.Context, specifications ...Specification) ([]E, error) { return r.FindWithLimit(ctx, -1, -1, specifications...) } @@ -111,6 +118,7 @@ func (r *MongoRepository[M, E]) buildOptions(limit int, offset int, specificatio return opts } +// FindWithLimit retrieves entities matching the provided specifications with pagination. func (r *MongoRepository[M, E]) FindWithLimit(ctx context.Context, limit int, offset int, specifications ...Specification) ([]E, error) { filter := r.buildFilter(specifications) opts := r.buildOptions(limit, offset, specifications) @@ -134,10 +142,12 @@ func (r *MongoRepository[M, E]) FindWithLimit(ctx context.Context, limit int, of return result, nil } +// FindAll retrieves all entities matching the provided specifications. func (r *MongoRepository[M, E]) FindAll(ctx context.Context, specification ...Specification) ([]E, error) { return r.FindWithLimit(ctx, -1, -1, specification...) } +// FindByIDs retrieves multiple entities by their IDs. func (r *MongoRepository[M, E]) FindByIDs(ctx context.Context, ids []entity.ID) ([]*E, error) { filter := bson.M{"_id": bson.M{"$in": ids}} @@ -161,12 +171,14 @@ func (r *MongoRepository[M, E]) FindByIDs(ctx context.Context, ids []entity.ID) return result, nil } +// DeleteByIDs removes multiple entities by their IDs. func (r *MongoRepository[M, E]) DeleteByIDs(ctx context.Context, ids []entity.ID) error { filter := bson.M{"_id": bson.M{"$in": ids}} _, err := r.collection.DeleteMany(ctx, filter) return err } +// BatchDelete removes multiple entities in a single operation. func (r *MongoRepository[M, E]) BatchDelete(ctx context.Context, entities []*E) error { ids := make([]entity.ID, 0, len(entities)) for _, entity := range entities { @@ -175,6 +187,7 @@ func (r *MongoRepository[M, E]) BatchDelete(ctx context.Context, entities []*E) return r.DeleteByIDs(ctx, ids) } +// BatchUpdate updates multiple entities. func (r *MongoRepository[M, E]) BatchUpdate(ctx context.Context, entities []*E) error { for _, entity := range entities { if err := r.Upsert(ctx, entity); err != nil { @@ -184,6 +197,7 @@ func (r *MongoRepository[M, E]) BatchUpdate(ctx context.Context, entities []*E) return nil } +// BatchInsert creates multiple entities in a single operation. func (r *MongoRepository[M, E]) BatchInsert(ctx context.Context, entities []*E) error { if len(entities) == 0 { return nil @@ -208,15 +222,18 @@ func (r *MongoRepository[M, E]) BatchInsert(ctx context.Context, entities []*E) return nil } +// GetCollection returns the underlying MongoDB collection. func (r *MongoRepository[M, E]) GetCollection() *mongo.Collection { return r.collection } +// NewEntity creates a new empty entity instance. func (r *MongoRepository[M, E]) NewEntity() E { var entity E return entity } +// CountWithSpecifications returns the number of entities matching the provided specifications. func (r *MongoRepository[M, E]) CountWithSpecifications(ctx context.Context, specifications ...Specification) (int64, error) { filter := r.buildFilter(specifications) return r.collection.CountDocuments(ctx, filter) diff --git a/orm/mongorepository/rest_adapter.go b/orm/mongorepository/rest_adapter.go index cb73928..aa20277 100644 --- a/orm/mongorepository/rest_adapter.go +++ b/orm/mongorepository/rest_adapter.go @@ -6,22 +6,27 @@ import ( "github.com/philiphil/restman/orm/entity" ) +// Create implements RestRepository.Create by inserting entities. func (r *MongoRepository[M, E]) Create(entities []*E) error { return r.BatchInsert(context.Background(), entities) } +// Update implements RestRepository.Update by updating entities. func (r *MongoRepository[M, E]) Update(entities []*E) error { return r.BatchUpdate(context.Background(), entities) } +// Read implements RestRepository.Read by finding entities by IDs. func (r *MongoRepository[M, E]) Read(ids []entity.ID) ([]*E, error) { return r.FindByIDs(context.Background(), ids) } +// Delete implements RestRepository.Delete by deleting entities. func (r *MongoRepository[M, E]) Delete(entities []*E) error { return r.BatchDelete(context.Background(), entities) } +// List implements RestRepository.List by finding entities with pagination and sorting. func (r *MongoRepository[M, E]) List(limit int, offset int, order map[string]string) ([]E, error) { orderSpecifications := make([]Specification, 0, len(order)) for k, v := range order { @@ -30,10 +35,12 @@ func (r *MongoRepository[M, E]) List(limit int, offset int, order map[string]str return r.FindWithLimit(context.Background(), limit, offset, orderSpecifications...) } +// New implements RestRepository.New by creating a new entity instance. func (r *MongoRepository[M, E]) New() E { return r.NewEntity() } +// Count implements RestRepository.Count by returning the total number of documents. func (r *MongoRepository[M, E]) Count() (int64, error) { return r.collection.CountDocuments(context.Background(), map[string]interface{}{}) } diff --git a/orm/mongorepository/specification.go b/orm/mongorepository/specification.go index 87d2810..c323f8f 100644 --- a/orm/mongorepository/specification.go +++ b/orm/mongorepository/specification.go @@ -4,6 +4,7 @@ import ( "go.mongodb.org/mongo-driver/bson" ) +// Specification defines the interface for MongoDB query specifications. type Specification interface { GetFilter() bson.M GetSort() bson.D @@ -62,6 +63,7 @@ func (s joinSpecification) GetSort() bson.D { return nil } +// And combines multiple specifications with the MongoDB $and operator. func And(specifications ...Specification) Specification { return joinSpecification{ specifications: specifications, @@ -69,6 +71,7 @@ func And(specifications ...Specification) Specification { } } +// Or combines multiple specifications with the MongoDB $or operator. func Or(specifications ...Specification) Specification { return joinSpecification{ specifications: specifications, @@ -91,72 +94,84 @@ func (s notSpecification) GetSort() bson.D { return nil } +// Not negates a specification using the MongoDB $nor operator. func Not(specification Specification) Specification { return notSpecification{ specification, } } +// Equal creates a specification for equality comparison in MongoDB. func Equal[T any](field string, value T) Specification { return filterSpecification{ filter: bson.M{field: value}, } } +// GreaterThan creates a specification for greater-than comparison using MongoDB $gt operator. func GreaterThan[T any](field string, value T) Specification { return filterSpecification{ filter: bson.M{field: bson.M{"$gt": value}}, } } +// GreaterOrEqual creates a specification for greater-than-or-equal comparison using MongoDB $gte operator. func GreaterOrEqual[T any](field string, value T) Specification { return filterSpecification{ filter: bson.M{field: bson.M{"$gte": value}}, } } +// LessThan creates a specification for less-than comparison using MongoDB $lt operator. func LessThan[T any](field string, value T) Specification { return filterSpecification{ filter: bson.M{field: bson.M{"$lt": value}}, } } +// LessOrEqual creates a specification for less-than-or-equal comparison using MongoDB $lte operator. func LessOrEqual[T any](field string, value T) Specification { return filterSpecification{ filter: bson.M{field: bson.M{"$lte": value}}, } } +// In creates a specification for checking if a field value is in a list using MongoDB $in operator. func In[T any](field string, values []T) Specification { return filterSpecification{ filter: bson.M{field: bson.M{"$in": values}}, } } +// Like creates a specification for case-sensitive pattern matching using MongoDB $regex operator. func Like(field string, pattern string) Specification { return filterSpecification{ filter: bson.M{field: bson.M{"$regex": pattern, "$options": ""}}, } } +// Ilike creates a specification for case-insensitive pattern matching using MongoDB $regex operator with i option. func Ilike(field string, pattern string) Specification { return filterSpecification{ filter: bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, } } +// IsNull creates a specification for checking if a field is null in MongoDB. func IsNull(field string) Specification { return filterSpecification{ filter: bson.M{field: nil}, } } +// IsNotNull creates a specification for checking if a field is not null using MongoDB $ne operator. func IsNotNull(field string) Specification { return filterSpecification{ filter: bson.M{field: bson.M{"$ne": nil}}, } } +// OrderBy creates a specification for sorting results by a field and direction in MongoDB. func OrderBy(field string, direction string) Specification { order := 1 if direction == "desc" || direction == "DESC" { @@ -168,6 +183,7 @@ func OrderBy(field string, direction string) Specification { } } +// Custom creates a specification from a custom MongoDB filter. func Custom(filter bson.M) Specification { return filterSpecification{ filter: filter, diff --git a/orm/orm.go b/orm/orm.go index 19acd1b..c37d6be 100644 --- a/orm/orm.go +++ b/orm/orm.go @@ -12,16 +12,19 @@ type ORM[T entity.Entity] struct { Repo RestRepository[entity.DatabaseModel[T], T] } +// NewORM creates a new ORM instance with the provided repository. func NewORM[T entity.Entity](repo RestRepository[entity.DatabaseModel[T], T]) *ORM[T] { return &ORM[T]{ Repo: repo, } } +// GetAll retrieves all entities from the repository with optional sorting. func (r *ORM[T]) GetAll(sort map[string]string) ([]T, error) { return r.Repo.List(-1, -1, sort) } +// GetByID retrieves a single entity by its ID. func (r *ORM[T]) GetByID(id any) (*T, error) { elem, err := r.Repo.Read([]entity.ID{entity.CastId(id)}) @@ -34,26 +37,32 @@ func (r *ORM[T]) GetByID(id any) (*T, error) { return elem[0], nil } +// GetPaginatedList retrieves a paginated list of entities with optional sorting. func (r *ORM[T]) GetPaginatedList(itemPerPage int, page int, sort map[string]string) ([]T, error) { return r.Repo.List(itemPerPage, itemPerPage*page, sort) } +// Count returns the total number of entities in the repository. func (r *ORM[T]) Count() (int64, error) { return r.Repo.Count() } +// Create persists one or more new entities to the repository. func (r *ORM[T]) Create(item ...*T) error { return r.Repo.Create(item) } +// Update modifies one or more existing entities in the repository. func (r *ORM[T]) Update(item ...*T) error { return r.Repo.Update(item) } +// Delete removes one or more entities from the repository. func (r *ORM[T]) Delete(item ...*T) error { return r.Repo.Delete(item) } +// FindByIDs retrieves multiple entities by their IDs, returning an error if not all are found. func (r *ORM[T]) FindByIDs(ids []entity.ID) ([]*T, error) { list, err := r.Repo.Read(ids) if err != nil { @@ -65,6 +74,7 @@ func (r *ORM[T]) FindByIDs(ids []entity.ID) ([]*T, error) { return list, nil } +// NewEntity creates a new empty entity instance. func (r *ORM[T]) NewEntity() T { return r.Repo.New() } diff --git a/orm/repository.go b/orm/repository.go index f726b0f..3bb159a 100644 --- a/orm/repository.go +++ b/orm/repository.go @@ -5,8 +5,7 @@ import ( "github.com/philiphil/restman/orm/entity" ) -//RestRepository - +// RestRepository defines the interface for database operations on entities. type RestRepository[M entity.DatabaseModel[E], E entity.Entity] interface { Create(entities []*E) error Read(ids []entity.ID) ([]*E, error) diff --git a/route/route_construct.go b/route/route_construct.go index 92f645e..6b602eb 100644 --- a/route/route_construct.go +++ b/route/route_construct.go @@ -6,6 +6,7 @@ import ( "github.com/philiphil/restman/configuration" ) +// NewRoute creates a new Route with the specified route type and optional configurations. func NewRoute(routeType RouteType, configurations ...configuration.Configuration) Route { c := Route{} c.RouteType = routeType @@ -15,10 +16,7 @@ func NewRoute(routeType RouteType, configurations ...configuration.Configuration return c } -// DefaultApiRoutes returns a map of default routes -// Get, GetList, Post, Put, Patch, Delete, Head, Options -// with empty configurations -// This is useful for creating a default ApiRouter with the standards operations +// DefaultApiRoutes returns a map of default CRUD routes (Get, GetList, Post, Put, Patch, Delete, Head, Options) with empty configurations. func DefaultApiRoutes() map[RouteType]Route { return map[RouteType]Route{ Get: NewRoute(Get), @@ -32,13 +30,14 @@ func DefaultApiRoutes() map[RouteType]Route { } } +// AllApiRoutes returns a map containing all default routes and batch operation routes. func AllApiRoutes() map[RouteType]Route { mergedRoutes := DefaultApiRoutes() maps.Copy(mergedRoutes, BatchOperations()) return mergedRoutes } -// BatchOperations +// BatchOperations returns a map of batch operation routes (BatchDelete, BatchPut, BatchPatch, BatchPost, BatchGet). func BatchOperations() map[RouteType]Route { return map[RouteType]Route{ BatchDelete: NewRoute(BatchDelete), diff --git a/route/route_type.go b/route/route_type.go index 3c2f5d6..552b575 100644 --- a/route/route_type.go +++ b/route/route_type.go @@ -2,6 +2,7 @@ package route import "fmt" +// RouteType represents the type of HTTP route operation. type RouteType int8 const ( @@ -28,6 +29,7 @@ const ( BatchDelete ) +// String returns the HTTP method name for the RouteType. func (e RouteType) String() string { switch e { case Patch: diff --git a/router/apirouter.go b/router/apirouter.go index 7de473d..d6dddac 100644 --- a/router/apirouter.go +++ b/router/apirouter.go @@ -88,6 +88,8 @@ 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 { @@ -156,6 +158,7 @@ func NewApiRouter[T entity.Entity](orm orm.ORM[T], routes map[route.RouteType]ro return router } +// TrimSlash removes leading and trailing slashes from a string. func TrimSlash(s string) string { return strings.TrimSuffix(strings.TrimPrefix(s, "/"), "/") } @@ -174,6 +177,7 @@ func (r *ApiRouter[T]) Route(routeType ...route.RouteType) (name string) { return name } +// AddFirewall adds one or more firewalls to this ApiRouter for authentication and authorization. func (r *ApiRouter[T]) AddFirewall(firewall ...security.Firewall) { r.Firewalls = append(r.Firewalls, firewall...) } diff --git a/router/batchGet.go b/router/batchGet.go index ff1fb4f..e9cb5b2 100644 --- a/router/batchGet.go +++ b/router/batchGet.go @@ -10,9 +10,8 @@ import ( "github.com/philiphil/restman/route" ) -// what is the difference between BatchGet and GetList? -// BatchGet is a route that returns a list of objects using given ids -// GetList is a route that returns a list of objects using pagination +// 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 { @@ -34,6 +33,7 @@ func (r *ApiRouter[T]) IsBatchGetOrGetList(c *gin.Context) route.RouteType { 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 { @@ -43,6 +43,8 @@ func (r *ApiRouter[T]) GetListOrBatchGet(c *gin.Context) { } } +// 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] @@ -58,6 +60,7 @@ func (r *ApiRouter[T]) GetIds(c *gin.Context) []string { return idsValues } +// BatchGet handles GET requests for multiple entities by their IDs. func (r *ApiRouter[T]) BatchGet(c *gin.Context) { idsValues := r.GetIds(c) diff --git a/router/batchPatch.go b/router/batchPatch.go index ae1f032..7131795 100644 --- a/router/batchPatch.go +++ b/router/batchPatch.go @@ -8,6 +8,7 @@ import ( "github.com/philiphil/restman/route" ) +// BatchPatch handles PATCH requests for multiple entities, partially updating existing entities. func (r *ApiRouter[T]) BatchPatch(c *gin.Context) { var entities []*T if err := UnserializeBodyAndMerge_A(c, &entities); err != nil { diff --git a/router/batchPut.go b/router/batchPut.go index ca75e05..e8f74e0 100644 --- a/router/batchPut.go +++ b/router/batchPut.go @@ -8,6 +8,7 @@ import ( "github.com/philiphil/restman/route" ) +// BatchPut handles PUT requests for multiple entities, fully replacing existing entities. func (r *ApiRouter[T]) BatchPut(c *gin.Context) { var entities []*T if err := UnserializeBodyAndMerge_A(c, &entities); err != nil { diff --git a/router/caching.go b/router/caching.go index cb37093..7967301 100644 --- a/router/caching.go +++ b/router/caching.go @@ -7,6 +7,7 @@ import ( "github.com/philiphil/restman/security" ) +// HandleCaching sets appropriate Cache-Control and Etag headers based on route configuration. func (r *ApiRouter[T]) HandleCaching(route route.RouteType, c *gin.Context) { entity := r.Orm.NewEntity() visibility := "public" diff --git a/router/delete.go b/router/delete.go index 85d5961..b34356f 100644 --- a/router/delete.go +++ b/router/delete.go @@ -5,6 +5,7 @@ import ( "github.com/philiphil/restman/errors" ) +// Delete handles HTTP DELETE requests to remove a single entity by ID. func (r *ApiRouter[T]) Delete(c *gin.Context) { id := c.Param("id") object, err := r.Orm.GetByID(id) diff --git a/router/get.go b/router/get.go index 869bbbc..3435924 100644 --- a/router/get.go +++ b/router/get.go @@ -7,6 +7,7 @@ import ( "github.com/philiphil/restman/route" ) +// Get handles HTTP GET requests to retrieve a single entity by ID. func (r *ApiRouter[T]) Get(c *gin.Context) { object, err := r.Orm.GetByID(c.Param("id")) if err != nil { diff --git a/router/getList.go b/router/getList.go index c4f4619..44f8ba9 100644 --- a/router/getList.go +++ b/router/getList.go @@ -12,6 +12,7 @@ import ( "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 { @@ -39,6 +40,7 @@ func (r *ApiRouter[T]) IsPaginationEnabled(c *gin.Context) (bool, error) { 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 { @@ -51,6 +53,7 @@ func (r *ApiRouter[T]) GetPage(c *gin.Context) (int, error) { 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 { @@ -75,6 +78,7 @@ func (r *ApiRouter[T]) GetItemPerPage(c *gin.Context) (int, error) { 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) @@ -116,7 +120,7 @@ func (r *ApiRouter[T]) GetSortOrder(c *gin.Context) (map[string]string, error) { 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) { + if (order != "ASC" && order != "DESC") || !slices.Contains(SortableFields.Values, field) { return nil, errors.ErrBadRequest } sortParams[field] = order @@ -128,6 +132,7 @@ func (r *ApiRouter[T]) GetSortOrder(c *gin.Context) (map[string]string, error) { 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) if err != nil { diff --git a/router/head.go b/router/head.go index b47e57a..7f0e277 100644 --- a/router/head.go +++ b/router/head.go @@ -10,6 +10,7 @@ import ( "github.com/philiphil/restman/serializer" ) +// Head handles HTTP HEAD requests to retrieve entity metadata without the response body. func (r *ApiRouter[T]) Head(c *gin.Context) { object, err := r.Orm.GetByID(c.Param("id")) if err != nil { diff --git a/router/header.go b/router/header.go index 3fde1d8..ab5d808 100644 --- a/router/header.go +++ b/router/header.go @@ -9,11 +9,13 @@ import ( "github.com/philiphil/restman/format" ) +// MediaType represents a media type with its quality weight from the Accept header. type MediaType struct { Type string Weight float64 } +// 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 @@ -55,6 +57,7 @@ func ParseAcceptHeader(acceptHeader string) (format.Format, error) { return format.Undefined, errors.ErrNotAcceptable } +// ParseTypeFromString converts a media type string to a Format constant. func ParseTypeFromString(str string) format.Format { if str == "" { return format.Undefined diff --git a/router/json_ld_collection.go b/router/json_ld_collection.go index ebfac48..5870cfd 100644 --- a/router/json_ld_collection.go +++ b/router/json_ld_collection.go @@ -5,6 +5,7 @@ import ( "strings" ) +// JsonldCollection creates a Hydra-compliant JSON-LD collection with pagination metadata. func JsonldCollection[T any](items []T, currentUrl string, currentPage int, params map[string]string, maxpage int) (m map[string]any) { m = map[string]any{} m["hydra:totalItems"] = len(items) @@ -39,6 +40,7 @@ func JsonldCollection[T any](items []T, currentUrl string, currentPage int, para return m } +// Max returns the maximum value from the provided integers. func Max(vars ...int) int { max := vars[0] diff --git a/router/options.go b/router/options.go index 90f71be..69b5d31 100644 --- a/router/options.go +++ b/router/options.go @@ -5,6 +5,7 @@ import ( "github.com/philiphil/restman/route" ) +// Options handles HTTP OPTIONS requests to return allowed HTTP methods for the resource. func (r *ApiRouter[T]) Options(c *gin.Context) { allowed := "" for _, method := range r.Routes { diff --git a/router/patch.go b/router/patch.go index f57413e..2d209b2 100644 --- a/router/patch.go +++ b/router/patch.go @@ -6,6 +6,7 @@ import ( "github.com/philiphil/restman/orm/entity" ) +// Patch handles HTTP PATCH requests to partially update an existing entity. func (r *ApiRouter[T]) Patch(c *gin.Context) { id := c.Param("id") obj, err := r.Orm.GetByID(id) diff --git a/router/post.go b/router/post.go index 8581057..198056a 100644 --- a/router/post.go +++ b/router/post.go @@ -7,6 +7,7 @@ import ( "github.com/philiphil/restman/route" ) +// Post handles HTTP POST requests to create one or more new entities. func (r *ApiRouter[T]) Post(c *gin.Context) { single := true var entities []*T diff --git a/router/put.go b/router/put.go index 1d5e071..42369ea 100644 --- a/router/put.go +++ b/router/put.go @@ -6,6 +6,7 @@ import ( "github.com/philiphil/restman/orm/entity" ) +// Put handles HTTP PUT requests to replace or create an entity at a specific ID. func (r *ApiRouter[T]) Put(c *gin.Context) { id := c.Param("id") obj, err := r.Orm.GetByID(id) diff --git a/router/security.go b/router/security.go index 5b10d7a..b19ba5c 100644 --- a/router/security.go +++ b/router/security.go @@ -6,6 +6,7 @@ import ( "github.com/philiphil/restman/security" ) +// FirewallCheck executes all configured firewalls to authenticate and retrieve the current user. func (r *ApiRouter[T]) FirewallCheck(c *gin.Context) (security.User, error) { var user security.User var err error @@ -24,6 +25,7 @@ func (r *ApiRouter[T]) FirewallCheck(c *gin.Context) (security.User, error) { return user, nil } +// ReadingCheck verifies that the authenticated user has permission to read the specified object. func (r *ApiRouter[T]) ReadingCheck(c *gin.Context, object *T) error { user, err := r.FirewallCheck(c) if err != nil { @@ -40,6 +42,7 @@ func (r *ApiRouter[T]) ReadingCheck(c *gin.Context, object *T) error { return nil } +// WritingCheck verifies that the authenticated user has permission to modify the specified object. func (r *ApiRouter[T]) WritingCheck(c *gin.Context, object *T) error { user, err := r.FirewallCheck(c) if err != nil { diff --git a/router/unserialize_body.go b/router/unserialize_body.go index 380662e..c42cd51 100644 --- a/router/unserialize_body.go +++ b/router/unserialize_body.go @@ -9,6 +9,7 @@ import ( "github.com/philiphil/restman/serializer" ) +// UnserializeBodyAndMerge deserializes the request body and merges it with the provided entity. func UnserializeBodyAndMerge[T any](c *gin.Context, e *T) error { serializedData, err := io.ReadAll(c.Request.Body) bodyReader := bytes.NewReader(serializedData) @@ -24,6 +25,7 @@ func UnserializeBodyAndMerge[T any](c *gin.Context, e *T) error { return nil } +// 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 { serializedData, err := io.ReadAll(c.Request.Body) bodyReader := bytes.NewReader(serializedData) diff --git a/security/authorization.go b/security/authorization.go index af90cd2..d17cf4d 100644 --- a/security/authorization.go +++ b/security/authorization.go @@ -9,8 +9,9 @@ import ( // it takes a user and an entity and returns a boolean type AuthorizationFunction func(User, entity.Entity) bool -// AuthorizationRequired is a default implementation of the AuthorizationFunction +// AuthenticationRequired is a default implementation of the AuthorizationFunction // it always returns true because it is reached only if the user is authenticated +// AuthenticationRequired validates that a user is authenticated before accessing an entity. func AuthenticationRequired(user User, object entity.Entity) bool { return true } diff --git a/security/security_interfaces.go b/security/security_interfaces.go index 07f4039..47b055e 100644 --- a/security/security_interfaces.go +++ b/security/security_interfaces.go @@ -12,13 +12,13 @@ type WritingRights interface { GetWritingRights() AuthorizationFunction } -// Check if the object implements the ReadingRights interface +// HasReadingRights checks if the object implements the ReadingRights interface. func HasReadingRights(obj any) (ReadingRights, bool) { rr, ok := obj.(ReadingRights) return rr, ok } -// Check if the object implements the WritingRights interface +// HasWritingRights checks if the object implements the WritingRights interface. func HasWritingRights(obj any) (WritingRights, bool) { rr, ok := obj.(WritingRights) return rr, ok diff --git a/serializer/deserialize.go b/serializer/deserialize.go index 4596f0e..151192f 100644 --- a/serializer/deserialize.go +++ b/serializer/deserialize.go @@ -12,7 +12,7 @@ import ( "github.com/philiphil/restman/serializer/filter" ) -// Deserializer encapsulates the deserialization logic +// Deserialize converts a string representation into an object in the configured format. func (s *Serializer) Deserialize(data string, obj any) error { if !isPointer(obj) { return fmt.Errorf("object must be pointer") @@ -92,8 +92,7 @@ func (s *Serializer) deserializeXML(data string, obj any) error { return xml.Unmarshal([]byte(data), obj) } -// MergeObjects merges two objects together -// Both target and source must be pointers +// MergeObjects merges source object fields into target object, both must be pointers. func (s *Serializer) MergeObjects(target any, source any) error { targetValue := reflect.ValueOf(target) sourceValue := reflect.ValueOf(source) @@ -176,6 +175,7 @@ func shouldExclude(field reflect.Value) bool { return false } +// DeserializeAndMerge deserializes data and merges it into the target object. func (s *Serializer) DeserializeAndMerge(data string, target any) error { source := reflect.New(reflect.TypeOf(target).Elem()).Interface() diff --git a/serializer/filter/field_utils.go b/serializer/filter/field_utils.go index f31b365..fdf9b84 100644 --- a/serializer/filter/field_utils.go +++ b/serializer/filter/field_utils.go @@ -5,6 +5,7 @@ import ( "strings" ) +// IsFieldIncluded checks if a struct field should be included based on its group tags and the provided groups. func IsFieldIncluded(field reflect.StructField, groups []string) bool { if len(groups) == 0 { return true //No filtration then @@ -31,14 +32,17 @@ func isFieldExported(field reflect.StructField) bool { return field.PkgPath == "" } +// IsStruct checks if a type is a struct, dereferencing pointers if necessary. func IsStruct(t reflect.Type) bool { return DereferenceTypeIfPointer(t).Kind() == reflect.Struct } +// IsList checks if a type is a slice or array, dereferencing pointers if necessary. func IsList(t reflect.Type) bool { return DereferenceTypeIfPointer(t).Kind() == reflect.Slice || DereferenceTypeIfPointer(t).Kind() == reflect.Array } +// IsMap checks if a type is a map, dereferencing pointers if necessary. func IsMap(t reflect.Type) bool { return DereferenceTypeIfPointer(t).Kind() == reflect.Map } @@ -47,6 +51,7 @@ func isAnonymous(field reflect.StructField) bool { return field.Anonymous } +// DereferenceValueIfPointer recursively dereferences a value if it is a pointer. func DereferenceValueIfPointer(value reflect.Value) reflect.Value { if value.Kind() == reflect.Ptr { return DereferenceValueIfPointer(value.Elem()) @@ -54,6 +59,7 @@ func DereferenceValueIfPointer(value reflect.Value) reflect.Value { return value } +// DereferenceTypeIfPointer recursively dereferences a type if it is a pointer. func DereferenceTypeIfPointer(t reflect.Type) reflect.Type { if t.Kind() == reflect.Ptr { return DereferenceTypeIfPointer(t.Elem()) diff --git a/serializer/filter/filter.go b/serializer/filter/filter.go index 6bb8a29..7929f0b 100644 --- a/serializer/filter/filter.go +++ b/serializer/filter/filter.go @@ -4,6 +4,7 @@ import ( "reflect" ) +// FilterByGroups filters an object's fields based on the provided serialization groups. func FilterByGroups[T any](obj T, groups ...string) T { value := reflect.ValueOf(obj) elemType := value.Type() diff --git a/serializer/serialize.go b/serializer/serialize.go index a368eae..9ef6fba 100644 --- a/serializer/serialize.go +++ b/serializer/serialize.go @@ -97,6 +97,7 @@ func marshalXMLFiltered(e *xml.Encoder, start xml.StartElement, value reflect.Va return e.EncodeToken(start.End()) } +// Serialize converts an object to a string representation in the configured format. func (s *Serializer) Serialize(obj any, groups ...string) (string, error) { switch s.Format { case format.JSON: diff --git a/serializer/serializer.go b/serializer/serializer.go index 7c7b7a6..0d1d687 100644 --- a/serializer/serializer.go +++ b/serializer/serializer.go @@ -12,7 +12,7 @@ type Serializer struct { Format format.Format } -// NewSerializer creates a new instance of Serializer +// NewSerializer creates a new Serializer instance with the specified format. func NewSerializer(format format.Format) *Serializer { return &Serializer{Format: format} }