Initial Spec done. More work to do. Need to bring in Argitek designs

This commit is contained in:
Warky 2025-01-08 23:45:53 +02:00
parent 7ce04e2032
commit f284e55a5c
22 changed files with 2150 additions and 2 deletions

1
.gitignore vendored
View File

@ -23,3 +23,4 @@ go.work.sum
# env file # env file
.env .env
bin/

27
.vscode/launch.json vendored Normal file
View 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
View 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
View File

@ -1,2 +1,227 @@
# ResolveSpec # 📜 ResolveSpec 📜
ResolveSpec📜 is a flexible and powerful REST API specification and implementation that provides GraphQL-like capabilities while maintaining REST simplicity
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

1
bin/.readme Normal file
View File

@ -0,0 +1 @@
Binary compiled files and working dir

95
cmd/testserver/main.go Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

31
go.mod Normal file
View 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
View 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
View File

@ -0,0 +1 @@
Internal utils and files. Non exported

362
openapi.yaml Normal file
View 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
View 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
View 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
}

View 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
View 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)
}

View File

@ -0,0 +1,9 @@
package resolvespec
type GormTableNameInterface interface {
TableName() string
}
type GormTableSchemaInterface interface {
TableSchema() string
}

131
pkg/resolvespec/meta.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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...)
}