mirror of
https://github.com/Warky-Devs/ResolveSpec.git
synced 2025-05-18 20:57:29 +00:00
Initial Spec done. More work to do. Need to bring in Argitek designs
This commit is contained in:
parent
7ce04e2032
commit
f284e55a5c
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,3 +23,4 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
bin/
|
27
.vscode/launch.json
vendored
Normal file
27
.vscode/launch.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
44
.vscode/tasks.json
vendored
Normal file
44
.vscode/tasks.json
vendored
Normal file
@ -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",
|
||||
|
||||
},
|
||||
]
|
||||
}
|
229
README.md
229
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.
|
||||
|
||||

|
||||
|
||||
## 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
|
1
bin/.readme
Normal file
1
bin/.readme
Normal file
@ -0,0 +1 @@
|
||||
Binary compiled files and working dir
|
95
cmd/testserver/main.go
Normal file
95
cmd/testserver/main.go
Normal file
@ -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
|
||||
}
|
BIN
generated_slogan.webp
Normal file
BIN
generated_slogan.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 352 KiB |
31
go.mod
Normal file
31
go.mod
Normal file
@ -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
|
||||
)
|
59
go.sum
Normal file
59
go.sum
Normal file
@ -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=
|
1
internal/.readme
Normal file
1
internal/.readme
Normal file
@ -0,0 +1 @@
|
||||
Internal utils and files. Non exported
|
362
openapi.yaml
Normal file
362
openapi.yaml
Normal file
@ -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: []
|
72
pkg/logger/logger.go
Normal file
72
pkg/logger/logger.go
Normal file
@ -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())
|
||||
}
|
70
pkg/models/registry.go
Normal file
70
pkg/models/registry.go
Normal file
@ -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
|
||||
}
|
76
pkg/resolvespec/apiHandler.go
Normal file
76
pkg/resolvespec/apiHandler.go
Normal file
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
250
pkg/resolvespec/crud.go
Normal file
250
pkg/resolvespec/crud.go
Normal file
@ -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)
|
||||
}
|
9
pkg/resolvespec/interfaces.go
Normal file
9
pkg/resolvespec/interfaces.go
Normal file
@ -0,0 +1,9 @@
|
||||
package resolvespec
|
||||
|
||||
type GormTableNameInterface interface {
|
||||
TableName() string
|
||||
}
|
||||
|
||||
type GormTableSchemaInterface interface {
|
||||
TableSchema() string
|
||||
}
|
131
pkg/resolvespec/meta.go
Normal file
131
pkg/resolvespec/meta.go
Normal file
@ -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")
|
||||
}
|
95
pkg/resolvespec/types.go
Normal file
95
pkg/resolvespec/types.go
Normal file
@ -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"`
|
||||
}
|
67
pkg/resolvespec/utils.go
Normal file
67
pkg/resolvespec/utils.go
Normal file
@ -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
|
||||
}
|
148
pkg/testmodels/business.go
Normal file
148
pkg/testmodels/business.go
Normal file
@ -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")
|
||||
}
|
232
tests/integration_test.go
Normal file
232
tests/integration_test.go
Normal file
@ -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))
|
||||
}
|
152
tests/test_helpers.go
Normal file
152
tests/test_helpers.go
Normal file
@ -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...)
|
||||
}
|
Loading…
Reference in New Issue
Block a user