diff --git a/.gitignore b/.gitignore index 6f72f89..9d03e21 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ go.work.sum # env file .env +bin/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..45657e5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "cwd": "${workspaceFolder}/bin", + "program": "${workspaceFolder}/cmd/testserver/main.go", + "args": [ + "-o","${workspaceFolder}/bin/debug_bin" + ], + "windows": { + "args": [ + "-o","${workspaceFolder}/bin/debug_bin.exe" + ], + }, + "env": { + "CGO_ENABLED": "0" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..dbad9cd --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "go", + "label": "go: build workspace", + "command": "build", + "options": { + "env": { + "CGO_ENABLED": "0" + }, + "cwd": "${workspaceFolder}/bin", + }, + "args": [ + "../..." + ], + "problemMatcher": [ + "$go" + ], + "group": "build", + + }, + { + "type": "go", + "label": "go: test workspace", + "command": "test", + + "options": { + "env": { + "CGO_ENABLED": "0" + }, + "cwd": "${workspaceFolder}/bin", + }, + "args": [ + "../..." + ], + "problemMatcher": [ + "$go" + ], + "group": "build", + + }, + ] +} \ No newline at end of file diff --git a/README.md b/README.md index b71dc63..be9e932 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,227 @@ -# ResolveSpec -ResolveSpec📜 is a flexible and powerful REST API specification and implementation that provides GraphQL-like capabilities while maintaining REST simplicity +# 📜 ResolveSpec 📜 + +ResolveSpec is a flexible and powerful REST API specification and implementation that provides GraphQL-like capabilities while maintaining REST simplicity. It allows for dynamic data querying, relationship preloading, and complex filtering through a clean, URL-based interface. + +![slogan](./generated_slogan.webp) + +## Features + +- **Dynamic Data Querying**: Select specific columns and relationships to return +- **Relationship Preloading**: Load related entities with custom column selection and filters +- **Complex Filtering**: Apply multiple filters with various operators +- **Sorting**: Multi-column sort support +- **Pagination**: Built-in limit and offset support +- **Computed Columns**: Define virtual columns for complex calculations +- **Custom Operators**: Add custom SQL conditions when needed + +## API Structure + +### URL Patterns +``` +/[schema]/[table_or_entity]/[id] +/[schema]/[table_or_entity] +/[schema]/[function] +/[schema]/[virtual] +``` + +### Request Format + +```json +{ + "operation": "read|create|update|delete", + "data": { + // For create/update operations + }, + "options": { + "preload": [...], + "columns": [...], + "filters": [...], + "sort": [...], + "limit": number, + "offset": number, + "customOperators": [...], + "computedColumns": [...] + } +} +``` + +## Example Usage + +### Reading Data with Related Entities +```json +POST /core/users +{ + "operation": "read", + "options": { + "columns": ["id", "name", "email"], + "preload": [ + { + "relation": "posts", + "columns": ["id", "title"], + "filters": [ + { + "column": "status", + "operator": "eq", + "value": "published" + } + ] + } + ], + "filters": [ + { + "column": "active", + "operator": "eq", + "value": true + } + ], + "sort": [ + { + "column": "created_at", + "direction": "desc" + } + ], + "limit": 10, + "offset": 0 + } +} +``` + +## Installation + +```bash +go get github.com/Warky-Devs/ResolveSpec +``` + +## Quick Start + +1. Import the package: +```go +import "github.com/Warky-Devs/ResolveSpec" +``` + +1. Initialize the handler: +```go +handler := resolvespec.NewAPIHandler(db) + +// Register your models +handler.RegisterModel("core", "users", &User{}) +handler.RegisterModel("core", "posts", &Post{}) +``` + +3. Use with your preferred router: + +Using Gin: +```go +func setupGin(handler *resolvespec.APIHandler) *gin.Engine { + r := gin.Default() + + r.POST("/:schema/:entity", func(c *gin.Context) { + params := map[string]string{ + "schema": c.Param("schema"), + "entity": c.Param("entity"), + "id": c.Param("id"), + } + handler.SetParams(params) + handler.Handle(c.Writer, c.Request) + }) + + return r +} +``` + +Using Mux: +```go +func setupMux(handler *resolvespec.APIHandler) *mux.Router { + r := mux.NewRouter() + + r.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + handler.SetParams(vars) + handler.Handle(w, r) + }).Methods("POST") + + return r +} +``` + +## Configuration + +### Model Registration +```go +type User struct { + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name"` + Email string `json:"email"` + Posts []Post `json:"posts,omitempty" gorm:"foreignKey:UserID"` +} + +handler.RegisterModel("core", "users", &User{}) +``` + +## Features in Detail + +### Filtering +Supported operators: +- eq: Equal +- neq: Not Equal +- gt: Greater Than +- gte: Greater Than or Equal +- lt: Less Than +- lte: Less Than or Equal +- like: LIKE pattern matching +- ilike: Case-insensitive LIKE +- in: IN clause + +### Sorting +Support for multiple sort criteria with direction: +```json +"sort": [ + { + "column": "created_at", + "direction": "desc" + }, + { + "column": "name", + "direction": "asc" + } +] +``` + +### Computed Columns +Define virtual columns using SQL expressions: +```json +"computedColumns": [ + { + "name": "full_name", + "expression": "CONCAT(first_name, ' ', last_name)" + } +] +``` + +## Security Considerations + +- Implement proper authentication and authorization +- Validate all input parameters +- Use prepared statements (handled by GORM) +- Implement rate limiting +- Control access at schema/entity level + +## Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- Inspired by REST, Odata and GraphQL's flexibility +- Built with [GORM](https://gorm.io) +- Uses Gin or Mux Web Framework +- Slogan generated using DALL-E +- AI used for documentation checking and correction \ No newline at end of file diff --git a/bin/.readme b/bin/.readme new file mode 100644 index 0000000..b1ce915 --- /dev/null +++ b/bin/.readme @@ -0,0 +1 @@ +Binary compiled files and working dir \ No newline at end of file diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go new file mode 100644 index 0000000..c455888 --- /dev/null +++ b/cmd/testserver/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/Warky-Devs/ResolveSpec/pkg/logger" + "github.com/Warky-Devs/ResolveSpec/pkg/models" + "github.com/Warky-Devs/ResolveSpec/pkg/testmodels" + + "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec" + "github.com/gorilla/mux" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + gormlog "gorm.io/gorm/logger" +) + +func main() { + // Initialize logger + fmt.Println("ResolveSpec test server starting") + logger.Init(true) + + // Init Models + testmodels.RegisterTestModels() + + // Initialize database + db, err := initDB() + if err != nil { + logger.Error("Failed to initialize database: %+v", err) + os.Exit(1) + } + + // Create router + r := mux.NewRouter() + + // Initialize API handler + handler := resolvespec.NewAPIHandler(db) + + // Setup routes + r.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + handler.Handle(w, r, vars) + }).Methods("POST") + + r.HandleFunc("/{schema}/{entity}/{id}", func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + handler.Handle(w, r, vars) + }).Methods("POST") + + r.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + handler.HandleGet(w, r, vars) + }).Methods("GET") + + // Start server + logger.Info("Starting server on :8080") + if err := http.ListenAndServe(":8080", r); err != nil { + logger.Error("Server failed to start: %v", err) + os.Exit(1) + } +} + +func initDB() (*gorm.DB, error) { + + newLogger := gormlog.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer + gormlog.Config{ + SlowThreshold: time.Second, // Slow SQL threshold + LogLevel: gormlog.Info, // Log level + IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger + ParameterizedQueries: true, // Don't include params in the SQL log + Colorful: true, // Disable color + }, + ) + + // Create SQLite database + db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{Logger: newLogger, FullSaveAssociations: false}) + if err != nil { + return nil, err + } + + modelList := models.GetModels() + + // Auto migrate schemas + err = db.AutoMigrate(modelList...) + if err != nil { + return nil, err + } + + return db, nil +} diff --git a/generated_slogan.webp b/generated_slogan.webp new file mode 100644 index 0000000..986f242 Binary files /dev/null and b/generated_slogan.webp differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..04443ca --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/Warky-Devs/ResolveSpec + +go 1.22.5 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/gorilla/mux v1.8.1 + github.com/stretchr/testify v1.8.1 + go.uber.org/zap v1.27.0 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f99265d --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/.readme b/internal/.readme new file mode 100644 index 0000000..4fed026 --- /dev/null +++ b/internal/.readme @@ -0,0 +1 @@ +Internal utils and files. Non exported \ No newline at end of file diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..33cf769 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,362 @@ +openapi: 3.0.0 +info: + title: ResolveSpec API + version: '1.0' + description: A flexible REST API with GraphQL-like capabilities + +servers: + - url: 'http://api.example.com/v1' + +paths: + '/{schema}/{entity}': + parameters: + - name: schema + in: path + required: true + schema: + type: string + - name: entity + in: path + required: true + schema: + type: string + get: + summary: Get table metadata + description: Retrieve table metadata including columns, types, and relationships + responses: + '200': + description: Successful operation + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Response' + - type: object + properties: + data: + $ref: '#/components/schemas/TableMetadata' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + post: + summary: Perform operations on entities + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + + '/{schema}/{entity}/{id}': + parameters: + - name: schema + in: path + required: true + schema: + type: string + - name: entity + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + post: + summary: Perform operations on a specific entity + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + +components: + schemas: + Request: + type: object + required: + - operation + properties: + operation: + type: string + enum: + - read + - create + - update + - delete + id: + oneOf: + - type: string + - type: array + items: + type: string + description: Optional record identifier(s) when not provided in URL + data: + oneOf: + - type: object + - type: array + items: + type: object + description: Data for single or bulk create/update operations + options: + $ref: '#/components/schemas/Options' + + Options: + type: object + properties: + preload: + type: array + items: + $ref: '#/components/schemas/PreloadOption' + columns: + type: array + items: + type: string + filters: + type: array + items: + $ref: '#/components/schemas/FilterOption' + sort: + type: array + items: + $ref: '#/components/schemas/SortOption' + limit: + type: integer + minimum: 0 + offset: + type: integer + minimum: 0 + customOperators: + type: array + items: + $ref: '#/components/schemas/CustomOperator' + computedColumns: + type: array + items: + $ref: '#/components/schemas/ComputedColumn' + + PreloadOption: + type: object + properties: + relation: + type: string + columns: + type: array + items: + type: string + filters: + type: array + items: + $ref: '#/components/schemas/FilterOption' + + FilterOption: + type: object + required: + - column + - operator + - value + properties: + column: + type: string + operator: + type: string + enum: + - eq + - neq + - gt + - gte + - lt + - lte + - like + - ilike + - in + value: + type: object + + SortOption: + type: object + required: + - column + - direction + properties: + column: + type: string + direction: + type: string + enum: + - asc + - desc + + CustomOperator: + type: object + required: + - name + - sql + properties: + name: + type: string + sql: + type: string + + ComputedColumn: + type: object + required: + - name + - expression + properties: + name: + type: string + expression: + type: string + + Response: + type: object + required: + - success + properties: + success: + type: boolean + data: + type: object + metadata: + $ref: '#/components/schemas/Metadata' + error: + $ref: '#/components/schemas/Error' + + Metadata: + type: object + properties: + total: + type: integer + filtered: + type: integer + limit: + type: integer + offset: + type: integer + + Error: + type: object + properties: + code: + type: string + message: + type: string + details: + type: object + + TableMetadata: + type: object + required: + - schema + - table + - columns + - relations + properties: + schema: + type: string + description: Schema name + table: + type: string + description: Table name + columns: + type: array + items: + $ref: '#/components/schemas/Column' + relations: + type: array + items: + type: string + description: List of relation names + + Column: + type: object + required: + - name + - type + - is_nullable + - is_primary + - is_unique + - has_index + properties: + name: + type: string + description: Column name + type: + type: string + description: Data type of the column + is_nullable: + type: boolean + description: Whether the column can contain null values + is_primary: + type: boolean + description: Whether the column is a primary key + is_unique: + type: boolean + description: Whether the column has a unique constraint + has_index: + type: boolean + description: Whether the column is indexed + + responses: + BadRequest: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + + ServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + +security: + - bearerAuth: [] \ No newline at end of file diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..1f78aa4 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,72 @@ +package logger + +import ( + "fmt" + "log" + "os" + + "go.uber.org/zap" +) + +var Logger *zap.SugaredLogger + +func Init(dev bool) { + + if dev { + cfg := zap.NewDevelopmentConfig() + UpdateLogger(&cfg) + } else { + cfg := zap.NewProductionConfig() + UpdateLogger(&cfg) + } + +} + +func UpdateLogger(config *zap.Config) { + defaultConfig := zap.NewProductionConfig() + defaultConfig.OutputPaths = []string{"resolvespec.log"} + if config == nil { + config = &defaultConfig + } + + logger, err := config.Build() + if err != nil { + log.Print(err) + return + } + + Logger = logger.Sugar() + Info("ResolveSpec Logger initialized") +} + +func Info(template string, args ...interface{}) { + if Logger == nil { + log.Printf(template, args...) + return + } + Logger.Infow(fmt.Sprintf(template, args...), "process_id", os.Getpid()) +} + +func Warn(template string, args ...interface{}) { + if Logger == nil { + log.Printf(template, args...) + return + } + Logger.Warnw(fmt.Sprintf(template, args...), "process_id", os.Getpid()) +} + +func Error(template string, args ...interface{}) { + if Logger == nil { + log.Printf(template, args...) + return + } + Logger.Errorw(fmt.Sprintf(template, args...), "process_id", os.Getpid()) +} + +func Debug(template string, args ...interface{}) { + if Logger == nil { + log.Printf(template, args...) + return + } + Logger.Debugw(fmt.Sprintf(template, args...), "process_id", os.Getpid()) +} diff --git a/pkg/models/registry.go b/pkg/models/registry.go new file mode 100644 index 0000000..4fa4042 --- /dev/null +++ b/pkg/models/registry.go @@ -0,0 +1,70 @@ +package models + +import ( + "fmt" + "reflect" + "sync" +) + +var ( + modelRegistry = make(map[string]interface{}) + functionRegistry = make(map[string]interface{}) + modelRegistryMutex sync.RWMutex + funcRegistryMutex sync.RWMutex +) + +// RegisterModel registers a model type with the registry +// The model must be a struct or a pointer to a struct +// e.g RegisterModel(&ModelPublicUser{},"public.user") +func RegisterModel(model interface{}, name string) { + modelRegistryMutex.Lock() + defer modelRegistryMutex.Unlock() + + modelType := reflect.TypeOf(model) + if modelType.Kind() == reflect.Ptr { + modelType = modelType.Elem() + } + if name == "" { + name = modelType.Name() + } + modelRegistry[name] = model +} + +// RegisterFunction register a function with the registry +func RegisterFunction(fn interface{}, name string) { + funcRegistryMutex.Lock() + defer funcRegistryMutex.Unlock() + functionRegistry[name] = fn +} + +// GetModelByName retrieves a model from the registry by its type name +func GetModelByName(name string) (interface{}, error) { + modelRegistryMutex.RLock() + defer modelRegistryMutex.RUnlock() + + if modelRegistry[name] == nil { + return nil, fmt.Errorf("model not found: %s", name) + } + return modelRegistry[name], nil +} + +// IterateModels iterates over all models in the registry +func IterateModels(fn func(name string, model interface{})) { + modelRegistryMutex.RLock() + defer modelRegistryMutex.RUnlock() + + for name, model := range modelRegistry { + fn(name, model) + } +} + +// GetModels returns a list of all models in the registry +func GetModels() []interface{} { + models := make([]interface{}, 0) + modelRegistryMutex.RLock() + defer modelRegistryMutex.RUnlock() + for _, model := range modelRegistry { + models = append(models, model) + } + return models +} diff --git a/pkg/resolvespec/apiHandler.go b/pkg/resolvespec/apiHandler.go new file mode 100644 index 0000000..24a6950 --- /dev/null +++ b/pkg/resolvespec/apiHandler.go @@ -0,0 +1,76 @@ +package resolvespec + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/Warky-Devs/ResolveSpec/pkg/logger" + "gorm.io/gorm" +) + +type HandlerFunc func(http.ResponseWriter, *http.Request) + +type APIHandler struct { + db *gorm.DB +} + +// NewAPIHandler creates a new API handler instance +func NewAPIHandler(db *gorm.DB) *APIHandler { + return &APIHandler{ + db: db, + } +} + +// Main handler method +func (h *APIHandler) Handle(w http.ResponseWriter, r *http.Request, params map[string]string) { + var req RequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logger.Error("Failed to decode request body: %v", err) + h.sendError(w, http.StatusBadRequest, "invalid_request", "Invalid request body", err) + return + } + + schema := params["schema"] + entity := params["entity"] + id := params["id"] + + logger.Info("Handling %s operation for %s.%s", req.Operation, schema, entity) + + switch req.Operation { + case "read": + h.handleRead(w, r, schema, entity, id, req.Options) + case "create": + h.handleCreate(w, r, schema, entity, req.Data, req.Options) + case "update": + h.handleUpdate(w, r, schema, entity, id, req.ID, req.Data, req.Options) + case "delete": + h.handleDelete(w, r, schema, entity, id) + default: + logger.Error("Invalid operation: %s", req.Operation) + h.sendError(w, http.StatusBadRequest, "invalid_operation", "Invalid operation", nil) + } +} + +func (h *APIHandler) sendResponse(w http.ResponseWriter, data interface{}, metadata *Metadata) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{ + Success: true, + Data: data, + Metadata: metadata, + }) +} + +func (h *APIHandler) sendError(w http.ResponseWriter, status int, code, message string, details interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(Response{ + Success: false, + Error: &APIError{ + Code: code, + Message: message, + Details: details, + Detail: fmt.Sprintf("%v", details), + }, + }) +} diff --git a/pkg/resolvespec/crud.go b/pkg/resolvespec/crud.go new file mode 100644 index 0000000..b7f5a51 --- /dev/null +++ b/pkg/resolvespec/crud.go @@ -0,0 +1,250 @@ +package resolvespec + +import ( + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/Warky-Devs/ResolveSpec/pkg/logger" + "gorm.io/gorm" +) + +// Read handler +func (h *APIHandler) handleRead(w http.ResponseWriter, r *http.Request, schema, entity, id string, options RequestOptions) { + logger.Info("Reading records from %s.%s", schema, entity) + + // Get the model struct for the entity + model, err := h.getModelForEntity(schema, entity) + if err != nil { + logger.Error("Invalid entity: %v", err) + h.sendError(w, http.StatusBadRequest, "invalid_entity", "Invalid entity", err) + return + } + + GormTableNameInterface, ok := model.(GormTableNameInterface) + if !ok { + logger.Error("Model does not implement GormTableNameInterface") + h.sendError(w, http.StatusInternalServerError, "model_error", "Model does not implement GormTableNameInterface", nil) + return + } + query := h.db.Model(model).Table(GormTableNameInterface.TableName()) + + // Apply column selection + if len(options.Columns) > 0 { + logger.Debug("Selecting columns: %v", options.Columns) + query = query.Select(options.Columns) + } + + // Apply preloading + for _, preload := range options.Preload { + logger.Debug("Applying preload for relation: %s", preload.Relation) + query = query.Preload(preload.Relation, func(db *gorm.DB) *gorm.DB { + + if len(preload.Columns) > 0 { + db = db.Select(preload.Columns) + } + if len(preload.Filters) > 0 { + for _, filter := range preload.Filters { + db = h.applyFilter(db, filter) + } + } + return db + }) + + } + + // Apply filters + for _, filter := range options.Filters { + logger.Debug("Applying filter: %s %s %v", filter.Column, filter.Operator, filter.Value) + query = h.applyFilter(query, filter) + } + + // Apply sorting + for _, sort := range options.Sort { + direction := "ASC" + if strings.ToLower(sort.Direction) == "desc" { + direction = "DESC" + } + logger.Debug("Applying sort: %s %s", sort.Column, direction) + query = query.Order(fmt.Sprintf("%s %s", sort.Column, direction)) + } + + // Get total count before pagination + var total int64 + if err := query.Count(&total).Error; err != nil { + logger.Error("Error counting records: %v", err) + h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err) + return + } + logger.Debug("Total records before filtering: %d", total) + + // Apply pagination + if options.Limit != nil && *options.Limit > 0 { + logger.Debug("Applying limit: %d", *options.Limit) + query = query.Limit(*options.Limit) + } + if options.Offset != nil && *options.Offset > 0 { + logger.Debug("Applying offset: %d", *options.Offset) + query = query.Offset(*options.Offset) + } + + // Execute query + var result interface{} + if id != "" { + logger.Debug("Querying single record with ID: %s", id) + singleResult := model + if err := query.First(singleResult, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + logger.Warn("Record not found with ID: %s", id) + h.sendError(w, http.StatusNotFound, "not_found", "Record not found", nil) + return + } + logger.Error("Error querying record: %v", err) + h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) + return + } + result = singleResult + } else { + logger.Debug("Querying multiple records") + sliceType := reflect.SliceOf(reflect.TypeOf(model)) + results := reflect.New(sliceType).Interface() + + if err := query.Find(results).Error; err != nil { + logger.Error("Error querying records: %v", err) + h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) + return + } + result = reflect.ValueOf(results).Elem().Interface() + } + + logger.Info("Successfully retrieved records") + h.sendResponse(w, result, &Metadata{ + Total: total, + Filtered: total, + Limit: optionalInt(options.Limit), + Offset: optionalInt(options.Offset), + }) +} + +// Create handler +func (h *APIHandler) handleCreate(w http.ResponseWriter, r *http.Request, schema, entity string, data any, options RequestOptions) { + logger.Info("Creating records for %s.%s", schema, entity) + query := h.db.Table(fmt.Sprintf("%s.%s", schema, entity)) + + switch v := data.(type) { + case map[string]interface{}: + result := query.Create(v) + if result.Error != nil { + logger.Error("Error creating record: %v", result.Error) + h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", result.Error) + return + } + logger.Info("Successfully created record") + h.sendResponse(w, v, nil) + + case []map[string]interface{}: + result := query.Create(v) + if result.Error != nil { + logger.Error("Error creating records: %v", result.Error) + h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records", result.Error) + return + } + logger.Info("Successfully created %d records", len(v)) + h.sendResponse(w, v, nil) + case []interface{}: + list := make([]interface{}, 0) + for _, item := range v { + result := query.Create(item) + list = append(list, item) + if result.Error != nil { + logger.Error("Error creating records: %v", result.Error) + h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records", result.Error) + return + } + logger.Info("Successfully created %d records", len(v)) + } + h.sendResponse(w, list, nil) + default: + logger.Error("Invalid data type for create operation: %T", data) + } +} + +// Update handler +func (h *APIHandler) handleUpdate(w http.ResponseWriter, r *http.Request, schema, entity string, urlID string, reqID any, data any, options RequestOptions) { + logger.Info("Updating records for %s.%s", schema, entity) + query := h.db.Table(fmt.Sprintf("%s.%s", schema, entity)) + + switch { + case urlID != "": + logger.Debug("Updating by URL ID: %s", urlID) + result := query.Where("id = ?", urlID).Updates(data) + handleUpdateResult(w, h, result, data) + + case reqID != nil: + switch id := reqID.(type) { + case string: + logger.Debug("Updating by request ID: %s", id) + result := query.Where("id = ?", id).Updates(data) + handleUpdateResult(w, h, result, data) + + case []string: + logger.Debug("Updating by multiple IDs: %v", id) + result := query.Where("id IN ?", id).Updates(data) + handleUpdateResult(w, h, result, data) + } + + case data != nil: + switch v := data.(type) { + case []map[string]interface{}: + logger.Debug("Performing bulk update with %d records", len(v)) + err := h.db.Transaction(func(tx *gorm.DB) error { + for _, item := range v { + if id, ok := item["id"].(string); ok { + if err := tx.Where("id = ?", id).Updates(item).Error; err != nil { + logger.Error("Error in bulk update transaction: %v", err) + return err + } + } + } + return nil + }) + if err != nil { + h.sendError(w, http.StatusInternalServerError, "update_error", "Error in bulk update", err) + return + } + logger.Info("Bulk update completed successfully") + h.sendResponse(w, data, nil) + } + default: + logger.Error("Invalid data type for update operation: %T", data) + + } +} + +// Delete handler +func (h *APIHandler) handleDelete(w http.ResponseWriter, r *http.Request, schema, entity, id string) { + logger.Info("Deleting records from %s.%s", schema, entity) + query := h.db.Table(fmt.Sprintf("%s.%s", schema, entity)) + + if id == "" { + logger.Error("Delete operation requires an ID") + h.sendError(w, http.StatusBadRequest, "missing_id", "Delete operation requires an ID", nil) + return + } + + result := query.Delete("id = ?", id) + if result.Error != nil { + logger.Error("Error deleting record: %v", result.Error) + h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting record", result.Error) + return + } + if result.RowsAffected == 0 { + logger.Warn("No record found to delete with ID: %s", id) + h.sendError(w, http.StatusNotFound, "not_found", "Record not found", nil) + return + } + + logger.Info("Successfully deleted record with ID: %s", id) + h.sendResponse(w, nil, nil) +} diff --git a/pkg/resolvespec/interfaces.go b/pkg/resolvespec/interfaces.go new file mode 100644 index 0000000..0fd2329 --- /dev/null +++ b/pkg/resolvespec/interfaces.go @@ -0,0 +1,9 @@ +package resolvespec + +type GormTableNameInterface interface { + TableName() string +} + +type GormTableSchemaInterface interface { + TableSchema() string +} diff --git a/pkg/resolvespec/meta.go b/pkg/resolvespec/meta.go new file mode 100644 index 0000000..6e62d7f --- /dev/null +++ b/pkg/resolvespec/meta.go @@ -0,0 +1,131 @@ +package resolvespec + +import ( + "net/http" + "reflect" + "strings" + + "github.com/Warky-Devs/ResolveSpec/pkg/logger" +) + +func (h *APIHandler) HandleGet(w http.ResponseWriter, r *http.Request, params map[string]string) { + schema := params["schema"] + entity := params["entity"] + + logger.Info("Getting metadata for %s.%s", schema, entity) + + // Get model for the entity + model, err := h.getModelForEntity(schema, entity) + if err != nil { + logger.Error("Failed to get model: %v", err) + h.sendError(w, http.StatusBadRequest, "invalid_entity", "Invalid entity", err) + return + } + + modelType := reflect.TypeOf(model) + if modelType.Kind() == reflect.Ptr { + modelType = modelType.Elem() + } + + metadata := TableMetadata{ + Schema: schema, + Table: entity, + Columns: make([]Column, 0), + Relations: make([]string, 0), + } + + // Get field information using reflection + for i := 0; i < modelType.NumField(); i++ { + field := modelType.Field(i) + + // Skip unexported fields + if !field.IsExported() { + continue + } + + // Parse GORM tags + gormTag := field.Tag.Get("gorm") + jsonTag := field.Tag.Get("json") + + // Skip if json tag is "-" + if jsonTag == "-" { + continue + } + + // Get JSON field name + jsonName := strings.Split(jsonTag, ",")[0] + if jsonName == "" { + jsonName = field.Name + } + + // Check if it's a relation + if field.Type.Kind() == reflect.Slice || + (field.Type.Kind() == reflect.Struct && field.Type.Name() != "Time") { + metadata.Relations = append(metadata.Relations, jsonName) + continue + } + + column := Column{ + Name: jsonName, + Type: getColumnType(field), + IsNullable: isNullable(field), + IsPrimary: strings.Contains(gormTag, "primaryKey"), + IsUnique: strings.Contains(gormTag, "unique") || strings.Contains(gormTag, "uniqueIndex"), + HasIndex: strings.Contains(gormTag, "index") || strings.Contains(gormTag, "uniqueIndex"), + } + + metadata.Columns = append(metadata.Columns, column) + } + + h.sendResponse(w, metadata, nil) +} + +func getColumnType(field reflect.StructField) string { + // Check GORM type tag first + gormTag := field.Tag.Get("gorm") + if strings.Contains(gormTag, "type:") { + parts := strings.Split(gormTag, "type:") + if len(parts) > 1 { + typePart := strings.Split(parts[1], ";")[0] + return typePart + } + } + + // Map Go types to SQL types + switch field.Type.Kind() { + case reflect.String: + return "string" + case reflect.Int, reflect.Int32: + return "integer" + case reflect.Int64: + return "bigint" + case reflect.Float32: + return "float" + case reflect.Float64: + return "double" + case reflect.Bool: + return "boolean" + default: + if field.Type.Name() == "Time" { + return "timestamp" + } + return "unknown" + } +} + +func isNullable(field reflect.StructField) bool { + // Check if it's a pointer type + if field.Type.Kind() == reflect.Ptr { + return true + } + + // Check if it's a null type from sql package + typeName := field.Type.Name() + if strings.HasPrefix(typeName, "Null") { + return true + } + + // Check GORM tags + gormTag := field.Tag.Get("gorm") + return !strings.Contains(gormTag, "not null") +} diff --git a/pkg/resolvespec/types.go b/pkg/resolvespec/types.go new file mode 100644 index 0000000..d17083d --- /dev/null +++ b/pkg/resolvespec/types.go @@ -0,0 +1,95 @@ +package resolvespec + +type RequestBody struct { + Operation string `json:"operation"` + Data interface{} `json:"data"` + ID *int64 `json:"id"` + Options RequestOptions `json:"options"` +} + +type RequestOptions struct { + Preload []PreloadOption `json:"preload"` + Columns []string `json:"columns"` + OmitColumns []string `json:"omit_columns"` + Filters []FilterOption `json:"filters"` + Sort []SortOption `json:"sort"` + Limit *int `json:"limit"` + Offset *int `json:"offset"` + CustomOperators []CustomOperator `json:"customOperators"` + ComputedColumns []ComputedColumn `json:"computedColumns"` + Parameters []Parameter `json:"parameters"` +} + +type Parameter struct { + Name string `json:"name"` + Value string `json:"value"` + Sequence *int `json:"sequence"` +} + +type PreloadOption struct { + Relation string `json:"relation"` + Columns []string `json:"columns"` + OmitColumns []string `json:"omit_columns"` + Filters []FilterOption `json:"filters"` + Limit *int `json:"limit"` + Offset *int `json:"offset"` +} + +type FilterOption struct { + Column string `json:"column"` + Operator string `json:"operator"` + Value interface{} `json:"value"` +} + +type SortOption struct { + Column string `json:"column"` + Direction string `json:"direction"` +} + +type CustomOperator struct { + Name string `json:"name"` + SQL string `json:"sql"` +} + +type ComputedColumn struct { + Name string `json:"name"` + Expression string `json:"expression"` +} + +// Response structures +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data"` + Metadata *Metadata `json:"metadata,omitempty"` + Error *APIError `json:"error,omitempty"` +} + +type Metadata struct { + Total int64 `json:"total"` + Filtered int64 `json:"filtered"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` + Detail string `json:"detail,omitempty"` +} + +type Column struct { + Name string `json:"name"` + Type string `json:"type"` + IsNullable bool `json:"is_nullable"` + IsPrimary bool `json:"is_primary"` + IsUnique bool `json:"is_unique"` + HasIndex bool `json:"has_index"` +} + +type TableMetadata struct { + Schema string `json:"schema"` + Table string `json:"table"` + Columns []Column `json:"columns"` + Relations []string `json:"relations"` +} diff --git a/pkg/resolvespec/utils.go b/pkg/resolvespec/utils.go new file mode 100644 index 0000000..9d05680 --- /dev/null +++ b/pkg/resolvespec/utils.go @@ -0,0 +1,67 @@ +package resolvespec + +import ( + "fmt" + "net/http" + + "github.com/Warky-Devs/ResolveSpec/pkg/logger" + "github.com/Warky-Devs/ResolveSpec/pkg/models" + "gorm.io/gorm" +) + +func handleUpdateResult(w http.ResponseWriter, h *APIHandler, result *gorm.DB, data interface{}) { + if result.Error != nil { + logger.Error("Update error: %v", result.Error) + h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record(s)", result.Error) + return + } + if result.RowsAffected == 0 { + logger.Warn("No records found to update") + h.sendError(w, http.StatusNotFound, "not_found", "No records found to update", nil) + return + } + logger.Info("Successfully updated %d records", result.RowsAffected) + h.sendResponse(w, data, nil) +} + +func optionalInt(ptr *int) int { + if ptr == nil { + return 0 + } + return *ptr +} + +// Helper methods +func (h *APIHandler) applyFilter(query *gorm.DB, filter FilterOption) *gorm.DB { + switch filter.Operator { + case "eq": + return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value) + case "neq": + return query.Where(fmt.Sprintf("%s != ?", filter.Column), filter.Value) + case "gt": + return query.Where(fmt.Sprintf("%s > ?", filter.Column), filter.Value) + case "gte": + return query.Where(fmt.Sprintf("%s >= ?", filter.Column), filter.Value) + case "lt": + return query.Where(fmt.Sprintf("%s < ?", filter.Column), filter.Value) + case "lte": + return query.Where(fmt.Sprintf("%s <= ?", filter.Column), filter.Value) + case "like": + return query.Where(fmt.Sprintf("%s LIKE ?", filter.Column), filter.Value) + case "ilike": + return query.Where(fmt.Sprintf("%s ILIKE ?", filter.Column), filter.Value) + case "in": + return query.Where(fmt.Sprintf("%s IN (?)", filter.Column), filter.Value) + default: + return query + } +} + +func (h *APIHandler) getModelForEntity(schema, name string) (interface{}, error) { + model, err := models.GetModelByName(fmt.Sprintf("%s.%s", schema, name)) + + if err != nil { + model, err = models.GetModelByName(name) + } + return model, err +} diff --git a/pkg/testmodels/business.go b/pkg/testmodels/business.go new file mode 100644 index 0000000..d348654 --- /dev/null +++ b/pkg/testmodels/business.go @@ -0,0 +1,148 @@ +package testmodels + +import ( + "time" + + "github.com/Warky-Devs/ResolveSpec/pkg/models" +) + +// Department represents a company department +type Department struct { + ID string `json:"id" gorm:"primaryKey;type:string"` + Name string `json:"name"` + Code string `json:"code" gorm:"uniqueIndex"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Employees []Employee `json:"employees,omitempty" gorm:"foreignKey:DepartmentID;references:ID"` + Projects []Project `json:"projects,omitempty" gorm:"many2many:department_projects;"` +} + +func (Department) TableName() string { + return "departments" +} + +// Employee represents a company employee +type Employee struct { + ID string `json:"id" gorm:"primaryKey;type:string"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email" gorm:"uniqueIndex"` + Title string `json:"title"` + DepartmentID string `json:"department_id" gorm:"type:string"` + ManagerID *string `json:"manager_id" gorm:"type:string"` + HireDate time.Time `json:"hire_date"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Department *Department `json:"department,omitempty" gorm:"foreignKey:DepartmentID;references:ID"` + Manager *Employee `json:"manager,omitempty" gorm:"foreignKey:ManagerID;references:ID"` + Reports []Employee `json:"reports,omitempty" gorm:"foreignKey:ManagerID;references:ID"` + Projects []Project `json:"projects,omitempty" gorm:"many2many:employee_projects;"` + Documents []Document `json:"documents,omitempty" gorm:"foreignKey:OwnerID;references:ID"` +} + +func (Employee) TableName() string { + return "employees" +} + +// Project represents a company project +type Project struct { + ID string `json:"id" gorm:"primaryKey;type:string"` + Name string `json:"name"` + Code string `json:"code" gorm:"uniqueIndex"` + Description string `json:"description"` + Status string `json:"status"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + Budget float64 `json:"budget"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Departments []Department `json:"departments,omitempty" gorm:"many2many:department_projects;"` + Employees []Employee `json:"employees,omitempty" gorm:"many2many:employee_projects;"` + Tasks []ProjectTask `json:"tasks,omitempty" gorm:"foreignKey:ProjectID;references:ID"` + Documents []Document `json:"documents,omitempty" gorm:"foreignKey:ProjectID;references:ID"` +} + +func (Project) TableName() string { + return "projects" +} + +// ProjectTask represents a task within a project +type ProjectTask struct { + ID string `json:"id" gorm:"primaryKey;type:string"` + ProjectID string `json:"project_id" gorm:"type:string"` + AssigneeID string `json:"assignee_id" gorm:"type:string"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority int `json:"priority"` + DueDate time.Time `json:"due_date"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Project Project `json:"project,omitempty" gorm:"foreignKey:ProjectID;references:ID"` + Assignee Employee `json:"assignee,omitempty" gorm:"foreignKey:AssigneeID;references:ID"` + Comments []Comment `json:"comments,omitempty" gorm:"foreignKey:TaskID;references:ID"` +} + +func (ProjectTask) TableName() string { + return "project_tasks" +} + +// Document represents any document in the system +type Document struct { + ID string `json:"id" gorm:"primaryKey;type:string"` + Name string `json:"name"` + Type string `json:"type"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + Path string `json:"path"` + OwnerID string `json:"owner_id" gorm:"type:string"` + ProjectID *string `json:"project_id" gorm:"type:string"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Owner Employee `json:"owner,omitempty" gorm:"foreignKey:OwnerID;references:ID"` + Project *Project `json:"project,omitempty" gorm:"foreignKey:ProjectID;references:ID"` +} + +func (Document) TableName() string { + return "documents" +} + +// Comment represents a comment on a task +type Comment struct { + ID string `json:"id" gorm:"primaryKey;type:string"` + TaskID string `json:"task_id" gorm:"type:string"` + AuthorID string `json:"author_id" gorm:"type:string"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Task ProjectTask `json:"task,omitempty" gorm:"foreignKey:TaskID;references:ID"` + Author Employee `json:"author,omitempty" gorm:"foreignKey:AuthorID;references:ID"` +} + +func (Comment) TableName() string { + return "comments" +} + +func RegisterTestModels() { + models.RegisterModel(&Department{}, "departments") + models.RegisterModel(&Employee{}, "employees") + models.RegisterModel(&Project{}, "projects") + models.RegisterModel(&ProjectTask{}, "project_tasks") + models.RegisterModel(&Document{}, "documents") + models.RegisterModel(&Comment{}, "comments") +} diff --git a/tests/integration_test.go b/tests/integration_test.go new file mode 100644 index 0000000..94f9add --- /dev/null +++ b/tests/integration_test.go @@ -0,0 +1,232 @@ +package test + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestMain sets up the test environment +func TestMain(m *testing.M) { + TestSetup(m) +} + +func TestDepartmentEmployees(t *testing.T) { + // Create test department + deptPayload := map[string]interface{}{ + "operation": "create", + "data": map[string]interface{}{ + "id": "dept1", + "name": "Engineering", + "code": "ENG", + "description": "Engineering Department", + }, + } + + resp := makeRequest(t, "/test/departments", deptPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Create employees in department + empPayload := map[string]interface{}{ + "operation": "create", + "data": []map[string]interface{}{ + { + "id": "emp1", + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com", + "department_id": "dept1", + "title": "Senior Engineer", + }, + { + "id": "emp2", + "first_name": "Jane", + "last_name": "Smith", + "email": "jane@example.com", + "department_id": "dept1", + "title": "Engineer", + }, + }, + } + + resp = makeRequest(t, "/test/employees", empPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Read department with employees + readPayload := map[string]interface{}{ + "operation": "read", + "options": map[string]interface{}{ + "preload": []map[string]interface{}{ + { + "relation": "employees", + "columns": []string{"id", "first_name", "last_name", "title"}, + }, + }, + }, + } + + resp = makeRequest(t, "/test/departments/dept1", readPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + data := result["data"].(map[string]interface{}) + employees := data["employees"].([]interface{}) + assert.Equal(t, 2, len(employees)) +} + +func TestEmployeeHierarchy(t *testing.T) { + // Create manager + mgrPayload := map[string]interface{}{ + "operation": "create", + "data": map[string]interface{}{ + "id": "mgr1", + "first_name": "Alice", + "last_name": "Manager", + "email": "alice@example.com", + "title": "Engineering Manager", + "department_id": "dept1", + }, + } + + resp := makeRequest(t, "/test/employees", mgrPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Update employees to set manager + updatePayload := map[string]interface{}{ + "operation": "update", + "data": map[string]interface{}{ + "manager_id": "mgr1", + }, + } + + resp = makeRequest(t, "/test/employees/emp1", updatePayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp = makeRequest(t, "/test/employees/emp2", updatePayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Read manager with reports + readPayload := map[string]interface{}{ + "operation": "read", + "options": map[string]interface{}{ + "preload": []map[string]interface{}{ + { + "relation": "reports", + "columns": []string{"id", "first_name", "last_name", "title"}, + }, + }, + }, + } + + resp = makeRequest(t, "/test/employees/mgr1", readPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + data := result["data"].(map[string]interface{}) + reports := data["reports"].([]interface{}) + assert.Equal(t, 2, len(reports)) +} + +func TestProjectStructure(t *testing.T) { + // Create project + projectPayload := map[string]interface{}{ + "operation": "create", + "data": map[string]interface{}{ + "id": "proj1", + "name": "New Website", + "code": "WEB", + "description": "Company website redesign", + "status": "active", + "start_date": time.Now().Format(time.RFC3339), + "end_date": time.Now().AddDate(0, 3, 0).Format(time.RFC3339), + "budget": 100000, + }, + } + + resp := makeRequest(t, "/test/projects", projectPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Create project tasks + taskPayload := map[string]interface{}{ + "operation": "create", + "data": []map[string]interface{}{ + { + "id": "task1", + "project_id": "proj1", + "assignee_id": "emp1", + "title": "Design Homepage", + "description": "Create homepage design", + "status": "in_progress", + "priority": 1, + "due_date": time.Now().AddDate(0, 1, 0).Format(time.RFC3339), + }, + { + "id": "task2", + "project_id": "proj1", + "assignee_id": "emp2", + "title": "Implement Backend", + "description": "Implement backend APIs", + "status": "planned", + "priority": 2, + "due_date": time.Now().AddDate(0, 2, 0).Format(time.RFC3339), + }, + }, + } + + resp = makeRequest(t, "/test/project_tasks", taskPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Create task comments + commentPayload := map[string]interface{}{ + "operation": "create", + "data": map[string]interface{}{ + "id": "comment1", + "task_id": "task1", + "author_id": "mgr1", + "content": "Looking good! Please add more animations.", + }, + } + + resp = makeRequest(t, "/test/comments", commentPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Read project with all relations + readPayload := map[string]interface{}{ + "operation": "read", + "options": map[string]interface{}{ + "preload": []map[string]interface{}{ + { + "relation": "tasks", + "columns": []string{"id", "title", "status", "assignee_id"}, + "preload": []map[string]interface{}{ + { + "relation": "comments", + "columns": []string{"id", "content", "author_id"}, + "preload": []map[string]interface{}{ + { + "relation": "author", + "columns": []string{"id", "first_name", "last_name"}, + }, + }, + }, + { + "relation": "assignee", + "columns": []string{"id", "first_name", "last_name", "title"}, + }, + }, + }, + }, + }, + } + + resp = makeRequest(t, "/test/projects/proj1", readPayload) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + assert.True(t, result["success"].(bool)) +} diff --git a/tests/test_helpers.go b/tests/test_helpers.go new file mode 100644 index 0000000..1691992 --- /dev/null +++ b/tests/test_helpers.go @@ -0,0 +1,152 @@ +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/Warky-Devs/ResolveSpec/pkg/logger" + "github.com/Warky-Devs/ResolveSpec/pkg/models" + "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec" + "github.com/Warky-Devs/ResolveSpec/pkg/testmodels" + "github.com/glebarez/sqlite" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + + "gorm.io/gorm" +) + +var ( + testDB *gorm.DB + testServer *httptest.Server + testServerURL string +) + +// makeRequest is a helper function to make HTTP requests in tests +func makeRequest(t *testing.T, path string, payload interface{}) *http.Response { + jsonData, err := json.Marshal(payload) + assert.NoError(t, err, "Failed to marshal request payload") + + logger.Debug("Making request to %s with payload: %s", path, string(jsonData)) + + req, err := http.NewRequest("POST", testServerURL+path, bytes.NewBuffer(jsonData)) + assert.NoError(t, err, "Failed to create request") + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "Failed to execute request") + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + logger.Error("Request failed with status %d: %s", resp.StatusCode, string(body)) + } else { + logger.Debug("Request successful with status %d", resp.StatusCode) + } + + return resp +} + +// verifyResponse is a helper function to verify response status and decode body +func verifyResponse(t *testing.T, resp *http.Response) map[string]interface{} { + assert.Equal(t, http.StatusOK, resp.StatusCode, "Unexpected response status") + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err, "Failed to decode response") + assert.True(t, result["success"].(bool), "Response indicates failure") + + return result +} + +// TestSetup initializes the test environment +func TestSetup(m *testing.M) int { + logger.Init(true) + + logger.Info("Setting up test environment") + + // Create test database + db, err := setupTestDB() + if err != nil { + logger.Error("Failed to setup test database: %v", err) + return 1 + } + testDB = db + + // Setup test server + router := setupTestRouter(testDB) + testServer = httptest.NewServer(router) + + fmt.Printf("ResolveSpec test server starting on %s\n", testServer.URL) + testServerURL = testServer.URL + + defer testServer.Close() + + // Run tests + code := m.Run() + + // Cleanup + logger.Info("Cleaning up test environment") + cleanup() + return code +} + +// setupTestDB creates and initializes the test database +func setupTestDB() (*gorm.DB, error) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("failed to open database: %v", err) + } + + // Init Models + testmodels.RegisterTestModels() + + // Auto migrate all test models + err = autoMigrateModels(db) + if err != nil { + return nil, fmt.Errorf("failed to migrate models: %v", err) + } + + return db, nil +} + +// setupTestRouter creates and configures the test router +func setupTestRouter(db *gorm.DB) http.Handler { + r := mux.NewRouter() + handler := resolvespec.NewAPIHandler(db) + + r.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + handler.Handle(w, r, vars) + }).Methods("POST") + + r.HandleFunc("/{schema}/{entity}/{id}", func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + handler.Handle(w, r, vars) + }).Methods("POST") + + return r +} + +// cleanup performs test cleanup +func cleanup() { + if testDB != nil { + db, err := testDB.DB() + if err == nil { + db.Close() + } + } + os.Remove("test.db") +} + +// autoMigrateModels performs automigration for all test models +func autoMigrateModels(db *gorm.DB) error { + modelList := models.GetModels() + return db.AutoMigrate(modelList...) +}