Compare commits

..

15 Commits

Author SHA1 Message Date
Hein
c84dd7dc91 Lets try the model approach again 2025-11-07 14:18:15 +02:00
Hein
f1c6b36374 Bun Adaptor updates 2025-11-07 14:03:40 +02:00
Hein
abee5c942f Count Fixes 2025-11-07 13:54:24 +02:00
Hein
2e9a0bd51a Better model pointers 2025-11-07 13:45:08 +02:00
Hein
f518a3c73c Some validation and header decoding 2025-11-07 13:31:48 +02:00
Hein
07c239aaa1 Make sure to enable Clean JSON when select fields given 2025-11-07 11:00:56 +02:00
Hein
1adca4c49b Content Types and Respose fixes for restheadpsec 2025-11-07 10:55:42 +02:00
Hein
eefed23766 COUNT queries were generating incorrect SQL with the table appearing twice 2025-11-07 10:37:53 +02:00
Hein
3b2d05465e Fixed tablename and schema lookups 2025-11-07 10:28:14 +02:00
Hein
e88018543e Reflect safty 2025-11-07 09:47:12 +02:00
Hein
e7e5754a47 Added panic catches 2025-11-07 09:32:37 +02:00
Hein
c88bff1883 Better handling with context 2025-11-07 09:13:06 +02:00
Hein
d122c7af42 Updated how model registry works 2025-11-07 08:26:50 +02:00
Hein
8e06736701 Massive refactor and introduction of restheadspec 2025-11-06 16:15:35 +02:00
399cea9335 Updated Database Interface, Added Bun Support 2025-08-14 22:36:04 +02:00
33 changed files with 6050 additions and 705 deletions

173
MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,173 @@
# Migration Guide: Database and Router Abstraction
This guide explains how to migrate from the direct GORM/Router dependencies to the new abstracted interfaces.
## Overview of Changes
### What was changed:
1. **Database Operations**: GORM-specific code is now abstracted behind `Database` interface
2. **Router Integration**: HTTP router dependencies are abstracted behind `Router` interface
3. **Model Registry**: Models are now managed through a `ModelRegistry` interface
4. **Backward Compatibility**: Existing code continues to work with `NewAPIHandler()`
### Benefits:
- **Database Flexibility**: Switch between GORM, Bun, or other ORMs without code changes
- **Router Flexibility**: Use Gorilla Mux, Gin, Echo, or other routers
- **Better Testing**: Easy to mock database and router interactions
- **Cleaner Separation**: Business logic separated from ORM/router specifics
## Migration Path
### Option 1: No Changes Required (Backward Compatible)
Your existing code continues to work without any changes:
```go
// This still works exactly as before
handler := resolvespec.NewAPIHandler(db)
```
### Option 2: Gradual Migration to New API
#### Step 1: Use New Handler Constructor
```go
// Old way
handler := resolvespec.NewAPIHandler(gormDB)
// New way
handler := resolvespec.NewHandlerWithGORM(gormDB)
```
#### Step 2: Use Interface-based Approach
```go
// Create database adapter
dbAdapter := resolvespec.NewGormAdapter(gormDB)
// Create model registry
registry := resolvespec.NewModelRegistry()
// Register your models
registry.RegisterModel("public.users", &User{})
registry.RegisterModel("public.orders", &Order{})
// Create handler
handler := resolvespec.NewHandler(dbAdapter, registry)
```
## Switching Database Backends
### From GORM to Bun
```go
// Add bun dependency first:
// go get github.com/uptrace/bun
// Old GORM setup
gormDB, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
gormAdapter := resolvespec.NewGormAdapter(gormDB)
// New Bun setup
sqlDB, _ := sql.Open("sqlite3", "test.db")
bunDB := bun.NewDB(sqlDB, sqlitedialect.New())
bunAdapter := resolvespec.NewBunAdapter(bunDB)
// Handler creation is identical
handler := resolvespec.NewHandler(bunAdapter, registry)
```
## Router Flexibility
### Current Gorilla Mux (Default)
```go
router := mux.NewRouter()
resolvespec.SetupRoutes(router, handler)
```
### BunRouter (Built-in Support)
```go
// Simple setup
router := bunrouter.New()
resolvespec.SetupBunRouterWithResolveSpec(router, handler)
// Or using adapter
routerAdapter := resolvespec.NewStandardBunRouterAdapter()
// Use routerAdapter.GetBunRouter() for the underlying router
```
### Using Router Adapters (Advanced)
```go
// For when you want router abstraction
routerAdapter := resolvespec.NewStandardRouter()
routerAdapter.RegisterRoute("/{schema}/{entity}", handlerFunc)
```
## Model Registration
### Old Way (Still Works)
```go
// Models registered through existing models package
handler.RegisterModel("public", "users", &User{})
```
### New Way (Recommended)
```go
registry := resolvespec.NewModelRegistry()
registry.RegisterModel("public.users", &User{})
registry.RegisterModel("public.orders", &Order{})
handler := resolvespec.NewHandler(dbAdapter, registry)
```
## Interface Definitions
### Database Interface
```go
type Database interface {
NewSelect() SelectQuery
NewInsert() InsertQuery
NewUpdate() UpdateQuery
NewDelete() DeleteQuery
// ... transaction methods
}
```
### Available Adapters
- `GormAdapter` - For GORM (ready to use)
- `BunAdapter` - For Bun (add dependency: `go get github.com/uptrace/bun`)
- Easy to create custom adapters for other ORMs
## Testing Benefits
### Before (Tightly Coupled)
```go
// Hard to test - requires real GORM setup
func TestHandler(t *testing.T) {
db := setupRealGormDB()
handler := resolvespec.NewAPIHandler(db)
// ... test logic
}
```
### After (Mockable)
```go
// Easy to test - mock the interfaces
func TestHandler(t *testing.T) {
mockDB := &MockDatabase{}
mockRegistry := &MockModelRegistry{}
handler := resolvespec.NewHandler(mockDB, mockRegistry)
// ... test logic with mocks
}
```
## Breaking Changes
- **None for existing code** - Full backward compatibility maintained
- New interfaces are additive, not replacing existing APIs
## Recommended Migration Timeline
1. **Phase 1**: Use existing code (no changes needed)
2. **Phase 2**: Gradually adopt new constructors (`NewHandlerWithGORM`)
3. **Phase 3**: Move to interface-based approach when needed
4. **Phase 4**: Switch database backends if desired
## Getting Help
- Check example functions in `resolvespec.go`
- Review interface definitions in `database.go`
- Examine adapter implementations for patterns

276
README.md
View File

@@ -2,8 +2,25 @@
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.
**🆕 New in v2.0**: Database-agnostic architecture with support for GORM, Bun, and other ORMs. Router-flexible design works with Gorilla Mux, Gin, Echo, and more.
![slogan](./generated_slogan.webp)
## Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Existing Code (Backward Compatible)](#option-1-existing-code-backward-compatible)
- [New Database-Agnostic API](#option-2-new-database-agnostic-api)
- [Router Integration](#router-integration)
- [Migration from v1.x](#migration-from-v1x)
- [Architecture](#architecture)
- [API Structure](#api-structure)
- [Example Usage](#example-usage)
- [Testing](#testing)
- [What's New in v2.0](#whats-new-in-v20)
## Features
- **Dynamic Data Querying**: Select specific columns and relationships to return
@@ -13,6 +30,10 @@ ResolveSpec is a flexible and powerful REST API specification and implementation
- **Pagination**: Built-in limit and offset support
- **Computed Columns**: Define virtual columns for complex calculations
- **Custom Operators**: Add custom SQL conditions when needed
- **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
- **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers
- **🆕 Backward Compatible**: Existing code works without changes
- **🆕 Better Testing**: Mockable interfaces for easy unit testing
## API Structure
@@ -94,53 +115,197 @@ go get github.com/Warky-Devs/ResolveSpec
## Quick Start
1. Import the package:
```go
import "github.com/Warky-Devs/ResolveSpec"
```
### Option 1: Existing Code (Backward Compatible)
1. Initialize the handler:
```go
handler := resolvespec.NewAPIHandler(db)
Your existing code continues to work without any changes:
// Register your models
```go
import "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
// This still works exactly as before
handler := resolvespec.NewAPIHandler(gormDB)
handler.RegisterModel("core", "users", &User{})
handler.RegisterModel("core", "posts", &Post{})
```
3. Use with your preferred router:
## Migration from v1.x
Using Gin:
ResolveSpec v2.0 introduces a new database and router abstraction layer while maintaining **100% backward compatibility**. Your existing code will continue to work without any changes.
### Migration Timeline
1. **Phase 1**: Continue using existing API (no changes needed)
2. **Phase 2**: Gradually adopt new constructors when convenient
3. **Phase 3**: Switch to interface-based approach for new features
4. **Phase 4**: Optionally switch database backends
### Detailed Migration Guide
For detailed migration instructions, examples, and best practices, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md).
## Architecture
### Database Abstraction Layer
```
Your Application Code
Handler (Business Logic)
Database Interface
[GormAdapter] [BunAdapter] [CustomAdapter]
↓ ↓ ↓
[GORM] [Bun] [Your ORM]
```
### Supported Database Layers
- **GORM** (default, fully supported)
- **Bun** (ready to use, included in dependencies)
- **Custom ORMs** (implement the `Database` interface)
### Supported Routers
- **Gorilla Mux** (built-in support with `SetupRoutes()`)
- **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`)
- **Gin** (manual integration, see examples above)
- **Echo** (manual integration, see examples above)
- **Custom Routers** (implement request/response adapters)
### Option 2: New Database-Agnostic API
#### With GORM (Recommended Migration Path)
```go
func setupGin(handler *resolvespec.APIHandler) *gin.Engine {
import "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
// Create database adapter
dbAdapter := resolvespec.NewGormAdapter(gormDB)
// Create model registry
registry := resolvespec.NewModelRegistry()
registry.RegisterModel("core.users", &User{})
registry.RegisterModel("core.posts", &Post{})
// Create handler
handler := resolvespec.NewHandler(dbAdapter, registry)
```
#### With Bun ORM
```go
import "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
import "github.com/uptrace/bun"
// Create Bun adapter (Bun dependency already included)
dbAdapter := resolvespec.NewBunAdapter(bunDB)
// Rest is identical to GORM
registry := resolvespec.NewModelRegistry()
handler := resolvespec.NewHandler(dbAdapter, registry)
```
### Router Integration
#### Gorilla Mux (Built-in Support)
```go
import "github.com/gorilla/mux"
// Backward compatible way
router := mux.NewRouter()
resolvespec.SetupRoutes(router, handler)
// Or manually:
router.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
handler.Handle(w, r, vars)
}).Methods("POST")
```
#### Gin (Custom Integration)
```go
import "github.com/gin-gonic/gin"
func setupGin(handler *resolvespec.Handler) *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)
// Use new adapter interfaces
reqAdapter := resolvespec.NewHTTPRequest(c.Request)
respAdapter := resolvespec.NewHTTPResponseWriter(c.Writer)
handler.Handle(respAdapter, reqAdapter, params)
})
return r
}
```
Using Mux:
#### Echo (Custom Integration)
```go
func setupMux(handler *resolvespec.APIHandler) *mux.Router {
r := mux.NewRouter()
import "github.com/labstack/echo/v4"
func setupEcho(handler *resolvespec.Handler) *echo.Echo {
e := echo.New()
r.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
handler.SetParams(vars)
handler.Handle(w, r)
}).Methods("POST")
e.POST("/:schema/:entity", func(c echo.Context) error {
params := map[string]string{
"schema": c.Param("schema"),
"entity": c.Param("entity"),
}
reqAdapter := resolvespec.NewHTTPRequest(c.Request())
respAdapter := resolvespec.NewHTTPResponseWriter(c.Response().Writer)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
return r
return e
}
```
#### BunRouter (Built-in Support)
```go
import "github.com/uptrace/bunrouter"
// Simple setup with built-in function
func setupBunRouter(handler *resolvespec.APIHandlerCompat) *bunrouter.Router {
router := bunrouter.New()
resolvespec.SetupBunRouterWithResolveSpec(router, handler)
return router
}
// Or use the adapter
func setupBunRouterAdapter() *resolvespec.StandardBunRouterAdapter {
routerAdapter := resolvespec.NewStandardBunRouterAdapter()
// Register routes manually
routerAdapter.RegisterRouteWithParams("POST", "/:schema/:entity",
[]string{"schema", "entity"},
func(w http.ResponseWriter, r *http.Request, params map[string]string) {
// Your handler logic
})
return routerAdapter
}
// Full uptrace stack (bunrouter + Bun ORM)
func setupFullUptrace(bunDB *bun.DB) *bunrouter.Router {
// Database adapter
dbAdapter := resolvespec.NewBunAdapter(bunDB)
registry := resolvespec.NewModelRegistry()
handler := resolvespec.NewHandler(dbAdapter, registry)
// Router
router := resolvespec.NewStandardBunRouterAdapter()
resolvespec.SetupBunRouterWithResolveSpec(router.GetBunRouter(),
&resolvespec.APIHandlerCompat{
newHandler: handler,
})
return router.GetBunRouter()
}
```
@@ -198,13 +363,45 @@ Define virtual columns using SQL expressions:
]
```
## Testing
### With New Architecture (Mockable)
```go
import "github.com/stretchr/testify/mock"
// Create mock database
type MockDatabase struct {
mock.Mock
}
func (m *MockDatabase) NewSelect() resolvespec.SelectQuery {
args := m.Called()
return args.Get(0).(resolvespec.SelectQuery)
}
// Test your handler with mocks
func TestHandler(t *testing.T) {
mockDB := &MockDatabase{}
mockRegistry := resolvespec.NewModelRegistry()
handler := resolvespec.NewHandler(mockDB, mockRegistry)
// Setup mock expectations
mockDB.On("NewSelect").Return(&MockSelectQuery{})
// Test your logic
// ... test code
}
```
## Security Considerations
- Implement proper authentication and authorization
- Validate all input parameters
- Use prepared statements (handled by GORM)
- Validate all input parameters
- Use prepared statements (handled by GORM/Bun/your ORM)
- Implement rate limiting
- Control access at schema/entity level
- **New**: Database abstraction layer provides additional security through interface boundaries
## Contributing
@@ -218,10 +415,29 @@ Define virtual columns using SQL expressions:
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## What's New in v2.0
### Breaking Changes
- **None!** Full backward compatibility maintained
### New Features
- **Database Abstraction**: Support for GORM, Bun, and custom ORMs
- **Router Flexibility**: Works with any HTTP router through adapters
- **BunRouter Integration**: Built-in support for uptrace/bunrouter
- **Better Architecture**: Clean separation of concerns with interfaces
- **Enhanced Testing**: Mockable interfaces for comprehensive testing
- **Migration Guide**: Step-by-step migration instructions
### Performance Improvements
- More efficient query building through interface design
- Reduced coupling between components
- Better memory management with interface boundaries
## Acknowledgments
- Inspired by REST, Odata and GraphQL's flexibility
- Built with [GORM](https://gorm.io)
- Uses Gin or Mux Web Framework
- Inspired by REST, OData, and GraphQL's flexibility
- **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/)
- **Router Support**: Gorilla Mux (built-in), Gin, Echo, and others through adapters
- Slogan generated using DALL-E
- AI used for documentation checking and correction
- AI used for documentation checking and correction
- Community feedback and contributions that made v2.0 possible

138
SCHEMA_TABLE_HANDLING.md Normal file
View File

@@ -0,0 +1,138 @@
# Schema and Table Name Handling
This document explains how the handlers properly separate and handle schema and table names.
## Implementation
Both `resolvespec` and `restheadspec` handlers now properly handle schema and table name separation through the following functions:
- `parseTableName(fullTableName)` - Splits "schema.table" into separate components
- `getSchemaAndTable(defaultSchema, entity, model)` - Returns schema and table separately
- `getTableName(schema, entity, model)` - Returns the full "schema.table" format
## Priority Order
When determining the schema and table name, the following priority is used:
1. **If `TableName()` contains a schema** (e.g., "myschema.mytable"), that schema takes precedence
2. **If model implements `SchemaProvider`**, use that schema
3. **Otherwise**, use the `defaultSchema` parameter from the URL/request
## Scenarios
### Scenario 1: Simple table name, default schema
```go
type User struct {
ID string
Name string
}
func (User) TableName() string {
return "users"
}
```
- Request URL: `/api/public/users`
- Result: `schema="public"`, `table="users"`, `fullName="public.users"`
### Scenario 2: Table name includes schema
```go
type User struct {
ID string
Name string
}
func (User) TableName() string {
return "auth.users" // Schema included!
}
```
- Request URL: `/api/public/users` (public is ignored)
- Result: `schema="auth"`, `table="users"`, `fullName="auth.users"`
- **Note**: The schema from `TableName()` takes precedence over the URL schema
### Scenario 3: Using SchemaProvider
```go
type User struct {
ID string
Name string
}
func (User) TableName() string {
return "users"
}
func (User) SchemaName() string {
return "auth"
}
```
- Request URL: `/api/public/users` (public is ignored)
- Result: `schema="auth"`, `table="users"`, `fullName="auth.users"`
### Scenario 4: Table name includes schema AND SchemaProvider
```go
type User struct {
ID string
Name string
}
func (User) TableName() string {
return "core.users" // This wins!
}
func (User) SchemaName() string {
return "auth" // This is ignored
}
```
- Request URL: `/api/public/users`
- Result: `schema="core"`, `table="users"`, `fullName="core.users"`
- **Note**: Schema from `TableName()` takes highest precedence
### Scenario 5: No providers at all
```go
type User struct {
ID string
Name string
}
// No TableName() or SchemaName()
```
- Request URL: `/api/public/users`
- Result: `schema="public"`, `table="users"`, `fullName="public.users"`
- Uses URL schema and entity name
## Key Features
1. **Automatic detection**: The code automatically detects if `TableName()` includes a schema by checking for "."
2. **Backward compatible**: Existing code continues to work
3. **Flexible**: Supports multiple ways to specify schema and table
4. **Debug logging**: Logs when schema is detected in `TableName()` for debugging
## Code Locations
### Handlers
- `/pkg/resolvespec/handler.go:472-531`
- `/pkg/restheadspec/handler.go:534-593`
### Database Adapters
- `/pkg/common/adapters/database/utils.go` - Shared `parseTableName()` function
- `/pkg/common/adapters/database/bun.go` - Bun adapter with separated schema/table
- `/pkg/common/adapters/database/gorm.go` - GORM adapter with separated schema/table
## Adapter Implementation
Both Bun and GORM adapters now properly separate schema and table name:
```go
// BunSelectQuery/GormSelectQuery now have separated fields:
type BunSelectQuery struct {
query *bun.SelectQuery
schema string // Separated schema name
tableName string // Just the table name, without schema
tableAlias string
}
```
When `Model()` or `Table()` is called:
1. The full table name (which may include schema) is parsed
2. Schema and table name are stored separately
3. When building joins, the already-separated table name is used directly
This ensures consistent handling of schema-qualified table names throughout the codebase.

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
"github.com/Warky-Devs/ResolveSpec/pkg/models"
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
"github.com/Warky-Devs/ResolveSpec/pkg/testmodels"
"github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
@@ -24,9 +24,6 @@ func main() {
fmt.Println("ResolveSpec test server starting")
logger.Init(true)
// Init Models
testmodels.RegisterTestModels()
// Initialize database
db, err := initDB()
if err != nil {
@@ -37,24 +34,22 @@ func main() {
// Create router
r := mux.NewRouter()
// Initialize API handler
handler := resolvespec.NewAPIHandler(db)
// Initialize API handler using new API
handler := resolvespec.NewHandlerWithGORM(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")
// Create a new registry instance and register models
registry := modelregistry.NewModelRegistry()
testmodels.RegisterTestModels(registry)
r.HandleFunc("/{schema}/{entity}/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
handler.Handle(w, r, vars)
}).Methods("POST")
// Register models with handler
models := testmodels.GetTestModels()
modelNames := []string{"departments", "employees", "projects", "project_tasks", "documents", "comments"}
for i, model := range models {
handler.RegisterModel("public", modelNames[i], model)
}
r.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
handler.HandleGet(w, r, vars)
}).Methods("GET")
// Setup routes using new SetupMuxRoutes function
resolvespec.SetupMuxRoutes(r, handler)
// Start server
logger.Info("Starting server on :8080")
@@ -83,7 +78,7 @@ func initDB() (*gorm.DB, error) {
return nil, err
}
modelList := models.GetModels()
modelList := testmodels.GetTestModels()
// Auto migrate schemas
err = db.AutoMigrate(modelList...)

14
go.mod
View File

@@ -1,11 +1,14 @@
module github.com/Warky-Devs/ResolveSpec
go 1.22.5
go 1.23.0
toolchain go1.24.6
require (
github.com/glebarez/sqlite v1.11.0
github.com/gorilla/mux v1.8.1
github.com/stretchr/testify v1.8.1
github.com/uptrace/bun v1.2.15
go.uber.org/zap v1.27.0
gorm.io/gorm v1.25.12
)
@@ -17,11 +20,16 @@ require (
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/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/uptrace/bunrouter v1.0.23 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/sys v0.34.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

29
go.sum
View File

@@ -17,10 +17,16 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
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/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
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=
@@ -31,19 +37,30 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.2.15 h1:Ut68XRBLDgp9qG9QBMa9ELWaZOmzHNdczHQdrOZbEFE=
github.com/uptrace/bun v1.2.15/go.mod h1:Eghz7NonZMiTX/Z6oKYytJ0oaMEJ/eq3kEV4vSqG038=
github.com/uptrace/bunrouter v1.0.23 h1:Bi7NKw3uCQkcA/GUCtDNPq5LE5UdR9pe+UyWbjHB/wU=
github.com/uptrace/bunrouter v1.0.23/go.mod h1:O3jAcl+5qgnF+ejhgkmbceEk0E/mqaK+ADOocdNpY8M=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
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/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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=

View File

@@ -15,12 +15,12 @@ if [[ $make_release =~ ^[Yy]$ ]]; then
fi
# Create an annotated tag
git tag -a "$version" -m "Released Core $version"
git tag -a "$version" -m "Released $version"
# Push the tag to the remote repository
git push origin "$version"
echo "Tag $version created for Core and pushed to the remote repository."
echo "Tag $version created and pushed to the remote repository."
else
echo "No release version created."
fi

View File

@@ -0,0 +1,440 @@
package database
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/Warky-Devs/ResolveSpec/pkg/common"
"github.com/uptrace/bun"
)
// BunAdapter adapts Bun to work with our Database interface
// This demonstrates how the abstraction works with different ORMs
type BunAdapter struct {
db *bun.DB
}
// NewBunAdapter creates a new Bun adapter
func NewBunAdapter(db *bun.DB) *BunAdapter {
return &BunAdapter{db: db}
}
func (b *BunAdapter) NewSelect() common.SelectQuery {
return &BunSelectQuery{
query: b.db.NewSelect(),
db: b.db,
}
}
func (b *BunAdapter) NewInsert() common.InsertQuery {
return &BunInsertQuery{query: b.db.NewInsert()}
}
func (b *BunAdapter) NewUpdate() common.UpdateQuery {
return &BunUpdateQuery{query: b.db.NewUpdate()}
}
func (b *BunAdapter) NewDelete() common.DeleteQuery {
return &BunDeleteQuery{query: b.db.NewDelete()}
}
func (b *BunAdapter) Exec(ctx context.Context, query string, args ...interface{}) (common.Result, error) {
result, err := b.db.ExecContext(ctx, query, args...)
return &BunResult{result: result}, err
}
func (b *BunAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
return b.db.NewRaw(query, args...).Scan(ctx, dest)
}
func (b *BunAdapter) BeginTx(ctx context.Context) (common.Database, error) {
tx, err := b.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return nil, err
}
// For Bun, we'll return a special wrapper that holds the transaction
return &BunTxAdapter{tx: tx}, nil
}
func (b *BunAdapter) CommitTx(ctx context.Context) error {
// For Bun, we need to handle this differently
// This is a simplified implementation
return nil
}
func (b *BunAdapter) RollbackTx(ctx context.Context) error {
// For Bun, we need to handle this differently
// This is a simplified implementation
return nil
}
func (b *BunAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) error {
return b.db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error {
// Create adapter with transaction
adapter := &BunTxAdapter{tx: tx}
return fn(adapter)
})
}
// BunSelectQuery implements SelectQuery for Bun
type BunSelectQuery struct {
query *bun.SelectQuery
db bun.IDB // Store DB connection for count queries
hasModel bool // Track if Model() was called
schema string // Separated schema name
tableName string // Just the table name, without schema
tableAlias string
}
func (b *BunSelectQuery) Model(model interface{}) common.SelectQuery {
b.query = b.query.Model(model)
b.hasModel = true // Mark that we have a model
// Try to get table name from model if it implements TableNameProvider
if provider, ok := model.(common.TableNameProvider); ok {
fullTableName := provider.TableName()
// Check if the table name contains schema (e.g., "schema.table")
b.schema, b.tableName = parseTableName(fullTableName)
}
return b
}
func (b *BunSelectQuery) Table(table string) common.SelectQuery {
b.query = b.query.Table(table)
// Check if the table name contains schema (e.g., "schema.table")
b.schema, b.tableName = parseTableName(table)
return b
}
func (b *BunSelectQuery) Column(columns ...string) common.SelectQuery {
b.query = b.query.Column(columns...)
return b
}
func (b *BunSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
b.query = b.query.Where(query, args...)
return b
}
func (b *BunSelectQuery) WhereOr(query string, args ...interface{}) common.SelectQuery {
b.query = b.query.WhereOr(query, args...)
return b
}
func (b *BunSelectQuery) Join(query string, args ...interface{}) common.SelectQuery {
// Extract optional prefix from args
// If the last arg is a string that looks like a table prefix, use it
var prefix string
sqlArgs := args
if len(args) > 0 {
if lastArg, ok := args[len(args)-1].(string); ok && len(lastArg) < 50 && !strings.Contains(lastArg, " ") {
// Likely a prefix, not a SQL parameter
prefix = lastArg
sqlArgs = args[:len(args)-1]
}
}
// If no prefix provided, use the table name as prefix (already separated from schema)
if prefix == "" && b.tableName != "" {
prefix = b.tableName
}
// If prefix is provided, add it as an alias in the join
// Bun expects: "JOIN table AS alias ON condition"
joinClause := query
if prefix != "" && !strings.Contains(strings.ToUpper(query), " AS ") {
// If query doesn't already have AS, check if it's a simple table name
parts := strings.Fields(query)
if len(parts) > 0 && !strings.HasPrefix(strings.ToUpper(parts[0]), "JOIN") {
// Simple table name, add prefix: "table AS prefix"
joinClause = fmt.Sprintf("%s AS %s", parts[0], prefix)
if len(parts) > 1 {
// Has ON clause: "table ON ..." becomes "table AS prefix ON ..."
joinClause += " " + strings.Join(parts[1:], " ")
}
}
}
b.query = b.query.Join(joinClause, sqlArgs...)
return b
}
func (b *BunSelectQuery) LeftJoin(query string, args ...interface{}) common.SelectQuery {
// Extract optional prefix from args
var prefix string
sqlArgs := args
if len(args) > 0 {
if lastArg, ok := args[len(args)-1].(string); ok && len(lastArg) < 50 && !strings.Contains(lastArg, " ") {
prefix = lastArg
sqlArgs = args[:len(args)-1]
}
}
// If no prefix provided, use the table name as prefix (already separated from schema)
if prefix == "" && b.tableName != "" {
prefix = b.tableName
}
// Construct LEFT JOIN with prefix
joinClause := query
if prefix != "" && !strings.Contains(strings.ToUpper(query), " AS ") {
parts := strings.Fields(query)
if len(parts) > 0 && !strings.HasPrefix(strings.ToUpper(parts[0]), "LEFT") && !strings.HasPrefix(strings.ToUpper(parts[0]), "JOIN") {
joinClause = fmt.Sprintf("%s AS %s", parts[0], prefix)
if len(parts) > 1 {
joinClause += " " + strings.Join(parts[1:], " ")
}
}
}
b.query = b.query.Join("LEFT JOIN " + joinClause, sqlArgs...)
return b
}
func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) common.SelectQuery {
// Bun uses Relation() method for preloading
// For now, we'll just pass the relation name without conditions
// TODO: Implement proper condition handling for Bun
b.query = b.query.Relation(relation)
return b
}
func (b *BunSelectQuery) Order(order string) common.SelectQuery {
b.query = b.query.Order(order)
return b
}
func (b *BunSelectQuery) Limit(n int) common.SelectQuery {
b.query = b.query.Limit(n)
return b
}
func (b *BunSelectQuery) Offset(n int) common.SelectQuery {
b.query = b.query.Offset(n)
return b
}
func (b *BunSelectQuery) Group(group string) common.SelectQuery {
b.query = b.query.Group(group)
return b
}
func (b *BunSelectQuery) Having(having string, args ...interface{}) common.SelectQuery {
b.query = b.query.Having(having, args...)
return b
}
func (b *BunSelectQuery) Scan(ctx context.Context, dest interface{}) error {
return b.query.Scan(ctx, dest)
}
func (b *BunSelectQuery) Count(ctx context.Context) (int, error) {
// If Model() was set, use bun's native Count() which works properly
if b.hasModel {
count, err := b.query.Count(ctx)
return count, err
}
// Otherwise, wrap as subquery to avoid "Model(nil)" error
// This is needed when only Table() is set without a model
var count int
err := b.db.NewSelect().
TableExpr("(?) AS subquery", b.query).
ColumnExpr("COUNT(*)").
Scan(ctx, &count)
return count, err
}
func (b *BunSelectQuery) Exists(ctx context.Context) (bool, error) {
return b.query.Exists(ctx)
}
// BunInsertQuery implements InsertQuery for Bun
type BunInsertQuery struct {
query *bun.InsertQuery
values map[string]interface{}
}
func (b *BunInsertQuery) Model(model interface{}) common.InsertQuery {
b.query = b.query.Model(model)
return b
}
func (b *BunInsertQuery) Table(table string) common.InsertQuery {
b.query = b.query.Table(table)
return b
}
func (b *BunInsertQuery) Value(column string, value interface{}) common.InsertQuery {
if b.values == nil {
b.values = make(map[string]interface{})
}
b.values[column] = value
return b
}
func (b *BunInsertQuery) OnConflict(action string) common.InsertQuery {
b.query = b.query.On(action)
return b
}
func (b *BunInsertQuery) Returning(columns ...string) common.InsertQuery {
if len(columns) > 0 {
b.query = b.query.Returning(columns[0])
}
return b
}
func (b *BunInsertQuery) Exec(ctx context.Context) (common.Result, error) {
if b.values != nil {
// For Bun, we need to handle this differently
for k, v := range b.values {
b.query = b.query.Set("? = ?", bun.Ident(k), v)
}
}
result, err := b.query.Exec(ctx)
return &BunResult{result: result}, err
}
// BunUpdateQuery implements UpdateQuery for Bun
type BunUpdateQuery struct {
query *bun.UpdateQuery
}
func (b *BunUpdateQuery) Model(model interface{}) common.UpdateQuery {
b.query = b.query.Model(model)
return b
}
func (b *BunUpdateQuery) Table(table string) common.UpdateQuery {
b.query = b.query.Table(table)
return b
}
func (b *BunUpdateQuery) Set(column string, value interface{}) common.UpdateQuery {
b.query = b.query.Set(column+" = ?", value)
return b
}
func (b *BunUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuery {
for column, value := range values {
b.query = b.query.Set(column+" = ?", value)
}
return b
}
func (b *BunUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
b.query = b.query.Where(query, args...)
return b
}
func (b *BunUpdateQuery) Returning(columns ...string) common.UpdateQuery {
if len(columns) > 0 {
b.query = b.query.Returning(columns[0])
}
return b
}
func (b *BunUpdateQuery) Exec(ctx context.Context) (common.Result, error) {
result, err := b.query.Exec(ctx)
return &BunResult{result: result}, err
}
// BunDeleteQuery implements DeleteQuery for Bun
type BunDeleteQuery struct {
query *bun.DeleteQuery
}
func (b *BunDeleteQuery) Model(model interface{}) common.DeleteQuery {
b.query = b.query.Model(model)
return b
}
func (b *BunDeleteQuery) Table(table string) common.DeleteQuery {
b.query = b.query.Table(table)
return b
}
func (b *BunDeleteQuery) Where(query string, args ...interface{}) common.DeleteQuery {
b.query = b.query.Where(query, args...)
return b
}
func (b *BunDeleteQuery) Exec(ctx context.Context) (common.Result, error) {
result, err := b.query.Exec(ctx)
return &BunResult{result: result}, err
}
// BunResult implements Result for Bun
type BunResult struct {
result sql.Result
}
func (b *BunResult) RowsAffected() int64 {
if b.result == nil {
return 0
}
rows, _ := b.result.RowsAffected()
return rows
}
func (b *BunResult) LastInsertId() (int64, error) {
if b.result == nil {
return 0, nil
}
return b.result.LastInsertId()
}
// BunTxAdapter wraps a Bun transaction to implement the Database interface
type BunTxAdapter struct {
tx bun.Tx
}
func (b *BunTxAdapter) NewSelect() common.SelectQuery {
return &BunSelectQuery{
query: b.tx.NewSelect(),
db: b.tx,
}
}
func (b *BunTxAdapter) NewInsert() common.InsertQuery {
return &BunInsertQuery{query: b.tx.NewInsert()}
}
func (b *BunTxAdapter) NewUpdate() common.UpdateQuery {
return &BunUpdateQuery{query: b.tx.NewUpdate()}
}
func (b *BunTxAdapter) NewDelete() common.DeleteQuery {
return &BunDeleteQuery{query: b.tx.NewDelete()}
}
func (b *BunTxAdapter) Exec(ctx context.Context, query string, args ...interface{}) (common.Result, error) {
result, err := b.tx.ExecContext(ctx, query, args...)
return &BunResult{result: result}, err
}
func (b *BunTxAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
return b.tx.NewRaw(query, args...).Scan(ctx, dest)
}
func (b *BunTxAdapter) BeginTx(ctx context.Context) (common.Database, error) {
return nil, fmt.Errorf("nested transactions not supported")
}
func (b *BunTxAdapter) CommitTx(ctx context.Context) error {
return b.tx.Commit()
}
func (b *BunTxAdapter) RollbackTx(ctx context.Context) error {
return b.tx.Rollback()
}
func (b *BunTxAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) error {
return fn(b) // Already in transaction
}

View File

@@ -0,0 +1,366 @@
package database
import (
"context"
"fmt"
"strings"
"github.com/Warky-Devs/ResolveSpec/pkg/common"
"gorm.io/gorm"
)
// GormAdapter adapts GORM to work with our Database interface
type GormAdapter struct {
db *gorm.DB
}
// NewGormAdapter creates a new GORM adapter
func NewGormAdapter(db *gorm.DB) *GormAdapter {
return &GormAdapter{db: db}
}
func (g *GormAdapter) NewSelect() common.SelectQuery {
return &GormSelectQuery{db: g.db}
}
func (g *GormAdapter) NewInsert() common.InsertQuery {
return &GormInsertQuery{db: g.db}
}
func (g *GormAdapter) NewUpdate() common.UpdateQuery {
return &GormUpdateQuery{db: g.db}
}
func (g *GormAdapter) NewDelete() common.DeleteQuery {
return &GormDeleteQuery{db: g.db}
}
func (g *GormAdapter) Exec(ctx context.Context, query string, args ...interface{}) (common.Result, error) {
result := g.db.WithContext(ctx).Exec(query, args...)
return &GormResult{result: result}, result.Error
}
func (g *GormAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
return g.db.WithContext(ctx).Raw(query, args...).Find(dest).Error
}
func (g *GormAdapter) BeginTx(ctx context.Context) (common.Database, error) {
tx := g.db.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
return &GormAdapter{db: tx}, nil
}
func (g *GormAdapter) CommitTx(ctx context.Context) error {
return g.db.WithContext(ctx).Commit().Error
}
func (g *GormAdapter) RollbackTx(ctx context.Context) error {
return g.db.WithContext(ctx).Rollback().Error
}
func (g *GormAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) error {
return g.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
adapter := &GormAdapter{db: tx}
return fn(adapter)
})
}
// GormSelectQuery implements SelectQuery for GORM
type GormSelectQuery struct {
db *gorm.DB
schema string // Separated schema name
tableName string // Just the table name, without schema
tableAlias string
}
func (g *GormSelectQuery) Model(model interface{}) common.SelectQuery {
g.db = g.db.Model(model)
// Try to get table name from model if it implements TableNameProvider
if provider, ok := model.(common.TableNameProvider); ok {
fullTableName := provider.TableName()
// Check if the table name contains schema (e.g., "schema.table")
g.schema, g.tableName = parseTableName(fullTableName)
}
return g
}
func (g *GormSelectQuery) Table(table string) common.SelectQuery {
g.db = g.db.Table(table)
// Check if the table name contains schema (e.g., "schema.table")
g.schema, g.tableName = parseTableName(table)
return g
}
func (g *GormSelectQuery) Column(columns ...string) common.SelectQuery {
g.db = g.db.Select(columns)
return g
}
func (g *GormSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
g.db = g.db.Where(query, args...)
return g
}
func (g *GormSelectQuery) WhereOr(query string, args ...interface{}) common.SelectQuery {
g.db = g.db.Or(query, args...)
return g
}
func (g *GormSelectQuery) Join(query string, args ...interface{}) common.SelectQuery {
// Extract optional prefix from args
// If the last arg is a string that looks like a table prefix, use it
var prefix string
sqlArgs := args
if len(args) > 0 {
if lastArg, ok := args[len(args)-1].(string); ok && len(lastArg) < 50 && !strings.Contains(lastArg, " ") {
// Likely a prefix, not a SQL parameter
prefix = lastArg
sqlArgs = args[:len(args)-1]
}
}
// If no prefix provided, use the table name as prefix (already separated from schema)
if prefix == "" && g.tableName != "" {
prefix = g.tableName
}
// If prefix is provided, add it as an alias in the join
// GORM expects: "JOIN table AS alias ON condition"
joinClause := query
if prefix != "" && !strings.Contains(strings.ToUpper(query), " AS ") {
// If query doesn't already have AS, check if it's a simple table name
parts := strings.Fields(query)
if len(parts) > 0 && !strings.HasPrefix(strings.ToUpper(parts[0]), "JOIN") {
// Simple table name, add prefix: "table AS prefix"
joinClause = fmt.Sprintf("%s AS %s", parts[0], prefix)
if len(parts) > 1 {
// Has ON clause: "table ON ..." becomes "table AS prefix ON ..."
joinClause += " " + strings.Join(parts[1:], " ")
}
}
}
g.db = g.db.Joins(joinClause, sqlArgs...)
return g
}
func (g *GormSelectQuery) LeftJoin(query string, args ...interface{}) common.SelectQuery {
// Extract optional prefix from args
var prefix string
sqlArgs := args
if len(args) > 0 {
if lastArg, ok := args[len(args)-1].(string); ok && len(lastArg) < 50 && !strings.Contains(lastArg, " ") {
prefix = lastArg
sqlArgs = args[:len(args)-1]
}
}
// If no prefix provided, use the table name as prefix (already separated from schema)
if prefix == "" && g.tableName != "" {
prefix = g.tableName
}
// Construct LEFT JOIN with prefix
joinClause := query
if prefix != "" && !strings.Contains(strings.ToUpper(query), " AS ") {
parts := strings.Fields(query)
if len(parts) > 0 && !strings.HasPrefix(strings.ToUpper(parts[0]), "LEFT") && !strings.HasPrefix(strings.ToUpper(parts[0]), "JOIN") {
joinClause = fmt.Sprintf("%s AS %s", parts[0], prefix)
if len(parts) > 1 {
joinClause += " " + strings.Join(parts[1:], " ")
}
}
}
g.db = g.db.Joins("LEFT JOIN "+joinClause, sqlArgs...)
return g
}
func (g *GormSelectQuery) Preload(relation string, conditions ...interface{}) common.SelectQuery {
g.db = g.db.Preload(relation, conditions...)
return g
}
func (g *GormSelectQuery) Order(order string) common.SelectQuery {
g.db = g.db.Order(order)
return g
}
func (g *GormSelectQuery) Limit(n int) common.SelectQuery {
g.db = g.db.Limit(n)
return g
}
func (g *GormSelectQuery) Offset(n int) common.SelectQuery {
g.db = g.db.Offset(n)
return g
}
func (g *GormSelectQuery) Group(group string) common.SelectQuery {
g.db = g.db.Group(group)
return g
}
func (g *GormSelectQuery) Having(having string, args ...interface{}) common.SelectQuery {
g.db = g.db.Having(having, args...)
return g
}
func (g *GormSelectQuery) Scan(ctx context.Context, dest interface{}) error {
return g.db.WithContext(ctx).Find(dest).Error
}
func (g *GormSelectQuery) Count(ctx context.Context) (int, error) {
var count int64
err := g.db.WithContext(ctx).Count(&count).Error
return int(count), err
}
func (g *GormSelectQuery) Exists(ctx context.Context) (bool, error) {
var count int64
err := g.db.WithContext(ctx).Limit(1).Count(&count).Error
return count > 0, err
}
// GormInsertQuery implements InsertQuery for GORM
type GormInsertQuery struct {
db *gorm.DB
model interface{}
values map[string]interface{}
}
func (g *GormInsertQuery) Model(model interface{}) common.InsertQuery {
g.model = model
g.db = g.db.Model(model)
return g
}
func (g *GormInsertQuery) Table(table string) common.InsertQuery {
g.db = g.db.Table(table)
return g
}
func (g *GormInsertQuery) Value(column string, value interface{}) common.InsertQuery {
if g.values == nil {
g.values = make(map[string]interface{})
}
g.values[column] = value
return g
}
func (g *GormInsertQuery) OnConflict(action string) common.InsertQuery {
// GORM handles conflicts differently, this would need specific implementation
return g
}
func (g *GormInsertQuery) Returning(columns ...string) common.InsertQuery {
// GORM doesn't have explicit RETURNING, but updates the model
return g
}
func (g *GormInsertQuery) Exec(ctx context.Context) (common.Result, error) {
var result *gorm.DB
if g.model != nil {
result = g.db.WithContext(ctx).Create(g.model)
} else if g.values != nil {
result = g.db.WithContext(ctx).Create(g.values)
} else {
result = g.db.WithContext(ctx).Create(map[string]interface{}{})
}
return &GormResult{result: result}, result.Error
}
// GormUpdateQuery implements UpdateQuery for GORM
type GormUpdateQuery struct {
db *gorm.DB
model interface{}
updates interface{}
}
func (g *GormUpdateQuery) Model(model interface{}) common.UpdateQuery {
g.model = model
g.db = g.db.Model(model)
return g
}
func (g *GormUpdateQuery) Table(table string) common.UpdateQuery {
g.db = g.db.Table(table)
return g
}
func (g *GormUpdateQuery) Set(column string, value interface{}) common.UpdateQuery {
if g.updates == nil {
g.updates = make(map[string]interface{})
}
if updates, ok := g.updates.(map[string]interface{}); ok {
updates[column] = value
}
return g
}
func (g *GormUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuery {
g.updates = values
return g
}
func (g *GormUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
g.db = g.db.Where(query, args...)
return g
}
func (g *GormUpdateQuery) Returning(columns ...string) common.UpdateQuery {
// GORM doesn't have explicit RETURNING
return g
}
func (g *GormUpdateQuery) Exec(ctx context.Context) (common.Result, error) {
result := g.db.WithContext(ctx).Updates(g.updates)
return &GormResult{result: result}, result.Error
}
// GormDeleteQuery implements DeleteQuery for GORM
type GormDeleteQuery struct {
db *gorm.DB
model interface{}
}
func (g *GormDeleteQuery) Model(model interface{}) common.DeleteQuery {
g.model = model
g.db = g.db.Model(model)
return g
}
func (g *GormDeleteQuery) Table(table string) common.DeleteQuery {
g.db = g.db.Table(table)
return g
}
func (g *GormDeleteQuery) Where(query string, args ...interface{}) common.DeleteQuery {
g.db = g.db.Where(query, args...)
return g
}
func (g *GormDeleteQuery) Exec(ctx context.Context) (common.Result, error) {
result := g.db.WithContext(ctx).Delete(g.model)
return &GormResult{result: result}, result.Error
}
// GormResult implements Result for GORM
type GormResult struct {
result *gorm.DB
}
func (g *GormResult) RowsAffected() int64 {
return g.result.RowsAffected
}
func (g *GormResult) LastInsertId() (int64, error) {
// GORM doesn't directly provide last insert ID, would need specific implementation
return 0, nil
}

View File

@@ -0,0 +1,13 @@
package database
import "strings"
// parseTableName splits a table name that may contain schema into separate schema and table
// For example: "public.users" -> ("public", "users")
// "users" -> ("", "users")
func parseTableName(fullTableName string) (schema, table string) {
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
return fullTableName[:idx], fullTableName[idx+1:]
}
return "", fullTableName
}

View File

@@ -0,0 +1,193 @@
package router
import (
"net/http"
"github.com/Warky-Devs/ResolveSpec/pkg/common"
"github.com/uptrace/bunrouter"
)
// BunRouterAdapter adapts uptrace/bunrouter to work with our Router interface
type BunRouterAdapter struct {
router *bunrouter.Router
}
// NewBunRouterAdapter creates a new bunrouter adapter
func NewBunRouterAdapter(router *bunrouter.Router) *BunRouterAdapter {
return &BunRouterAdapter{router: router}
}
// NewBunRouterAdapterDefault creates a new bunrouter adapter with default router
func NewBunRouterAdapterDefault() *BunRouterAdapter {
return &BunRouterAdapter{router: bunrouter.New()}
}
func (b *BunRouterAdapter) HandleFunc(pattern string, handler common.HTTPHandlerFunc) common.RouteRegistration {
route := &BunRouterRegistration{
router: b.router,
pattern: pattern,
handler: handler,
}
return route
}
func (b *BunRouterAdapter) ServeHTTP(w common.ResponseWriter, r common.Request) {
// This method would be used when we need to serve through our interface
// For now, we'll work directly with the underlying router
panic("ServeHTTP not implemented - use GetBunRouter() for direct access")
}
// GetBunRouter returns the underlying bunrouter for direct access
func (b *BunRouterAdapter) GetBunRouter() *bunrouter.Router {
return b.router
}
// BunRouterRegistration implements RouteRegistration for bunrouter
type BunRouterRegistration struct {
router *bunrouter.Router
pattern string
handler common.HTTPHandlerFunc
}
func (b *BunRouterRegistration) Methods(methods ...string) common.RouteRegistration {
// bunrouter handles methods differently - we'll register for each method
for _, method := range methods {
b.router.Handle(method, b.pattern, func(w http.ResponseWriter, req bunrouter.Request) error {
// Convert bunrouter.Request to our BunRouterRequest
reqAdapter := &BunRouterRequest{req: req}
respAdapter := &HTTPResponseWriter{resp: w}
b.handler(respAdapter, reqAdapter)
return nil
})
}
return b
}
func (b *BunRouterRegistration) PathPrefix(prefix string) common.RouteRegistration {
// bunrouter doesn't have PathPrefix like mux, but we can modify the pattern
newPattern := prefix + b.pattern
b.pattern = newPattern
return b
}
// BunRouterRequest adapts bunrouter.Request to our Request interface
type BunRouterRequest struct {
req bunrouter.Request
body []byte
}
// NewBunRouterRequest creates a new BunRouterRequest adapter
func NewBunRouterRequest(req bunrouter.Request) *BunRouterRequest {
return &BunRouterRequest{req: req}
}
func (b *BunRouterRequest) Method() string {
return b.req.Method
}
func (b *BunRouterRequest) URL() string {
return b.req.URL.String()
}
func (b *BunRouterRequest) Header(key string) string {
return b.req.Header.Get(key)
}
func (b *BunRouterRequest) Body() ([]byte, error) {
if b.body != nil {
return b.body, nil
}
if b.req.Body == nil {
return nil, nil
}
// Create HTTPRequest adapter and use its Body() method
httpAdapter := NewHTTPRequest(b.req.Request)
body, err := httpAdapter.Body()
if err != nil {
return nil, err
}
b.body = body
return body, nil
}
func (b *BunRouterRequest) PathParam(key string) string {
return b.req.Param(key)
}
func (b *BunRouterRequest) QueryParam(key string) string {
return b.req.URL.Query().Get(key)
}
func (b *BunRouterRequest) AllHeaders() map[string]string {
headers := make(map[string]string)
for key, values := range b.req.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
return headers
}
// StandardBunRouterAdapter creates routes compatible with standard bunrouter handlers
type StandardBunRouterAdapter struct {
*BunRouterAdapter
}
func NewStandardBunRouterAdapter() *StandardBunRouterAdapter {
return &StandardBunRouterAdapter{
BunRouterAdapter: NewBunRouterAdapterDefault(),
}
}
// RegisterRoute registers a route that works with the existing Handler
func (s *StandardBunRouterAdapter) RegisterRoute(method, pattern string, handler func(http.ResponseWriter, *http.Request, map[string]string)) {
s.router.Handle(method, pattern, func(w http.ResponseWriter, req bunrouter.Request) error {
// Extract path parameters
params := make(map[string]string)
// bunrouter doesn't provide a direct way to get all params
// You would typically access them individually with req.Param("name")
// For this example, we'll create the map based on the request context
handler(w, req.Request, params)
return nil
})
}
// RegisterRouteWithParams registers a route with explicit parameter extraction
func (s *StandardBunRouterAdapter) RegisterRouteWithParams(method, pattern string, paramNames []string, handler func(http.ResponseWriter, *http.Request, map[string]string)) {
s.router.Handle(method, pattern, func(w http.ResponseWriter, req bunrouter.Request) error {
// Extract specified path parameters
params := make(map[string]string)
for _, paramName := range paramNames {
params[paramName] = req.Param(paramName)
}
handler(w, req.Request, params)
return nil
})
}
// BunRouterConfig holds bunrouter-specific configuration
type BunRouterConfig struct {
UseStrictSlash bool
RedirectTrailingSlash bool
HandleMethodNotAllowed bool
HandleOPTIONS bool
GlobalOPTIONS http.Handler
GlobalMethodNotAllowed http.Handler
PanicHandler func(http.ResponseWriter, *http.Request, interface{})
}
// DefaultBunRouterConfig returns default bunrouter configuration
func DefaultBunRouterConfig() *BunRouterConfig {
return &BunRouterConfig{
UseStrictSlash: false,
RedirectTrailingSlash: true,
HandleMethodNotAllowed: true,
HandleOPTIONS: true,
}
}

View File

@@ -0,0 +1,208 @@
package router
import (
"encoding/json"
"io"
"net/http"
"github.com/Warky-Devs/ResolveSpec/pkg/common"
"github.com/gorilla/mux"
)
// MuxAdapter adapts Gorilla Mux to work with our Router interface
type MuxAdapter struct {
router *mux.Router
}
// NewMuxAdapter creates a new Mux adapter
func NewMuxAdapter(router *mux.Router) *MuxAdapter {
return &MuxAdapter{router: router}
}
func (m *MuxAdapter) HandleFunc(pattern string, handler common.HTTPHandlerFunc) common.RouteRegistration {
route := &MuxRouteRegistration{
router: m.router,
pattern: pattern,
handler: handler,
}
return route
}
func (m *MuxAdapter) ServeHTTP(w common.ResponseWriter, r common.Request) {
// This method would be used when we need to serve through our interface
// For now, we'll work directly with the underlying router
panic("ServeHTTP not implemented - use GetMuxRouter() for direct access")
}
// MuxRouteRegistration implements RouteRegistration for Mux
type MuxRouteRegistration struct {
router *mux.Router
pattern string
handler common.HTTPHandlerFunc
route *mux.Route
}
func (m *MuxRouteRegistration) Methods(methods ...string) common.RouteRegistration {
if m.route == nil {
m.route = m.router.HandleFunc(m.pattern, func(w http.ResponseWriter, r *http.Request) {
reqAdapter := &HTTPRequest{req: r, vars: mux.Vars(r)}
respAdapter := &HTTPResponseWriter{resp: w}
m.handler(respAdapter, reqAdapter)
})
}
m.route.Methods(methods...)
return m
}
func (m *MuxRouteRegistration) PathPrefix(prefix string) common.RouteRegistration {
if m.route == nil {
m.route = m.router.HandleFunc(m.pattern, func(w http.ResponseWriter, r *http.Request) {
reqAdapter := &HTTPRequest{req: r, vars: mux.Vars(r)}
respAdapter := &HTTPResponseWriter{resp: w}
m.handler(respAdapter, reqAdapter)
})
}
m.route.PathPrefix(prefix)
return m
}
// HTTPRequest adapts standard http.Request to our Request interface
type HTTPRequest struct {
req *http.Request
vars map[string]string
body []byte
}
func NewHTTPRequest(r *http.Request) *HTTPRequest {
return &HTTPRequest{
req: r,
vars: make(map[string]string),
}
}
func (h *HTTPRequest) Method() string {
return h.req.Method
}
func (h *HTTPRequest) URL() string {
return h.req.URL.String()
}
func (h *HTTPRequest) Header(key string) string {
return h.req.Header.Get(key)
}
func (h *HTTPRequest) Body() ([]byte, error) {
if h.body != nil {
return h.body, nil
}
if h.req.Body == nil {
return nil, nil
}
defer h.req.Body.Close()
body, err := io.ReadAll(h.req.Body)
if err != nil {
return nil, err
}
h.body = body
return body, nil
}
func (h *HTTPRequest) PathParam(key string) string {
return h.vars[key]
}
func (h *HTTPRequest) QueryParam(key string) string {
return h.req.URL.Query().Get(key)
}
func (h *HTTPRequest) AllHeaders() map[string]string {
headers := make(map[string]string)
for key, values := range h.req.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
return headers
}
// HTTPResponseWriter adapts our ResponseWriter interface to standard http.ResponseWriter
type HTTPResponseWriter struct {
resp http.ResponseWriter
w common.ResponseWriter
status int
}
func NewHTTPResponseWriter(w http.ResponseWriter) *HTTPResponseWriter {
return &HTTPResponseWriter{resp: w}
}
func (h *HTTPResponseWriter) SetHeader(key, value string) {
h.resp.Header().Set(key, value)
}
func (h *HTTPResponseWriter) WriteHeader(statusCode int) {
h.status = statusCode
h.resp.WriteHeader(statusCode)
}
func (h *HTTPResponseWriter) Write(data []byte) (int, error) {
return h.resp.Write(data)
}
func (h *HTTPResponseWriter) WriteJSON(data interface{}) error {
h.SetHeader("Content-Type", "application/json")
return json.NewEncoder(h.resp).Encode(data)
}
// StandardMuxAdapter creates routes compatible with standard http.HandlerFunc
type StandardMuxAdapter struct {
*MuxAdapter
}
func NewStandardMuxAdapter() *StandardMuxAdapter {
return &StandardMuxAdapter{
MuxAdapter: NewMuxAdapter(mux.NewRouter()),
}
}
// RegisterRoute registers a route that works with the existing Handler
func (s *StandardMuxAdapter) RegisterRoute(pattern string, handler func(http.ResponseWriter, *http.Request, map[string]string)) *mux.Route {
return s.router.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
handler(w, r, vars)
})
}
// GetMuxRouter returns the underlying mux router for direct access
func (s *StandardMuxAdapter) GetMuxRouter() *mux.Router {
return s.router
}
// PathParamExtractor extracts path parameters from different router types
type PathParamExtractor interface {
ExtractParams(*http.Request) map[string]string
}
// MuxParamExtractor extracts parameters from Gorilla Mux
type MuxParamExtractor struct{}
func (m MuxParamExtractor) ExtractParams(r *http.Request) map[string]string {
return mux.Vars(r)
}
// RouterConfig holds router configuration
type RouterConfig struct {
PathPrefix string
Middleware []func(http.Handler) http.Handler
ParamExtractor PathParamExtractor
}
// DefaultRouterConfig returns default router configuration
func DefaultRouterConfig() *RouterConfig {
return &RouterConfig{
PathPrefix: "",
Middleware: make([]func(http.Handler) http.Handler, 0),
ParamExtractor: MuxParamExtractor{},
}
}

137
pkg/common/interfaces.go Normal file
View File

@@ -0,0 +1,137 @@
package common
import "context"
// Database interface designed to work with both GORM and Bun
type Database interface {
// Core query operations
NewSelect() SelectQuery
NewInsert() InsertQuery
NewUpdate() UpdateQuery
NewDelete() DeleteQuery
// Raw SQL execution
Exec(ctx context.Context, query string, args ...interface{}) (Result, error)
Query(ctx context.Context, dest interface{}, query string, args ...interface{}) error
// Transaction support
BeginTx(ctx context.Context) (Database, error)
CommitTx(ctx context.Context) error
RollbackTx(ctx context.Context) error
RunInTransaction(ctx context.Context, fn func(Database) error) error
}
// SelectQuery interface for building SELECT queries (compatible with both GORM and Bun)
type SelectQuery interface {
Model(model interface{}) SelectQuery
Table(table string) SelectQuery
Column(columns ...string) SelectQuery
Where(query string, args ...interface{}) SelectQuery
WhereOr(query string, args ...interface{}) SelectQuery
Join(query string, args ...interface{}) SelectQuery
LeftJoin(query string, args ...interface{}) SelectQuery
Preload(relation string, conditions ...interface{}) SelectQuery
Order(order string) SelectQuery
Limit(n int) SelectQuery
Offset(n int) SelectQuery
Group(group string) SelectQuery
Having(having string, args ...interface{}) SelectQuery
// Execution methods
Scan(ctx context.Context, dest interface{}) error
Count(ctx context.Context) (int, error)
Exists(ctx context.Context) (bool, error)
}
// InsertQuery interface for building INSERT queries
type InsertQuery interface {
Model(model interface{}) InsertQuery
Table(table string) InsertQuery
Value(column string, value interface{}) InsertQuery
OnConflict(action string) InsertQuery
Returning(columns ...string) InsertQuery
// Execution
Exec(ctx context.Context) (Result, error)
}
// UpdateQuery interface for building UPDATE queries
type UpdateQuery interface {
Model(model interface{}) UpdateQuery
Table(table string) UpdateQuery
Set(column string, value interface{}) UpdateQuery
SetMap(values map[string]interface{}) UpdateQuery
Where(query string, args ...interface{}) UpdateQuery
Returning(columns ...string) UpdateQuery
// Execution
Exec(ctx context.Context) (Result, error)
}
// DeleteQuery interface for building DELETE queries
type DeleteQuery interface {
Model(model interface{}) DeleteQuery
Table(table string) DeleteQuery
Where(query string, args ...interface{}) DeleteQuery
// Execution
Exec(ctx context.Context) (Result, error)
}
// Result interface for query execution results
type Result interface {
RowsAffected() int64
LastInsertId() (int64, error)
}
// ModelRegistry manages model registration and retrieval
type ModelRegistry interface {
RegisterModel(name string, model interface{}) error
GetModel(name string) (interface{}, error)
GetAllModels() map[string]interface{}
GetModelByEntity(schema, entity string) (interface{}, error)
}
// Router interface for HTTP router abstraction
type Router interface {
HandleFunc(pattern string, handler HTTPHandlerFunc) RouteRegistration
ServeHTTP(w ResponseWriter, r Request)
}
// RouteRegistration allows method chaining for route configuration
type RouteRegistration interface {
Methods(methods ...string) RouteRegistration
PathPrefix(prefix string) RouteRegistration
}
// Request interface abstracts HTTP request
type Request interface {
Method() string
URL() string
Header(key string) string
AllHeaders() map[string]string // Get all headers as a map
Body() ([]byte, error)
PathParam(key string) string
QueryParam(key string) string
}
// ResponseWriter interface abstracts HTTP response
type ResponseWriter interface {
SetHeader(key, value string)
WriteHeader(statusCode int)
Write(data []byte) (int, error)
WriteJSON(data interface{}) error
}
// HTTPHandlerFunc type for HTTP handlers
type HTTPHandlerFunc func(ResponseWriter, Request)
// TableNameProvider interface for models that provide table names
type TableNameProvider interface {
TableName() string
}
// SchemaProvider interface for models that provide schema names
type SchemaProvider interface {
SchemaName() string
}

View File

@@ -1,4 +1,4 @@
package resolvespec
package common
type RequestBody struct {
Operation string `json:"operation"`

272
pkg/common/validation.go Normal file
View File

@@ -0,0 +1,272 @@
package common
import (
"fmt"
"reflect"
"strings"
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
)
// ColumnValidator validates column names against a model's fields
type ColumnValidator struct {
validColumns map[string]bool
model interface{}
}
// NewColumnValidator creates a new column validator for a given model
func NewColumnValidator(model interface{}) *ColumnValidator {
validator := &ColumnValidator{
validColumns: make(map[string]bool),
model: model,
}
validator.buildValidColumns()
return validator
}
// buildValidColumns extracts all valid column names from the model using reflection
func (v *ColumnValidator) buildValidColumns() {
modelType := reflect.TypeOf(v.model)
// Unwrap pointers, slices, and arrays to get to the base struct type
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
// Validate that we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
return
}
// Extract column names from struct fields
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if !field.IsExported() {
continue
}
// Get column name from bun, gorm, or json tag
columnName := v.getColumnName(field)
if columnName != "" && columnName != "-" {
v.validColumns[strings.ToLower(columnName)] = true
}
}
}
// getColumnName extracts the column name from a struct field's tags
// Supports both Bun and GORM tags
func (v *ColumnValidator) getColumnName(field reflect.StructField) string {
// First check Bun tag for column name
bunTag := field.Tag.Get("bun")
if bunTag != "" && bunTag != "-" {
parts := strings.Split(bunTag, ",")
// The first part is usually the column name
columnName := strings.TrimSpace(parts[0])
if columnName != "" && columnName != "-" {
return columnName
}
}
// Check GORM tag for column name
gormTag := field.Tag.Get("gorm")
if strings.Contains(gormTag, "column:") {
parts := strings.Split(gormTag, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "column:") {
return strings.TrimPrefix(part, "column:")
}
}
}
// Fall back to JSON tag
jsonTag := field.Tag.Get("json")
if jsonTag != "" && jsonTag != "-" {
// Extract just the name part (before any comma)
jsonName := strings.Split(jsonTag, ",")[0]
return jsonName
}
// Fall back to field name in lowercase (snake_case conversion would be better)
return strings.ToLower(field.Name)
}
// ValidateColumn validates a single column name
// Returns nil if valid, error if invalid
// Columns prefixed with "cql" (case insensitive) are always valid
func (v *ColumnValidator) ValidateColumn(column string) error {
// Allow empty columns
if column == "" {
return nil
}
// Allow columns prefixed with "cql" (case insensitive) for computed columns
if strings.HasPrefix(strings.ToLower(column), "cql") {
return nil
}
// Check if column exists in model
if _, exists := v.validColumns[strings.ToLower(column)]; !exists {
return fmt.Errorf("invalid column '%s': column does not exist in model", column)
}
return nil
}
// IsValidColumn checks if a column is valid
// Returns true if valid, false if invalid
func (v *ColumnValidator) IsValidColumn(column string) bool {
return v.ValidateColumn(column) == nil
}
// FilterValidColumns filters a list of columns, returning only valid ones
// Logs warnings for any invalid columns
func (v *ColumnValidator) FilterValidColumns(columns []string) []string {
if len(columns) == 0 {
return columns
}
validColumns := make([]string, 0, len(columns))
for _, col := range columns {
if v.IsValidColumn(col) {
validColumns = append(validColumns, col)
} else {
logger.Warn("Invalid column '%s' filtered out: column does not exist in model", col)
}
}
return validColumns
}
// ValidateColumns validates multiple column names
// Returns error with details about all invalid columns
func (v *ColumnValidator) ValidateColumns(columns []string) error {
var invalidColumns []string
for _, column := range columns {
if err := v.ValidateColumn(column); err != nil {
invalidColumns = append(invalidColumns, column)
}
}
if len(invalidColumns) > 0 {
return fmt.Errorf("invalid columns: %s", strings.Join(invalidColumns, ", "))
}
return nil
}
// ValidateRequestOptions validates all column references in RequestOptions
func (v *ColumnValidator) ValidateRequestOptions(options RequestOptions) error {
// Validate Columns
if err := v.ValidateColumns(options.Columns); err != nil {
return fmt.Errorf("in select columns: %w", err)
}
// Validate OmitColumns
if err := v.ValidateColumns(options.OmitColumns); err != nil {
return fmt.Errorf("in omit columns: %w", err)
}
// Validate Filter columns
for _, filter := range options.Filters {
if err := v.ValidateColumn(filter.Column); err != nil {
return fmt.Errorf("in filter: %w", err)
}
}
// Validate Sort columns
for _, sort := range options.Sort {
if err := v.ValidateColumn(sort.Column); err != nil {
return fmt.Errorf("in sort: %w", err)
}
}
// Validate Preload columns (if specified)
for _, preload := range options.Preload {
// Note: We don't validate the relation name itself, as it's a relationship
// Only validate columns if specified for the preload
if err := v.ValidateColumns(preload.Columns); err != nil {
return fmt.Errorf("in preload '%s' columns: %w", preload.Relation, err)
}
if err := v.ValidateColumns(preload.OmitColumns); err != nil {
return fmt.Errorf("in preload '%s' omit columns: %w", preload.Relation, err)
}
// Validate filter columns in preload
for _, filter := range preload.Filters {
if err := v.ValidateColumn(filter.Column); err != nil {
return fmt.Errorf("in preload '%s' filter: %w", preload.Relation, err)
}
}
}
return nil
}
// FilterRequestOptions filters all column references in RequestOptions
// Returns a new RequestOptions with only valid columns, logging warnings for invalid ones
func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOptions {
filtered := options
// Filter Columns
filtered.Columns = v.FilterValidColumns(options.Columns)
// Filter OmitColumns
filtered.OmitColumns = v.FilterValidColumns(options.OmitColumns)
// Filter Filter columns
validFilters := make([]FilterOption, 0, len(options.Filters))
for _, filter := range options.Filters {
if v.IsValidColumn(filter.Column) {
validFilters = append(validFilters, filter)
} else {
logger.Warn("Invalid column in filter '%s' removed", filter.Column)
}
}
filtered.Filters = validFilters
// Filter Sort columns
validSorts := make([]SortOption, 0, len(options.Sort))
for _, sort := range options.Sort {
if v.IsValidColumn(sort.Column) {
validSorts = append(validSorts, sort)
} else {
logger.Warn("Invalid column in sort '%s' removed", sort.Column)
}
}
filtered.Sort = validSorts
// Filter Preload columns
validPreloads := make([]PreloadOption, 0, len(options.Preload))
for _, preload := range options.Preload {
filteredPreload := preload
filteredPreload.Columns = v.FilterValidColumns(preload.Columns)
filteredPreload.OmitColumns = v.FilterValidColumns(preload.OmitColumns)
// Filter preload filters
validPreloadFilters := make([]FilterOption, 0, len(preload.Filters))
for _, filter := range preload.Filters {
if v.IsValidColumn(filter.Column) {
validPreloadFilters = append(validPreloadFilters, filter)
} else {
logger.Warn("Invalid column in preload '%s' filter '%s' removed", preload.Relation, filter.Column)
}
}
filteredPreload.Filters = validPreloadFilters
validPreloads = append(validPreloads, filteredPreload)
}
filtered.Preload = validPreloads
return filtered
}
// GetValidColumns returns a list of all valid column names for debugging purposes
func (v *ColumnValidator) GetValidColumns() []string {
columns := make([]string, 0, len(v.validColumns))
for col := range v.validColumns {
columns = append(columns, col)
}
return columns
}

View File

@@ -0,0 +1,363 @@
package common
import (
"strings"
"testing"
)
// TestModel represents a sample model for testing
type TestModel struct {
ID int64 `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"column:name"`
Email string `json:"email" bun:"email"`
Age int `json:"age"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
}
func TestNewColumnValidator(t *testing.T) {
model := TestModel{}
validator := NewColumnValidator(model)
if validator == nil {
t.Fatal("Expected validator to be created")
}
if len(validator.validColumns) == 0 {
t.Fatal("Expected validator to have valid columns")
}
// Check that expected columns are present
expectedColumns := []string{"id", "name", "email", "age", "is_active", "created_at"}
for _, col := range expectedColumns {
if !validator.validColumns[col] {
t.Errorf("Expected column '%s' to be valid", col)
}
}
}
func TestValidateColumn(t *testing.T) {
model := TestModel{}
validator := NewColumnValidator(model)
tests := []struct {
name string
column string
shouldError bool
}{
{"Valid column - id", "id", false},
{"Valid column - name", "name", false},
{"Valid column - email", "email", false},
{"Valid column - uppercase", "ID", false}, // Case insensitive
{"Invalid column", "invalid_column", true},
{"CQL prefixed - should be valid", "cqlComputedField", false},
{"CQL prefixed uppercase - should be valid", "CQLComputedField", false},
{"Empty column", "", false}, // Empty columns are allowed
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateColumn(tt.column)
if tt.shouldError && err == nil {
t.Errorf("Expected error for column '%s', got nil", tt.column)
}
if !tt.shouldError && err != nil {
t.Errorf("Expected no error for column '%s', got: %v", tt.column, err)
}
})
}
}
func TestValidateColumns(t *testing.T) {
model := TestModel{}
validator := NewColumnValidator(model)
tests := []struct {
name string
columns []string
shouldError bool
}{
{"All valid columns", []string{"id", "name", "email"}, false},
{"One invalid column", []string{"id", "invalid_col", "name"}, true},
{"All invalid columns", []string{"bad1", "bad2"}, true},
{"With CQL prefix", []string{"id", "cqlComputed", "name"}, false},
{"Empty list", []string{}, false},
{"Nil list", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateColumns(tt.columns)
if tt.shouldError && err == nil {
t.Errorf("Expected error for columns %v, got nil", tt.columns)
}
if !tt.shouldError && err != nil {
t.Errorf("Expected no error for columns %v, got: %v", tt.columns, err)
}
})
}
}
func TestValidateRequestOptions(t *testing.T) {
model := TestModel{}
validator := NewColumnValidator(model)
tests := []struct {
name string
options RequestOptions
shouldError bool
errorMsg string
}{
{
name: "Valid options with columns",
options: RequestOptions{
Columns: []string{"id", "name"},
Filters: []FilterOption{
{Column: "name", Operator: "eq", Value: "test"},
},
Sort: []SortOption{
{Column: "id", Direction: "ASC"},
},
},
shouldError: false,
},
{
name: "Invalid column in Columns",
options: RequestOptions{
Columns: []string{"id", "invalid_column"},
},
shouldError: true,
errorMsg: "select columns",
},
{
name: "Invalid column in Filters",
options: RequestOptions{
Filters: []FilterOption{
{Column: "invalid_col", Operator: "eq", Value: "test"},
},
},
shouldError: true,
errorMsg: "filter",
},
{
name: "Invalid column in Sort",
options: RequestOptions{
Sort: []SortOption{
{Column: "invalid_col", Direction: "ASC"},
},
},
shouldError: true,
errorMsg: "sort",
},
{
name: "Valid CQL prefixed columns",
options: RequestOptions{
Columns: []string{"id", "cqlComputedField"},
Filters: []FilterOption{
{Column: "cqlCustomFilter", Operator: "eq", Value: "test"},
},
},
shouldError: false,
},
{
name: "Invalid column in Preload",
options: RequestOptions{
Preload: []PreloadOption{
{
Relation: "SomeRelation",
Columns: []string{"id", "invalid_col"},
},
},
},
shouldError: true,
errorMsg: "preload",
},
{
name: "Valid preload with valid columns",
options: RequestOptions{
Preload: []PreloadOption{
{
Relation: "SomeRelation",
Columns: []string{"id", "name"},
},
},
},
shouldError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateRequestOptions(tt.options)
if tt.shouldError {
if err == nil {
t.Errorf("Expected error, got nil")
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("Expected error to contain '%s', got: %v", tt.errorMsg, err)
}
} else {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
})
}
}
func TestGetValidColumns(t *testing.T) {
model := TestModel{}
validator := NewColumnValidator(model)
columns := validator.GetValidColumns()
if len(columns) == 0 {
t.Error("Expected to get valid columns, got empty list")
}
// Should have at least the columns from TestModel
if len(columns) < 6 {
t.Errorf("Expected at least 6 columns, got %d", len(columns))
}
}
// Test with Bun tags specifically
type BunModel struct {
ID int64 `bun:"id,pk"`
Name string `bun:"name"`
Email string `bun:"user_email"`
}
func TestBunTagSupport(t *testing.T) {
model := BunModel{}
validator := NewColumnValidator(model)
// Test that bun tags are properly recognized
tests := []struct {
column string
shouldError bool
}{
{"id", false},
{"name", false},
{"user_email", false}, // Bun tag specifies this name
{"email", true}, // JSON tag would be "email", but bun tag says "user_email"
}
for _, tt := range tests {
t.Run(tt.column, func(t *testing.T) {
err := validator.ValidateColumn(tt.column)
if tt.shouldError && err == nil {
t.Errorf("Expected error for column '%s'", tt.column)
}
if !tt.shouldError && err != nil {
t.Errorf("Expected no error for column '%s', got: %v", tt.column, err)
}
})
}
}
func TestFilterValidColumns(t *testing.T) {
model := TestModel{}
validator := NewColumnValidator(model)
tests := []struct {
name string
input []string
expectedOutput []string
}{
{
name: "All valid columns",
input: []string{"id", "name", "email"},
expectedOutput: []string{"id", "name", "email"},
},
{
name: "Mix of valid and invalid",
input: []string{"id", "invalid_col", "name", "bad_col", "email"},
expectedOutput: []string{"id", "name", "email"},
},
{
name: "All invalid columns",
input: []string{"bad1", "bad2"},
expectedOutput: []string{},
},
{
name: "With CQL prefix (should pass)",
input: []string{"id", "cqlComputed", "name"},
expectedOutput: []string{"id", "cqlComputed", "name"},
},
{
name: "Empty input",
input: []string{},
expectedOutput: []string{},
},
{
name: "Nil input",
input: nil,
expectedOutput: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.FilterValidColumns(tt.input)
if len(result) != len(tt.expectedOutput) {
t.Errorf("Expected %d columns, got %d", len(tt.expectedOutput), len(result))
}
for i, col := range result {
if col != tt.expectedOutput[i] {
t.Errorf("At index %d: expected %s, got %s", i, tt.expectedOutput[i], col)
}
}
})
}
}
func TestFilterRequestOptions(t *testing.T) {
model := TestModel{}
validator := NewColumnValidator(model)
options := RequestOptions{
Columns: []string{"id", "name", "invalid_col"},
OmitColumns: []string{"email", "bad_col"},
Filters: []FilterOption{
{Column: "name", Operator: "eq", Value: "test"},
{Column: "invalid_col", Operator: "eq", Value: "test"},
},
Sort: []SortOption{
{Column: "id", Direction: "ASC"},
{Column: "bad_col", Direction: "DESC"},
},
}
filtered := validator.FilterRequestOptions(options)
// Check Columns
if len(filtered.Columns) != 2 {
t.Errorf("Expected 2 columns, got %d", len(filtered.Columns))
}
if filtered.Columns[0] != "id" || filtered.Columns[1] != "name" {
t.Errorf("Expected columns [id, name], got %v", filtered.Columns)
}
// Check OmitColumns
if len(filtered.OmitColumns) != 1 {
t.Errorf("Expected 1 omit column, got %d", len(filtered.OmitColumns))
}
if filtered.OmitColumns[0] != "email" {
t.Errorf("Expected omit column [email], got %v", filtered.OmitColumns)
}
// Check Filters
if len(filtered.Filters) != 1 {
t.Errorf("Expected 1 filter, got %d", len(filtered.Filters))
}
if filtered.Filters[0].Column != "name" {
t.Errorf("Expected filter column 'name', got %s", filtered.Filters[0].Column)
}
// Check Sort
if len(filtered.Sort) != 1 {
t.Errorf("Expected 1 sort, got %d", len(filtered.Sort))
}
if filtered.Sort[0].Column != "id" {
t.Errorf("Expected sort column 'id', got %s", filtered.Sort[0].Column)
}
}

View File

@@ -0,0 +1,135 @@
package modelregistry
import (
"fmt"
"reflect"
"sync"
)
// DefaultModelRegistry implements ModelRegistry interface
type DefaultModelRegistry struct {
models map[string]interface{}
mutex sync.RWMutex
}
// Global default registry instance
var defaultRegistry = &DefaultModelRegistry{
models: make(map[string]interface{}),
}
// NewModelRegistry creates a new model registry
func NewModelRegistry() *DefaultModelRegistry {
return &DefaultModelRegistry{
models: make(map[string]interface{}),
}
}
func (r *DefaultModelRegistry) RegisterModel(name string, model interface{}) error {
r.mutex.Lock()
defer r.mutex.Unlock()
if _, exists := r.models[name]; exists {
return fmt.Errorf("model %s already registered", name)
}
// Validate that model is a non-pointer struct
modelType := reflect.TypeOf(model)
if modelType == nil {
return fmt.Errorf("model cannot be nil")
}
originalType := modelType
// Unwrap pointers, slices, and arrays to check the underlying type
for modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array {
modelType = modelType.Elem()
}
// Validate that the underlying type is a struct
if modelType.Kind() != reflect.Struct {
return fmt.Errorf("model must be a struct or pointer to struct, got %s", originalType.String())
}
// If a pointer/slice/array was passed, unwrap to the base struct
if originalType != modelType {
// Create a zero value of the struct type
model = reflect.New(modelType).Elem().Interface()
}
// Additional check: ensure model is not a pointer
finalType := reflect.TypeOf(model)
if finalType.Kind() == reflect.Ptr {
return fmt.Errorf("model must be a non-pointer struct, got pointer to %s. Use MyModel{} instead of &MyModel{}", finalType.Elem().Name())
}
r.models[name] = model
return nil
}
func (r *DefaultModelRegistry) GetModel(name string) (interface{}, error) {
r.mutex.RLock()
defer r.mutex.RUnlock()
model, exists := r.models[name]
if !exists {
return nil, fmt.Errorf("model %s not found", name)
}
return model, nil
}
func (r *DefaultModelRegistry) GetAllModels() map[string]interface{} {
r.mutex.RLock()
defer r.mutex.RUnlock()
result := make(map[string]interface{})
for k, v := range r.models {
result[k] = v
}
return result
}
func (r *DefaultModelRegistry) GetModelByEntity(schema, entity string) (interface{}, error) {
// Try full name first
fullName := fmt.Sprintf("%s.%s", schema, entity)
if model, err := r.GetModel(fullName); err == nil {
return model, nil
}
// Fallback to entity name only
return r.GetModel(entity)
}
// Global convenience functions using the default registry
// RegisterModel registers a model with the default global registry
func RegisterModel(model interface{}, name string) error {
return defaultRegistry.RegisterModel(name, model)
}
// GetModelByName retrieves a model from the default global registry by name
func GetModelByName(name string) (interface{}, error) {
return defaultRegistry.GetModel(name)
}
// IterateModels iterates over all models in the default global registry
func IterateModels(fn func(name string, model interface{})) {
defaultRegistry.mutex.RLock()
defer defaultRegistry.mutex.RUnlock()
for name, model := range defaultRegistry.models {
fn(name, model)
}
}
// GetModels returns a list of all models in the default global registry
func GetModels() []interface{} {
defaultRegistry.mutex.RLock()
defer defaultRegistry.mutex.RUnlock()
models := make([]interface{}, 0, len(defaultRegistry.models))
for _, model := range defaultRegistry.models {
models = append(models, model)
}
return models
}

View File

@@ -1,71 +0,0 @@
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) error {
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
return nil
}
// 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

@@ -1,91 +0,0 @@
package resolvespec
import (
"encoding/json"
"fmt"
"io"
"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 r.Body == nil {
logger.Error("No body to decode")
h.sendError(w, http.StatusBadRequest, "invalid_request", "No body to decode", nil)
return
} else {
defer r.Body.Close()
}
if bodyContents, err := io.ReadAll(r.Body); err != nil {
logger.Error("Failed to decode read body: %v", err)
h.sendError(w, http.StatusBadRequest, "read_request", "Invalid request body", err)
return
} else {
if err := json.Unmarshal(bodyContents, &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),
},
})
}

View File

@@ -0,0 +1,85 @@
package resolvespec
import (
"context"
)
// Context keys for request-scoped data
type contextKey string
const (
contextKeySchema contextKey = "schema"
contextKeyEntity contextKey = "entity"
contextKeyTableName contextKey = "tableName"
contextKeyModel contextKey = "model"
contextKeyModelPtr contextKey = "modelPtr"
)
// WithSchema adds schema to context
func WithSchema(ctx context.Context, schema string) context.Context {
return context.WithValue(ctx, contextKeySchema, schema)
}
// GetSchema retrieves schema from context
func GetSchema(ctx context.Context) string {
if v := ctx.Value(contextKeySchema); v != nil {
return v.(string)
}
return ""
}
// WithEntity adds entity to context
func WithEntity(ctx context.Context, entity string) context.Context {
return context.WithValue(ctx, contextKeyEntity, entity)
}
// GetEntity retrieves entity from context
func GetEntity(ctx context.Context) string {
if v := ctx.Value(contextKeyEntity); v != nil {
return v.(string)
}
return ""
}
// WithTableName adds table name to context
func WithTableName(ctx context.Context, tableName string) context.Context {
return context.WithValue(ctx, contextKeyTableName, tableName)
}
// GetTableName retrieves table name from context
func GetTableName(ctx context.Context) string {
if v := ctx.Value(contextKeyTableName); v != nil {
return v.(string)
}
return ""
}
// WithModel adds model to context
func WithModel(ctx context.Context, model interface{}) context.Context {
return context.WithValue(ctx, contextKeyModel, model)
}
// GetModel retrieves model from context
func GetModel(ctx context.Context) interface{} {
return ctx.Value(contextKeyModel)
}
// WithModelPtr adds model pointer to context
func WithModelPtr(ctx context.Context, modelPtr interface{}) context.Context {
return context.WithValue(ctx, contextKeyModelPtr, modelPtr)
}
// GetModelPtr retrieves model pointer from context
func GetModelPtr(ctx context.Context) interface{} {
return ctx.Value(contextKeyModelPtr)
}
// WithRequestData adds all request-scoped data to context at once
func WithRequestData(ctx context.Context, schema, entity, tableName string, model, modelPtr interface{}) context.Context {
ctx = WithSchema(ctx, schema)
ctx = WithEntity(ctx, entity)
ctx = WithTableName(ctx, tableName)
ctx = WithModel(ctx, model)
ctx = WithModelPtr(ctx, modelPtr)
return ctx
}

View File

@@ -1,250 +0,0 @@
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)
}

786
pkg/resolvespec/handler.go Normal file
View File

@@ -0,0 +1,786 @@
package resolvespec
import (
"context"
"encoding/json"
"fmt"
"net/http"
"reflect"
"runtime/debug"
"strings"
"github.com/Warky-Devs/ResolveSpec/pkg/common"
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
)
// Handler handles API requests using database and model abstractions
type Handler struct {
db common.Database
registry common.ModelRegistry
}
// NewHandler creates a new API handler with database and registry abstractions
func NewHandler(db common.Database, registry common.ModelRegistry) *Handler {
return &Handler{
db: db,
registry: registry,
}
}
// handlePanic is a helper function to handle panics with stack traces
func (h *Handler) handlePanic(w common.ResponseWriter, method string, err interface{}) {
stack := debug.Stack()
logger.Error("Panic in %s: %v\nStack trace:\n%s", method, err, string(stack))
h.sendError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("Internal server error in %s", method), fmt.Errorf("%v", err))
}
// Handle processes API requests through router-agnostic interface
func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[string]string) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "Handle", err)
}
}()
ctx := context.Background()
body, err := r.Body()
if err != nil {
logger.Error("Failed to read request body: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_request", "Failed to read request body", err)
return
}
var req common.RequestBody
if err := json.Unmarshal(body, &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)
// Get model and populate context with request-scoped data
model, err := h.registry.GetModelByEntity(schema, entity)
if err != nil {
logger.Error("Invalid entity: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_entity", "Invalid entity", err)
return
}
// Validate that the model is a struct type (not a slice or pointer to slice)
modelType := reflect.TypeOf(model)
originalType := modelType
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Error("Model for %s.%s must be a struct type, got %v. Please register models as struct types, not slices or pointers to slices.", schema, entity, originalType)
h.sendError(w, http.StatusInternalServerError, "invalid_model_type",
fmt.Sprintf("Model must be a struct type, got %v. Ensure you register the struct (e.g., ModelCoreAccount{}) not a slice (e.g., []*ModelCoreAccount)", originalType),
fmt.Errorf("invalid model type: %v", originalType))
return
}
// If the registered model was a pointer or slice, use the unwrapped struct type
if originalType != modelType {
model = reflect.New(modelType).Elem().Interface()
}
// Create a pointer to the model type for database operations
modelPtr := reflect.New(reflect.TypeOf(model)).Interface()
tableName := h.getTableName(schema, entity, model)
// Add request-scoped data to context
ctx = WithRequestData(ctx, schema, entity, tableName, model, modelPtr)
// Validate and filter columns in options (log warnings for invalid columns)
validator := common.NewColumnValidator(model)
req.Options = validator.FilterRequestOptions(req.Options)
switch req.Operation {
case "read":
h.handleRead(ctx, w, id, req.Options)
case "create":
h.handleCreate(ctx, w, req.Data, req.Options)
case "update":
h.handleUpdate(ctx, w, id, req.ID, req.Data, req.Options)
case "delete":
h.handleDelete(ctx, w, id)
default:
logger.Error("Invalid operation: %s", req.Operation)
h.sendError(w, http.StatusBadRequest, "invalid_operation", "Invalid operation", nil)
}
}
// HandleGet processes GET requests for metadata
func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params map[string]string) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "HandleGet", err)
}
}()
schema := params["schema"]
entity := params["entity"]
logger.Info("Getting metadata for %s.%s", schema, entity)
model, err := h.registry.GetModelByEntity(schema, entity)
if err != nil {
logger.Error("Failed to get model: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_entity", "Invalid entity", err)
return
}
metadata := h.generateMetadata(schema, entity, model)
h.sendResponse(w, metadata, nil)
}
func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id string, options common.RequestOptions) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleRead", err)
}
}()
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
model := GetModel(ctx)
// Validate and unwrap model type to get base struct
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Error("Model must be a struct type, got %v for %s.%s", modelType, schema, entity)
h.sendError(w, http.StatusInternalServerError, "invalid_model", "Model must be a struct type", fmt.Errorf("invalid model type: %v", modelType))
return
}
logger.Info("Reading records from %s.%s", schema, entity)
// Create the model pointer for Scan() operations
sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
modelPtr := reflect.New(sliceType).Interface()
// Start with Model() to avoid "Model(nil)" errors in Count()
query := h.db.NewSelect().Model(model)
// Only set Table() if the model doesn't provide a table name
if provider, ok := model.(common.TableNameProvider); !ok || provider.TableName() == "" {
query = query.Table(tableName)
}
// Apply column selection
if len(options.Columns) > 0 {
logger.Debug("Selecting columns: %v", options.Columns)
query = query.Column(options.Columns...)
}
// Apply preloading
if len(options.Preload) > 0 {
query = h.applyPreloads(model, query, options.Preload)
}
// 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
total, err := query.Count(ctx)
if 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)
// For single record, create a new pointer to the struct type
singleResult := reflect.New(modelType).Interface()
query = query.Where("id = ?", id)
if err := query.Scan(ctx, singleResult); err != nil {
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")
// Use the modelPtr already created and set on the query
if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error querying records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
return
}
result = reflect.ValueOf(modelPtr).Elem().Interface()
}
logger.Info("Successfully retrieved records")
limit := 0
if options.Limit != nil {
limit = *options.Limit
}
offset := 0
if options.Offset != nil {
offset = *options.Offset
}
h.sendResponse(w, result, &common.Metadata{
Total: int64(total),
Filtered: int64(total),
Limit: limit,
Offset: offset,
})
}
func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, data interface{}, options common.RequestOptions) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleCreate", err)
}
}()
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
logger.Info("Creating records for %s.%s", schema, entity)
query := h.db.NewInsert().Table(tableName)
switch v := data.(type) {
case map[string]interface{}:
for key, value := range v {
query = query.Value(key, value)
}
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
}
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
h.sendResponse(w, v, nil)
case []map[string]interface{}:
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
txQuery := tx.NewInsert().Table(tableName)
for key, value := range item {
txQuery = txQuery.Value(key, value)
}
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
}
return nil
})
if err != nil {
logger.Error("Error creating records: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records", err)
return
}
logger.Info("Successfully created %d records", len(v))
h.sendResponse(w, v, nil)
case []interface{}:
// Handle []interface{} type from JSON unmarshaling
list := make([]interface{}, 0)
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
txQuery := tx.NewInsert().Table(tableName)
for key, value := range itemMap {
txQuery = txQuery.Value(key, value)
}
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
list = append(list, item)
}
}
return nil
})
if err != nil {
logger.Error("Error creating records: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records", err)
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)
h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data type for create operation", nil)
}
}
func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, urlID string, reqID interface{}, data interface{}, options common.RequestOptions) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleUpdate", err)
}
}()
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
logger.Info("Updating records for %s.%s", schema, entity)
query := h.db.NewUpdate().Table(tableName)
switch updates := data.(type) {
case map[string]interface{}:
query = query.SetMap(updates)
default:
logger.Error("Invalid data type for update operation: %T", data)
h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data type for update operation", nil)
return
}
// Apply conditions
if urlID != "" {
logger.Debug("Updating by URL ID: %s", urlID)
query = query.Where("id = ?", urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
logger.Debug("Updating by request ID: %s", id)
query = query.Where("id = ?", id)
case []string:
logger.Debug("Updating by multiple IDs: %v", id)
query = query.Where("id IN (?)", id)
}
}
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Update error: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record(s)", err)
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 (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleDelete", err)
}
}()
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
logger.Info("Deleting records from %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
}
query := h.db.NewDelete().Table(tableName).Where("id = ?", id)
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error deleting record: %v", err)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting record", err)
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)
}
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
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
}
}
// parseTableName splits a table name that may contain schema into separate schema and table
func (h *Handler) parseTableName(fullTableName string) (schema, table string) {
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
return fullTableName[:idx], fullTableName[idx+1:]
}
return "", fullTableName
}
// getSchemaAndTable returns the schema and table name separately
// It checks SchemaProvider and TableNameProvider interfaces and handles cases where
// the table name may already include the schema (e.g., "public.users")
//
// Priority order:
// 1. If TableName() contains a schema (e.g., "myschema.mytable"), that schema takes precedence
// 2. If model implements SchemaProvider, use that schema
// 3. Otherwise, use the defaultSchema parameter
func (h *Handler) getSchemaAndTable(defaultSchema, entity string, model interface{}) (schema, table string) {
// First check if model provides a table name
// We check this FIRST because the table name might already contain the schema
if tableProvider, ok := model.(common.TableNameProvider); ok {
tableName := tableProvider.TableName()
// IMPORTANT: Check if the table name already contains a schema (e.g., "schema.table")
// This is common when models need to specify a different schema than the default
if tableSchema, tableOnly := h.parseTableName(tableName); tableSchema != "" {
// Table name includes schema - use it and ignore any other schema providers
logger.Debug("TableName() includes schema: %s.%s", tableSchema, tableOnly)
return tableSchema, tableOnly
}
// Table name is just the table name without schema
// Now determine which schema to use
if schemaProvider, ok := model.(common.SchemaProvider); ok {
schema = schemaProvider.SchemaName()
} else {
schema = defaultSchema
}
return schema, tableName
}
// No TableNameProvider, so check for schema and use entity as table name
if schemaProvider, ok := model.(common.SchemaProvider); ok {
schema = schemaProvider.SchemaName()
} else {
schema = defaultSchema
}
// Default to entity name as table
return schema, entity
}
// getTableName returns the full table name including schema (schema.table)
func (h *Handler) getTableName(schema, entity string, model interface{}) string {
schemaName, tableName := h.getSchemaAndTable(schema, entity, model)
if schemaName != "" {
return fmt.Sprintf("%s.%s", schemaName, tableName)
}
return tableName
}
func (h *Handler) generateMetadata(schema, entity string, model interface{}) *common.TableMetadata {
modelType := reflect.TypeOf(model)
// Unwrap pointers, slices, and arrays to get to the base struct type
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
// Validate that we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Error("Model type must be a struct, got %v for %s.%s", modelType, schema, entity)
return &common.TableMetadata{
Schema: schema,
Table: entity,
Columns: make([]common.Column, 0),
Relations: make([]string, 0),
}
}
metadata := &common.TableMetadata{
Schema: schema,
Table: entity,
Columns: make([]common.Column, 0),
Relations: make([]string, 0),
}
// Generate metadata using reflection (same logic as before)
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if !field.IsExported() {
continue
}
gormTag := field.Tag.Get("gorm")
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == "" {
jsonName = field.Name
}
if field.Type.Kind() == reflect.Slice ||
(field.Type.Kind() == reflect.Struct && field.Type.Name() != "Time") {
metadata.Relations = append(metadata.Relations, jsonName)
continue
}
column := common.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)
}
return metadata
}
func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata) {
w.SetHeader("Content-Type", "application/json")
w.WriteJSON(common.Response{
Success: true,
Data: data,
Metadata: metadata,
})
}
func (h *Handler) sendError(w common.ResponseWriter, status int, code, message string, details interface{}) {
w.SetHeader("Content-Type", "application/json")
w.WriteHeader(status)
w.WriteJSON(common.Response{
Success: false,
Error: &common.APIError{
Code: code,
Message: message,
Details: details,
Detail: fmt.Sprintf("%v", details),
},
})
}
// RegisterModel allows registering models at runtime
func (h *Handler) RegisterModel(schema, name string, model interface{}) error {
fullname := fmt.Sprintf("%s.%s", schema, name)
return h.registry.RegisterModel(fullname, model)
}
// Helper functions
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")
}
// Preload support functions
type relationshipInfo struct {
fieldName string
jsonName string
relationType string // "belongsTo", "hasMany", "hasOne", "many2many"
foreignKey string
references string
joinTable string
relatedModel interface{}
}
func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, preloads []common.PreloadOption) common.SelectQuery {
modelType := reflect.TypeOf(model)
// Unwrap pointers, slices, and arrays to get to the base struct type
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
// Validate that we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot apply preloads to non-struct type: %v", modelType)
return query
}
for _, preload := range preloads {
logger.Debug("Processing preload for relation: %s", preload.Relation)
relInfo := h.getRelationshipInfo(modelType, preload.Relation)
if relInfo == nil {
logger.Warn("Relation %s not found in model", preload.Relation)
continue
}
// Use the field name (capitalized) for ORM preloading
// ORMs like GORM and Bun expect the struct field name, not the JSON name
relationFieldName := relInfo.fieldName
// For now, we'll preload without conditions
// TODO: Implement column selection and filtering for preloads
// This requires a more sophisticated approach with callbacks or query builders
query = query.Preload(relationFieldName)
logger.Debug("Applied Preload for relation: %s (field: %s)", preload.Relation, relationFieldName)
}
return query
}
func (h *Handler) getRelationshipInfo(modelType reflect.Type, relationName string) *relationshipInfo {
// Ensure we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot get relationship info from non-struct type: %v", modelType)
return nil
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == relationName {
gormTag := field.Tag.Get("gorm")
info := &relationshipInfo{
fieldName: field.Name,
jsonName: jsonName,
}
// Parse GORM tag to determine relationship type and keys
if strings.Contains(gormTag, "foreignKey") {
info.foreignKey = h.extractTagValue(gormTag, "foreignKey")
info.references = h.extractTagValue(gormTag, "references")
// Determine if it's belongsTo or hasMany/hasOne
if field.Type.Kind() == reflect.Slice {
info.relationType = "hasMany"
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
info.relationType = "belongsTo"
}
} else if strings.Contains(gormTag, "many2many") {
info.relationType = "many2many"
info.joinTable = h.extractTagValue(gormTag, "many2many")
}
return info
}
}
return nil
}
func (h *Handler) extractTagValue(tag, key string) string {
parts := strings.Split(tag, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, key+":") {
return strings.TrimPrefix(part, key+":")
}
}
return ""
}

View File

@@ -1,5 +1,6 @@
package resolvespec
// Legacy interfaces for backward compatibility
type GormTableNameInterface interface {
TableName() string
}
@@ -19,3 +20,8 @@ func (r *GormTableCRUDRequest) SetRequest(request string) {
func (r GormTableCRUDRequest) GetRequest() string {
return *r.CRUDRequest
}
// New interfaces that replace the legacy ones above
// These are now defined in database.go:
// - TableNameProvider (replaces GormTableNameInterface)
// - SchemaProvider (replaces GormTableSchemaInterface)

View File

@@ -1,131 +0,0 @@
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")
}

View File

@@ -0,0 +1,181 @@
package resolvespec
import (
"net/http"
"github.com/Warky-Devs/ResolveSpec/pkg/common/adapters/database"
"github.com/Warky-Devs/ResolveSpec/pkg/common/adapters/router"
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
"github.com/gorilla/mux"
"github.com/uptrace/bun"
"github.com/uptrace/bunrouter"
"gorm.io/gorm"
)
// NewHandlerWithGORM creates a new Handler with GORM adapter
func NewHandlerWithGORM(db *gorm.DB) *Handler {
gormAdapter := database.NewGormAdapter(db)
registry := modelregistry.NewModelRegistry()
return NewHandler(gormAdapter, registry)
}
// NewHandlerWithBun creates a new Handler with Bun adapter
func NewHandlerWithBun(db *bun.DB) *Handler {
bunAdapter := database.NewBunAdapter(db)
registry := modelregistry.NewModelRegistry()
return NewHandler(bunAdapter, registry)
}
// NewStandardMuxRouter creates a router with standard Mux HTTP handlers
func NewStandardMuxRouter() *router.StandardMuxAdapter {
return router.NewStandardMuxAdapter()
}
// NewStandardBunRouter creates a router with standard BunRouter handlers
func NewStandardBunRouter() *router.StandardBunRouterAdapter {
return router.NewStandardBunRouterAdapter()
}
// SetupMuxRoutes sets up routes for the ResolveSpec API with Mux
func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler) {
muxRouter.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
reqAdapter := router.NewHTTPRequest(r)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, vars)
}).Methods("POST")
muxRouter.HandleFunc("/{schema}/{entity}/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
reqAdapter := router.NewHTTPRequest(r)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, vars)
}).Methods("POST")
muxRouter.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
reqAdapter := router.NewHTTPRequest(r)
respAdapter := router.NewHTTPResponseWriter(w)
handler.HandleGet(respAdapter, reqAdapter, vars)
}).Methods("GET")
}
// Example usage functions for documentation:
// ExampleWithGORM shows how to use ResolveSpec with GORM
func ExampleWithGORM(db *gorm.DB) {
// Create handler using GORM
handler := NewHandlerWithGORM(db)
// Setup router
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler)
// Register models
// handler.RegisterModel("public", "users", &User{})
}
// ExampleWithBun shows how to switch to Bun ORM
func ExampleWithBun(bunDB *bun.DB) {
// Create Bun adapter
dbAdapter := database.NewBunAdapter(bunDB)
// Create model registry
registry := modelregistry.NewModelRegistry()
// registry.RegisterModel("public.users", &User{})
// Create handler
handler := NewHandler(dbAdapter, registry)
// Setup routes
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler)
}
// SetupBunRouterRoutes sets up bunrouter routes for the ResolveSpec API
func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) {
r := bunRouter.GetBunRouter()
r.Handle("POST", "/:schema/:entity", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
}
reqAdapter := router.NewHTTPRequest(req.Request)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("POST", "/:schema/:entity/:id", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
"id": req.Param("id"),
}
reqAdapter := router.NewHTTPRequest(req.Request)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("GET", "/:schema/:entity", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
}
reqAdapter := router.NewHTTPRequest(req.Request)
respAdapter := router.NewHTTPResponseWriter(w)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
r.Handle("GET", "/:schema/:entity/:id", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
"id": req.Param("id"),
}
reqAdapter := router.NewHTTPRequest(req.Request)
respAdapter := router.NewHTTPResponseWriter(w)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
}
// ExampleWithBunRouter shows how to use bunrouter from uptrace
func ExampleWithBunRouter(bunDB *bun.DB) {
// Create handler with Bun adapter
handler := NewHandlerWithBun(bunDB)
// Create bunrouter
bunRouter := router.NewStandardBunRouterAdapter()
// Setup ResolveSpec routes with bunrouter
SetupBunRouterRoutes(bunRouter, handler)
// Start server
// http.ListenAndServe(":8080", bunRouter.GetBunRouter())
}
// ExampleBunRouterWithBunDB shows the full uptrace stack (bunrouter + Bun ORM)
func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
// Create Bun database adapter
dbAdapter := database.NewBunAdapter(bunDB)
// Create model registry
registry := modelregistry.NewModelRegistry()
// registry.RegisterModel("public.users", &User{})
// Create handler with Bun
handler := NewHandler(dbAdapter, registry)
// Create bunrouter
bunRouter := router.NewStandardBunRouterAdapter()
// Setup ResolveSpec routes
SetupBunRouterRoutes(bunRouter, handler)
// This gives you the full uptrace stack: bunrouter + Bun ORM
// http.ListenAndServe(":8080", bunRouter.GetBunRouter())
}

View File

@@ -1,78 +0,0 @@
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
}
func (h *APIHandler) RegisterModel(schema, name string, model interface{}) error {
fullname := fmt.Sprintf("%s.%s", schema, name)
oldModel, err := models.GetModelByName(fullname)
if oldModel != nil && err != nil {
return fmt.Errorf("model %s already exists", fullname)
}
err = models.RegisterModel(model, fullname)
return err
}

614
pkg/restheadspec/HEADERS.md Normal file
View File

@@ -0,0 +1,614 @@
# RestHeadSpec Headers Documentation
RestHeadSpec provides a comprehensive header-based REST API where all query options are passed via HTTP headers instead of request body. This document describes all supported headers and their usage.
## Overview
RestHeadSpec uses HTTP headers for:
- Field selection
- Filtering and searching
- Joins and relationship loading
- Sorting and pagination
- Advanced query features
- Response formatting
- Transaction control
### Header Naming Convention
All headers support **optional identifiers** at the end to allow multiple instances of the same header type. This is useful when you need to specify multiple related filters or options.
**Examples:**
```
# Standard header
x-preload: employees
# Headers with identifiers (both work the same)
x-preload-main: employees
x-preload-secondary: department
x-preload-1: projects
```
The system uses `strings.HasPrefix()` to match headers, so any suffix after the header name is ignored for matching purposes. This allows you to:
- Add descriptive identifiers: `x-sort-primary`, `x-sort-fallback`
- Add numeric identifiers: `x-fieldfilter-status-1`, `x-fieldfilter-status-2`
- Organize related headers: `x-preload-employee-data`, `x-preload-department-info`
## Header Categories
### 1. Field Selection
#### `x-select-fields`
Specify which columns to include in the response.
**Format:** Comma-separated list of column names
```
x-select-fields: id,name,email,created_at
```
#### `x-not-select-fields`
Specify which columns to exclude from the response.
**Format:** Comma-separated list of column names
```
x-not-select-fields: password,internal_notes
```
#### `x-clean-json`
Remove null and empty fields from the response.
**Format:** Boolean (true/false)
```
x-clean-json: true
```
---
### 2. Filtering & Search
#### `x-fieldfilter-{colname}`
Exact match filter on a specific column.
**Format:** `x-fieldfilter-{columnName}: {value}`
```
x-fieldfilter-status: active
x-fieldfilter-department_id: dept123
```
#### `x-searchfilter-{colname}`
Fuzzy search (ILIKE) on a specific column.
**Format:** `x-searchfilter-{columnName}: {searchTerm}`
```
x-searchfilter-name: john
x-searchfilter-description: website
```
This will match any records where the column contains the search term (case-insensitive).
#### `x-searchop-{operator}-{colname}`
Search with specific operators (AND logic).
**Supported Operators:**
- `contains` - Contains substring (case-insensitive)
- `beginswith` / `startswith` - Starts with (case-insensitive)
- `endswith` - Ends with (case-insensitive)
- `equals` / `eq` - Exact match
- `notequals` / `neq` / `ne` - Not equal
- `greaterthan` / `gt` - Greater than
- `lessthan` / `lt` - Less than
- `greaterthanorequal` / `gte` / `ge` - Greater than or equal
- `lessthanorequal` / `lte` / `le` - Less than or equal
- `between` - Between two values, **exclusive** (> val1 AND < val2) - format: `value1,value2`
- `betweeninclusive` - Between two values, **inclusive** (>= val1 AND <= val2) - format: `value1,value2`
- `in` - In a list of values - format: `value1,value2,value3`
- `empty` / `isnull` / `null` - Is NULL or empty string
- `notempty` / `isnotnull` / `notnull` - Is NOT NULL and not empty string
**Type-Aware Features:**
- Text searches use case-insensitive matching (ILIKE with citext cast)
- Numeric comparisons work with integers, floats, and decimals
- Date/time comparisons handle timestamps correctly
- JSON field support for structured data
**Examples:**
```
# Text search (case-insensitive)
x-searchop-contains-name: smith
# Numeric comparison
x-searchop-gt-age: 25
x-searchop-gte-salary: 50000
# Date range (exclusive)
x-searchop-between-created_at: 2024-01-01,2024-12-31
# Date range (inclusive)
x-searchop-betweeninclusive-birth_date: 1990-01-01,2000-12-31
# List matching
x-searchop-in-status: active,pending,review
# NULL checks
x-searchop-empty-deleted_at: true
x-searchop-notempty-email: true
```
#### `x-searchor-{operator}-{colname}`
Same as `x-searchop` but with OR logic instead of AND.
```
x-searchor-eq-status: active
x-searchor-eq-status: pending
```
#### `x-searchand-{operator}-{colname}`
Explicit AND logic (same as `x-searchop`).
```
x-searchand-gte-age: 18
x-searchand-lte-age: 65
```
#### `x-searchcols`
Specify columns for "all" search operations.
**Format:** Comma-separated list
```
x-searchcols: name,email,description
```
#### `x-custom-sql-w`
Raw SQL WHERE clause with AND condition.
**Format:** SQL WHERE clause (without the WHERE keyword)
```
x-custom-sql-w: status = 'active' AND created_at > '2024-01-01'
```
⚠️ **Warning:** Use with caution - ensure proper SQL injection prevention.
#### `x-custom-sql-or`
Raw SQL WHERE clause with OR condition.
**Format:** SQL WHERE clause
```
x-custom-sql-or: status = 'archived' OR is_deleted = true
```
---
### 3. Joins & Relations
#### `x-preload`
Preload related tables using the ORM's preload functionality.
**Format:** `RelationName:field1,field2` or `RelationName`
Multiple relations can be specified using multiple headers or by separating with `|`
**Examples:**
```
# Preload all fields from employees relation
x-preload: employees
# Preload specific fields from employees
x-preload: employees:id,first_name,last_name,email
# Multiple preloads using pipe separator
x-preload: employees:id,name|department:id,name
# Multiple preloads using separate headers with identifiers
x-preload-1: employees:id,first_name,last_name
x-preload-2: department:id,name
x-preload-related: projects:id,name,status
```
#### `x-expand`
LEFT JOIN related tables and expand results inline.
**Format:** Same as `x-preload`
```
x-expand: department:id,name,code
```
**Note:** Currently, expand falls back to preload behavior. Full JOIN expansion is planned for future implementation.
#### `x-custom-sql-join`
Raw SQL JOIN statement.
**Format:** SQL JOIN clause
```
x-custom-sql-join: LEFT JOIN departments d ON d.id = employees.department_id
```
⚠️ **Note:** Not yet fully implemented.
---
### 4. Sorting & Pagination
#### `x-sort`
Sort results by one or more columns.
**Format:** Comma-separated list with optional `+` (ASC) or `-` (DESC) prefix
```
# Single column ascending (default)
x-sort: name
# Single column descending
x-sort: -created_at
# Multiple columns
x-sort: +department,- created_at,name
# Equivalent to: ORDER BY department ASC, created_at DESC, name ASC
```
#### `x-limit`
Limit the number of records returned.
**Format:** Integer
```
x-limit: 50
```
#### `x-offset`
Skip a number of records (offset-based pagination).
**Format:** Integer
```
x-offset: 100
```
#### `x-cursor-forward`
Cursor-based pagination (forward).
**Format:** Cursor string
```
x-cursor-forward: eyJpZCI6MTIzfQ==
```
⚠️ **Note:** Not yet fully implemented.
#### `x-cursor-backward`
Cursor-based pagination (backward).
**Format:** Cursor string
```
x-cursor-backward: eyJpZCI6MTIzfQ==
```
⚠️ **Note:** Not yet fully implemented.
---
### 5. Advanced Features
#### `x-advsql-{colname}`
Advanced SQL expression for a specific column.
**Format:** `x-advsql-{columnName}: {SQLExpression}`
```
x-advsql-full_name: CONCAT(first_name, ' ', last_name)
x-advsql-age_years: EXTRACT(YEAR FROM AGE(birth_date))
```
⚠️ **Note:** Not yet fully implemented in query execution.
#### `x-cql-sel-{colname}`
Computed Query Language - custom SQL expressions aliased as columns.
**Format:** `x-cql-sel-{aliasName}: {SQLExpression}`
```
x-cql-sel-employee_count: COUNT(employees.id)
x-cql-sel-total_revenue: SUM(orders.amount)
```
⚠️ **Note:** Not yet fully implemented in query execution.
#### `x-distinct`
Apply DISTINCT to the query.
**Format:** Boolean (true/false)
```
x-distinct: true
```
⚠️ **Note:** Implementation depends on ORM adapter support.
#### `x-skipcount`
Skip counting total records (performance optimization).
**Format:** Boolean (true/false)
```
x-skipcount: true
```
When enabled, the total count will be -1 in the response metadata.
#### `x-skipcache`
Bypass query cache (if caching is implemented).
**Format:** Boolean (true/false)
```
x-skipcache: true
```
#### `x-fetch-rownumber`
Get the row number of a specific record in the result set.
**Format:** Record identifier
```
x-fetch-rownumber: record123
```
⚠️ **Note:** Not yet implemented.
#### `x-pkrow`
Similar to `x-fetch-rownumber` - get row number by primary key.
**Format:** Primary key value
```
x-pkrow: 123
```
⚠️ **Note:** Not yet implemented.
---
### 6. Response Format
#### `x-simpleapi`
Return simple format (just the data array).
**Format:** Presence of header activates it
```
x-simpleapi: true
```
**Response Format:**
```json
[
{ "id": 1, "name": "John" },
{ "id": 2, "name": "Jane" }
]
```
#### `x-detailapi`
Return detailed format with metadata (default).
**Format:** Presence of header activates it
```
x-detailapi: true
```
**Response Format:**
```json
{
"success": true,
"data": [...],
"metadata": {
"total": 100,
"filtered": 100,
"limit": 50,
"offset": 0
}
}
```
#### `x-syncfusion`
Format response for Syncfusion UI components.
**Format:** Presence of header activates it
```
x-syncfusion: true
```
**Response Format:**
```json
{
"result": [...],
"count": 100
}
```
---
### 7. Transaction Control
#### `x-transaction-atomic`
Use atomic transactions for write operations.
**Format:** Boolean (true/false)
```
x-transaction-atomic: true
```
Ensures that all write operations in the request succeed or fail together.
---
## Base64 Encoding
Headers support base64 encoding for complex values. Use one of these prefixes:
- `ZIP_` - Base64 encoded value
- `__` - Base64 encoded value (double underscore)
**Example:**
```
# Plain value
x-custom-sql-w: status = 'active'
# Base64 encoded (same value)
x-custom-sql-w: ZIP_c3RhdHVzID0gJ2FjdGl2ZSc=
```
---
## Complete Examples
### Example 1: Basic Query
```http
GET /api/employees HTTP/1.1
Host: example.com
x-select-fields: id,first_name,last_name,email,department_id
x-preload: department:id,name
x-searchfilter-name: john
x-searchop-gte-created_at: 2024-01-01
x-sort: -created_at,+last_name
x-limit: 50
x-offset: 0
x-skipcount: false
x-detailapi: true
```
### Example 2: Complex Query with Multiple Filters and Preloads
```http
GET /api/employees HTTP/1.1
Host: example.com
x-select-fields-main: id,first_name,last_name,email,department_id,manager_id
x-preload-1: department:id,name,code
x-preload-2: manager:id,first_name,last_name
x-preload-3: projects:id,name,status
x-fieldfilter-status-1: active
x-searchop-gte-created_at-filter1: 2024-01-01
x-searchop-lt-created_at-filter2: 2024-12-31
x-searchfilter-name-query: smith
x-sort-primary: -created_at
x-sort-secondary: +last_name
x-limit-page: 100
x-offset-page: 0
x-detailapi: true
```
**Note:** The identifiers after the header names (like `-main`, `-1`, `-filter1`, etc.) are optional and help organize multiple headers of the same type. Both approaches work:
```http
# Without identifiers
x-preload: employees
x-preload: department
# With identifiers (more organized)
x-preload-1: employees
x-preload-2: department
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": "emp1",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"department_id": "dept1",
"department": {
"id": "dept1",
"name": "Engineering"
}
}
],
"metadata": {
"total": 1,
"filtered": 1,
"limit": 50,
"offset": 0
}
}
```
---
## HTTP Method Mapping
- `GET /{schema}/{entity}` - List all records
- `GET /{schema}/{entity}/{id}` - Get single record
- `POST /{schema}/{entity}` - Create record(s)
- `PUT /{schema}/{entity}/{id}` - Update record
- `PATCH /{schema}/{entity}/{id}` - Partial update
- `DELETE /{schema}/{entity}/{id}` - Delete record
- `GET /{schema}/{entity}/metadata` - Get table metadata
---
## Implementation Status
**Implemented:**
- Field selection (select/omit columns)
- Filtering (field filters, search filters, operators)
- Preloading relations
- Sorting and pagination
- Skip count optimization
- Response format options
- Base64 decoding
⚠️ **Partially Implemented:**
- Expand (currently falls back to preload)
- DISTINCT (depends on ORM adapter)
🚧 **Planned:**
- Advanced SQL expressions (advsql, cql-sel)
- Custom SQL joins
- Cursor pagination
- Row number fetching
- Full expand with JOIN
- Query caching control
---
## Security Considerations
1. **SQL Injection**: Custom SQL headers (`x-custom-sql-*`) should be properly sanitized or restricted to trusted users only.
2. **Query Complexity**: Consider implementing query complexity limits to prevent resource exhaustion.
3. **Authentication**: Implement proper authentication and authorization checks before processing requests.
4. **Rate Limiting**: Apply rate limiting to prevent abuse.
5. **Field Restrictions**: Consider implementing field-level permissions to restrict access to sensitive columns.
---
## Performance Tips
1. Use `x-skipcount: true` for large datasets when you don't need the total count
2. Select only needed columns with `x-select-fields`
3. Use preload wisely - only load relations you need
4. Implement proper database indexes for filtered and sorted columns
5. Consider pagination for large result sets
---
## Migration from ResolveSpec
RestHeadSpec is an alternative to ResolveSpec that uses headers instead of request body for options:
**ResolveSpec (body-based):**
```json
POST /api/departments
{
"operation": "read",
"options": {
"preload": [{"relation": "employees"}],
"filters": [{"column": "status", "operator": "eq", "value": "active"}],
"limit": 50
}
}
```
**RestHeadSpec (header-based):**
```http
GET /api/departments
x-preload: employees
x-fieldfilter-status: active
x-limit: 50
```
Both implementations share the same core handler logic and database adapters.

View File

@@ -0,0 +1,85 @@
package restheadspec
import (
"context"
)
// Context keys for request-scoped data
type contextKey string
const (
contextKeySchema contextKey = "schema"
contextKeyEntity contextKey = "entity"
contextKeyTableName contextKey = "tableName"
contextKeyModel contextKey = "model"
contextKeyModelPtr contextKey = "modelPtr"
)
// WithSchema adds schema to context
func WithSchema(ctx context.Context, schema string) context.Context {
return context.WithValue(ctx, contextKeySchema, schema)
}
// GetSchema retrieves schema from context
func GetSchema(ctx context.Context) string {
if v := ctx.Value(contextKeySchema); v != nil {
return v.(string)
}
return ""
}
// WithEntity adds entity to context
func WithEntity(ctx context.Context, entity string) context.Context {
return context.WithValue(ctx, contextKeyEntity, entity)
}
// GetEntity retrieves entity from context
func GetEntity(ctx context.Context) string {
if v := ctx.Value(contextKeyEntity); v != nil {
return v.(string)
}
return ""
}
// WithTableName adds table name to context
func WithTableName(ctx context.Context, tableName string) context.Context {
return context.WithValue(ctx, contextKeyTableName, tableName)
}
// GetTableName retrieves table name from context
func GetTableName(ctx context.Context) string {
if v := ctx.Value(contextKeyTableName); v != nil {
return v.(string)
}
return ""
}
// WithModel adds model to context
func WithModel(ctx context.Context, model interface{}) context.Context {
return context.WithValue(ctx, contextKeyModel, model)
}
// GetModel retrieves model from context
func GetModel(ctx context.Context) interface{} {
return ctx.Value(contextKeyModel)
}
// WithModelPtr adds model pointer to context
func WithModelPtr(ctx context.Context, modelPtr interface{}) context.Context {
return context.WithValue(ctx, contextKeyModelPtr, modelPtr)
}
// GetModelPtr retrieves model pointer from context
func GetModelPtr(ctx context.Context) interface{} {
return ctx.Value(contextKeyModelPtr)
}
// WithRequestData adds all request-scoped data to context at once
func WithRequestData(ctx context.Context, schema, entity, tableName string, model, modelPtr interface{}) context.Context {
ctx = WithSchema(ctx, schema)
ctx = WithEntity(ctx, entity)
ctx = WithTableName(ctx, tableName)
ctx = WithModel(ctx, model)
ctx = WithModelPtr(ctx, modelPtr)
return ctx
}

799
pkg/restheadspec/handler.go Normal file
View File

@@ -0,0 +1,799 @@
package restheadspec
import (
"context"
"encoding/json"
"fmt"
"net/http"
"reflect"
"runtime/debug"
"strings"
"github.com/Warky-Devs/ResolveSpec/pkg/common"
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
)
// Handler handles API requests using database and model abstractions
// This handler reads filters, columns, and options from HTTP headers
type Handler struct {
db common.Database
registry common.ModelRegistry
}
// NewHandler creates a new API handler with database and registry abstractions
func NewHandler(db common.Database, registry common.ModelRegistry) *Handler {
return &Handler{
db: db,
registry: registry,
}
}
// handlePanic is a helper function to handle panics with stack traces
func (h *Handler) handlePanic(w common.ResponseWriter, method string, err interface{}) {
stack := debug.Stack()
logger.Error("Panic in %s: %v\nStack trace:\n%s", method, err, string(stack))
h.sendError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("Internal server error in %s", method), fmt.Errorf("%v", err))
}
// Handle processes API requests through router-agnostic interface
// Options are read from HTTP headers instead of request body
func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[string]string) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "Handle", err)
}
}()
ctx := context.Background()
schema := params["schema"]
entity := params["entity"]
id := params["id"]
// Parse options from headers (now returns ExtendedRequestOptions)
options := h.parseOptionsFromHeaders(r)
// Determine operation based on HTTP method
method := r.Method()
logger.Info("Handling %s request for %s.%s", method, schema, entity)
// Get model and populate context with request-scoped data
model, err := h.registry.GetModelByEntity(schema, entity)
if err != nil {
logger.Error("Invalid entity: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_entity", "Invalid entity", err)
return
}
// Validate that the model is a struct type (not a slice or pointer to slice)
modelType := reflect.TypeOf(model)
originalType := modelType
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Error("Model for %s.%s must be a struct type, got %v. Please register models as struct types, not slices or pointers to slices.", schema, entity, originalType)
h.sendError(w, http.StatusInternalServerError, "invalid_model_type",
fmt.Sprintf("Model must be a struct type, got %v. Ensure you register the struct (e.g., ModelCoreAccount{}) not a slice (e.g., []*ModelCoreAccount)", originalType),
fmt.Errorf("invalid model type: %v", originalType))
return
}
// If the registered model was a pointer or slice, use the unwrapped struct type
if originalType != modelType {
model = reflect.New(modelType).Elem().Interface()
}
modelPtr := reflect.New(reflect.TypeOf(model)).Interface()
tableName := h.getTableName(schema, entity, model)
// Add request-scoped data to context
ctx = WithRequestData(ctx, schema, entity, tableName, model, modelPtr)
// Validate and filter columns in options (log warnings for invalid columns)
validator := common.NewColumnValidator(model)
options = filterExtendedOptions(validator, options)
switch method {
case "GET":
if id != "" {
// GET with ID - read single record
h.handleRead(ctx, w, id, options)
} else {
// GET without ID - read multiple records
h.handleRead(ctx, w, "", options)
}
case "POST":
// Create operation
body, err := r.Body()
if err != nil {
logger.Error("Failed to read request body: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_request", "Failed to read request body", err)
return
}
var data interface{}
if err := json.Unmarshal(body, &data); err != nil {
logger.Error("Failed to decode request body: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_request", "Invalid request body", err)
return
}
h.handleCreate(ctx, w, data, options)
case "PUT", "PATCH":
// Update operation
body, err := r.Body()
if err != nil {
logger.Error("Failed to read request body: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_request", "Failed to read request body", err)
return
}
var data interface{}
if err := json.Unmarshal(body, &data); err != nil {
logger.Error("Failed to decode request body: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_request", "Invalid request body", err)
return
}
h.handleUpdate(ctx, w, id, nil, data, options)
case "DELETE":
h.handleDelete(ctx, w, id)
default:
logger.Error("Invalid HTTP method: %s", method)
h.sendError(w, http.StatusMethodNotAllowed, "invalid_method", "Invalid HTTP method", nil)
}
}
// HandleGet processes GET requests for metadata
func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params map[string]string) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "HandleGet", err)
}
}()
schema := params["schema"]
entity := params["entity"]
logger.Info("Getting metadata for %s.%s", schema, entity)
model, err := h.registry.GetModelByEntity(schema, entity)
if err != nil {
logger.Error("Failed to get model: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_entity", "Invalid entity", err)
return
}
metadata := h.generateMetadata(schema, entity, model)
h.sendResponse(w, metadata, nil)
}
// parseOptionsFromHeaders is now implemented in headers.go
func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id string, options ExtendedRequestOptions) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleRead", err)
}
}()
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
model := GetModel(ctx)
// Validate and unwrap model type to get base struct
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Error("Model must be a struct type, got %v for %s.%s", modelType, schema, entity)
h.sendError(w, http.StatusInternalServerError, "invalid_model", "Model must be a struct type", fmt.Errorf("invalid model type: %v", modelType))
return
}
// Create a pointer to a slice of pointers to the model type for query results
modelPtr := reflect.New(reflect.SliceOf(reflect.PointerTo(modelType))).Interface()
logger.Info("Reading records from %s.%s", schema, entity)
// Start with Model() to avoid "Model(nil)" errors in Count()
query := h.db.NewSelect().Model(model)
// Only set Table() if the model doesn't provide a table name
if provider, ok := model.(common.TableNameProvider); !ok || provider.TableName() == "" {
query = query.Table(tableName)
}
// Apply column selection
if len(options.Columns) > 0 {
logger.Debug("Selecting columns: %v", options.Columns)
query = query.Column(options.Columns...)
}
// Apply preloading
for _, preload := range options.Preload {
logger.Debug("Applying preload: %s", preload.Relation)
query = query.Preload(preload.Relation)
}
// Apply expand (LEFT JOIN)
for _, expand := range options.Expand {
logger.Debug("Applying expand: %s", expand.Relation)
// Note: Expand would require JOIN implementation
// For now, we'll use Preload as a fallback
query = query.Preload(expand.Relation)
}
// Apply DISTINCT if requested
if options.Distinct {
logger.Debug("Applying DISTINCT")
// Note: DISTINCT implementation depends on ORM support
// This may need to be handled differently per database adapter
}
// 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 custom SQL WHERE clause (AND condition)
if options.CustomSQLWhere != "" {
logger.Debug("Applying custom SQL WHERE: %s", options.CustomSQLWhere)
query = query.Where(options.CustomSQLWhere)
}
// Apply custom SQL WHERE clause (OR condition)
if options.CustomSQLOr != "" {
logger.Debug("Applying custom SQL OR: %s", options.CustomSQLOr)
query = query.WhereOr(options.CustomSQLOr)
}
// If ID is provided, filter by ID
if id != "" {
logger.Debug("Filtering by ID: %s", id)
query = query.Where("id = ?", id)
}
// 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 (unless skip count is requested)
var total int
if !options.SkipCount {
count, err := query.Count(ctx)
if err != nil {
logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err)
return
}
total = count
logger.Debug("Total records: %d", total)
} else {
logger.Debug("Skipping count as requested")
total = -1 // Indicate count was skipped
}
// 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 - modelPtr was already created earlier
if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error executing query: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
return
}
limit := 0
if options.Limit != nil {
limit = *options.Limit
}
offset := 0
if options.Offset != nil {
offset = *options.Offset
}
metadata := &common.Metadata{
Total: int64(total),
Filtered: int64(total),
Limit: limit,
Offset: offset,
}
h.sendFormattedResponse(w, modelPtr, metadata, options)
}
func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, data interface{}, options ExtendedRequestOptions) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleCreate", err)
}
}()
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
model := GetModel(ctx)
logger.Info("Creating record in %s.%s", schema, entity)
// Handle batch creation
dataValue := reflect.ValueOf(data)
if dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array {
logger.Debug("Batch creation detected, count: %d", dataValue.Len())
// Use transaction for batch insert
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for i := 0; i < dataValue.Len(); i++ {
item := dataValue.Index(i).Interface()
// Convert item to model type - create a pointer to the model
modelValue := reflect.New(reflect.TypeOf(model)).Interface()
jsonData, err := json.Marshal(item)
if err != nil {
return fmt.Errorf("failed to marshal item: %w", err)
}
if err := json.Unmarshal(jsonData, modelValue); err != nil {
return fmt.Errorf("failed to unmarshal item: %w", err)
}
query := tx.NewInsert().Model(modelValue).Table(tableName)
if _, err := query.Exec(ctx); err != nil {
return fmt.Errorf("failed to insert record: %w", err)
}
}
return nil
})
if err != nil {
logger.Error("Error creating records: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records", err)
return
}
h.sendResponse(w, map[string]interface{}{"created": dataValue.Len()}, nil)
return
}
// Single record creation - create a pointer to the model
modelValue := reflect.New(reflect.TypeOf(model)).Interface()
jsonData, err := json.Marshal(data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data format", err)
return
}
if err := json.Unmarshal(jsonData, modelValue); err != nil {
logger.Error("Error unmarshaling data: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data format", err)
return
}
query := h.db.NewInsert().Model(modelValue).Table(tableName)
if _, err := query.Exec(ctx); err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
}
h.sendResponse(w, modelValue, nil)
}
func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id string, idPtr *int64, data interface{}, options ExtendedRequestOptions) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleUpdate", err)
}
}()
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
logger.Info("Updating record in %s.%s", schema, entity)
// Convert data to map
dataMap, ok := data.(map[string]interface{})
if !ok {
jsonData, err := json.Marshal(data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data format", err)
return
}
if err := json.Unmarshal(jsonData, &dataMap); err != nil {
logger.Error("Error unmarshaling data: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data format", err)
return
}
}
query := h.db.NewUpdate().Table(tableName).SetMap(dataMap)
// Apply ID filter
if id != "" {
query = query.Where("id = ?", id)
} else if idPtr != nil {
query = query.Where("id = ?", *idPtr)
} else {
h.sendError(w, http.StatusBadRequest, "missing_id", "ID is required for update", nil)
return
}
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error updating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record", err)
return
}
h.sendResponse(w, map[string]interface{}{
"updated": result.RowsAffected(),
}, nil)
}
func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleDelete", err)
}
}()
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
logger.Info("Deleting record from %s.%s", schema, entity)
query := h.db.NewDelete().Table(tableName)
if id == "" {
h.sendError(w, http.StatusBadRequest, "missing_id", "ID is required for delete", nil)
return
}
query = query.Where("id = ?", id)
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error deleting record: %v", err)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting record", err)
return
}
h.sendResponse(w, map[string]interface{}{
"deleted": result.RowsAffected(),
}, nil)
}
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
switch strings.ToLower(filter.Operator) {
case "eq", "equals":
return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value)
case "neq", "not_equals", "ne":
return query.Where(fmt.Sprintf("%s != ?", filter.Column), filter.Value)
case "gt", "greater_than":
return query.Where(fmt.Sprintf("%s > ?", filter.Column), filter.Value)
case "gte", "greater_than_equals", "ge":
return query.Where(fmt.Sprintf("%s >= ?", filter.Column), filter.Value)
case "lt", "less_than":
return query.Where(fmt.Sprintf("%s < ?", filter.Column), filter.Value)
case "lte", "less_than_equals", "le":
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":
// Use ILIKE for case-insensitive search (PostgreSQL)
// For other databases, cast to citext or use LOWER()
return query.Where(fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column), filter.Value)
case "in":
return query.Where(fmt.Sprintf("%s IN (?)", filter.Column), filter.Value)
case "between":
// Handle between operator - exclusive (> val1 AND < val2)
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
return query.Where(fmt.Sprintf("%s > ? AND %s < ?", filter.Column, filter.Column), values[0], values[1])
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
return query.Where(fmt.Sprintf("%s > ? AND %s < ?", filter.Column, filter.Column), values[0], values[1])
}
logger.Warn("Invalid BETWEEN filter value format")
return query
case "between_inclusive":
// Handle between inclusive operator - inclusive (>= val1 AND <= val2)
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
return query.Where(fmt.Sprintf("%s >= ? AND %s <= ?", filter.Column, filter.Column), values[0], values[1])
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
return query.Where(fmt.Sprintf("%s >= ? AND %s <= ?", filter.Column, filter.Column), values[0], values[1])
}
logger.Warn("Invalid BETWEEN INCLUSIVE filter value format")
return query
case "is_null", "isnull":
// Check for NULL values
return query.Where(fmt.Sprintf("(%s IS NULL OR %s = '')", filter.Column, filter.Column))
case "is_not_null", "isnotnull":
// Check for NOT NULL values
return query.Where(fmt.Sprintf("(%s IS NOT NULL AND %s != '')", filter.Column, filter.Column))
default:
logger.Warn("Unknown filter operator: %s, defaulting to equals", filter.Operator)
return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value)
}
}
// parseTableName splits a table name that may contain schema into separate schema and table
func (h *Handler) parseTableName(fullTableName string) (schema, table string) {
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
return fullTableName[:idx], fullTableName[idx+1:]
}
return "", fullTableName
}
// getSchemaAndTable returns the schema and table name separately
// It checks SchemaProvider and TableNameProvider interfaces and handles cases where
// the table name may already include the schema (e.g., "public.users")
//
// Priority order:
// 1. If TableName() contains a schema (e.g., "myschema.mytable"), that schema takes precedence
// 2. If model implements SchemaProvider, use that schema
// 3. Otherwise, use the defaultSchema parameter
func (h *Handler) getSchemaAndTable(defaultSchema, entity string, model interface{}) (schema, table string) {
// First check if model provides a table name
// We check this FIRST because the table name might already contain the schema
if tableProvider, ok := model.(common.TableNameProvider); ok {
tableName := tableProvider.TableName()
// IMPORTANT: Check if the table name already contains a schema (e.g., "schema.table")
// This is common when models need to specify a different schema than the default
if tableSchema, tableOnly := h.parseTableName(tableName); tableSchema != "" {
// Table name includes schema - use it and ignore any other schema providers
logger.Debug("TableName() includes schema: %s.%s", tableSchema, tableOnly)
return tableSchema, tableOnly
}
// Table name is just the table name without schema
// Now determine which schema to use
if schemaProvider, ok := model.(common.SchemaProvider); ok {
schema = schemaProvider.SchemaName()
} else {
schema = defaultSchema
}
return schema, tableName
}
// No TableNameProvider, so check for schema and use entity as table name
if schemaProvider, ok := model.(common.SchemaProvider); ok {
schema = schemaProvider.SchemaName()
} else {
schema = defaultSchema
}
// Default to entity name as table
return schema, entity
}
// getTableName returns the full table name including schema (schema.table)
func (h *Handler) getTableName(schema, entity string, model interface{}) string {
schemaName, tableName := h.getSchemaAndTable(schema, entity, model)
if schemaName != "" {
return fmt.Sprintf("%s.%s", schemaName, tableName)
}
return tableName
}
func (h *Handler) generateMetadata(schema, entity string, model interface{}) *common.TableMetadata {
modelType := reflect.TypeOf(model)
// Unwrap pointers, slices, and arrays to get to the base struct type
for modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array {
modelType = modelType.Elem()
}
// Validate that we have a struct type
if modelType.Kind() != reflect.Struct {
logger.Error("Model type must be a struct, got %s for %s.%s", modelType.Kind(), schema, entity)
return &common.TableMetadata{
Schema: schema,
Table: h.getTableName(schema, entity, model),
Columns: []common.Column{},
}
}
tableName := h.getTableName(schema, entity, model)
metadata := &common.TableMetadata{
Schema: schema,
Table: tableName,
Columns: []common.Column{},
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
// Get column name from gorm tag or json tag
columnName := field.Tag.Get("gorm")
if strings.Contains(columnName, "column:") {
parts := strings.Split(columnName, ";")
for _, part := range parts {
if strings.HasPrefix(part, "column:") {
columnName = strings.TrimPrefix(part, "column:")
break
}
}
} else {
columnName = field.Tag.Get("json")
if columnName == "" || columnName == "-" {
columnName = strings.ToLower(field.Name)
}
}
// Check for primary key and unique constraint
gormTag := field.Tag.Get("gorm")
column := common.Column{
Name: columnName,
Type: h.getColumnType(field.Type),
IsNullable: h.isNullable(field),
IsPrimary: strings.Contains(gormTag, "primaryKey") || strings.Contains(gormTag, "primary_key"),
IsUnique: strings.Contains(gormTag, "unique"),
HasIndex: strings.Contains(gormTag, "index"),
}
metadata.Columns = append(metadata.Columns, column)
}
return metadata
}
func (h *Handler) getColumnType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "integer"
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "float"
case reflect.Bool:
return "boolean"
case reflect.Ptr:
return h.getColumnType(t.Elem())
default:
return "unknown"
}
}
func (h *Handler) isNullable(field reflect.StructField) bool {
return field.Type.Kind() == reflect.Ptr
}
func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata) {
response := common.Response{
Success: true,
Data: data,
Metadata: metadata,
}
w.WriteHeader(http.StatusOK)
w.WriteJSON(response)
}
// sendFormattedResponse sends response with formatting options
func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) {
// Clean JSON if requested (remove null/empty fields)
if options.CleanJSON {
data = h.cleanJSON(data)
}
w.SetHeader("Content-Type", "application/json")
// Format response based on response format option
switch options.ResponseFormat {
case "simple":
// Simple format: just return the data array
w.WriteHeader(http.StatusOK)
w.WriteJSON(data)
case "syncfusion":
// Syncfusion format: { result: data, count: total }
response := map[string]interface{}{
"result": data,
}
if metadata != nil {
response["count"] = metadata.Total
}
w.WriteHeader(http.StatusOK)
w.WriteJSON(response)
default:
// Default/detail format: standard response with metadata
response := common.Response{
Success: true,
Data: data,
Metadata: metadata,
}
w.WriteHeader(http.StatusOK)
w.WriteJSON(response)
}
}
// cleanJSON removes null and empty fields from the response
func (h *Handler) cleanJSON(data interface{}) interface{} {
// This is a simplified implementation
// A full implementation would recursively clean nested structures
// For now, we'll return the data as-is
// TODO: Implement recursive cleaning
return data
}
func (h *Handler) sendError(w common.ResponseWriter, statusCode int, code, message string, err error) {
var details string
if err != nil {
details = err.Error()
}
response := common.Response{
Success: false,
Error: &common.APIError{
Code: code,
Message: message,
Details: details,
},
}
w.WriteHeader(statusCode)
w.WriteJSON(response)
}
// filterExtendedOptions filters all column references, removing invalid ones and logging warnings
func filterExtendedOptions(validator *common.ColumnValidator, options ExtendedRequestOptions) ExtendedRequestOptions {
filtered := options
// Filter base RequestOptions
filtered.RequestOptions = validator.FilterRequestOptions(options.RequestOptions)
// Filter SearchColumns
filtered.SearchColumns = validator.FilterValidColumns(options.SearchColumns)
// Filter AdvancedSQL column keys
filteredAdvSQL := make(map[string]string)
for colName, sqlExpr := range options.AdvancedSQL {
if validator.IsValidColumn(colName) {
filteredAdvSQL[colName] = sqlExpr
} else {
logger.Warn("Invalid column in advanced SQL removed: %s", colName)
}
}
filtered.AdvancedSQL = filteredAdvSQL
// ComputedQL columns are allowed to be any name since they're computed
// No filtering needed for ComputedQL keys
filtered.ComputedQL = options.ComputedQL
// Filter Expand columns
filteredExpands := make([]ExpandOption, 0, len(options.Expand))
for _, expand := range options.Expand {
filteredExpand := expand
// Don't validate relation name, only columns
filteredExpand.Columns = validator.FilterValidColumns(expand.Columns)
filteredExpands = append(filteredExpands, filteredExpand)
}
filtered.Expand = filteredExpands
return filtered
}

464
pkg/restheadspec/headers.go Normal file
View File

@@ -0,0 +1,464 @@
package restheadspec
import (
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/Warky-Devs/ResolveSpec/pkg/common"
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
)
// ExtendedRequestOptions extends common.RequestOptions with additional features
type ExtendedRequestOptions struct {
common.RequestOptions
// Field selection
CleanJSON bool
// Advanced filtering
SearchColumns []string
CustomSQLWhere string
CustomSQLOr string
// Joins
Expand []ExpandOption
// Advanced features
AdvancedSQL map[string]string // Column -> SQL expression
ComputedQL map[string]string // Column -> CQL expression
Distinct bool
SkipCount bool
SkipCache bool
FetchRowNumber *string
PKRow *string
// Response format
ResponseFormat string // "simple", "detail", "syncfusion"
// Transaction
AtomicTransaction bool
// Cursor pagination
CursorForward string
CursorBackward string
}
// ExpandOption represents a relation expansion configuration
type ExpandOption struct {
Relation string
Columns []string
Where string
Sort string
}
// decodeHeaderValue decodes base64 encoded header values
// Supports ZIP_ and __ prefixes for base64 encoding
func decodeHeaderValue(value string) string {
str, _ := DecodeParam(value)
return str
}
// DecodeParam - Decodes parameter string and returns unencoded string
func DecodeParam(pStr string) (string, error) {
var code string = pStr
if strings.HasPrefix(pStr, "ZIP_") {
code = strings.ReplaceAll(pStr, "ZIP_", "")
code = strings.ReplaceAll(code, "\n", "")
code = strings.ReplaceAll(code, "\r", "")
code = strings.ReplaceAll(code, " ", "")
strDat, err := base64.StdEncoding.DecodeString(code)
if err != nil {
return code, fmt.Errorf("failed to read parameter base64: %v", err)
} else {
code = string(strDat)
}
} else if strings.HasPrefix(pStr, "__") {
code = strings.ReplaceAll(pStr, "__", "")
code = strings.ReplaceAll(code, "\n", "")
code = strings.ReplaceAll(code, "\r", "")
code = strings.ReplaceAll(code, " ", "")
strDat, err := base64.StdEncoding.DecodeString(code)
if err != nil {
return code, fmt.Errorf("failed to read parameter base64: %v", err)
} else {
code = string(strDat)
}
}
if strings.HasPrefix(code, "ZIP_") || strings.HasPrefix(code, "__") {
code, _ = DecodeParam(code)
}
return code, nil
}
// parseOptionsFromHeaders parses all request options from HTTP headers
func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptions {
options := ExtendedRequestOptions{
RequestOptions: common.RequestOptions{
Filters: make([]common.FilterOption, 0),
Sort: make([]common.SortOption, 0),
Preload: make([]common.PreloadOption, 0),
},
AdvancedSQL: make(map[string]string),
ComputedQL: make(map[string]string),
Expand: make([]ExpandOption, 0),
ResponseFormat: "simple", // Default response format
}
// Get all headers
headers := r.AllHeaders()
// Process each header
for key, value := range headers {
// Normalize header key to lowercase for consistent matching
normalizedKey := strings.ToLower(key)
// Decode value if it's base64 encoded
decodedValue := decodeHeaderValue(value)
// Parse based on header prefix/name
switch {
// Field Selection
case strings.HasPrefix(normalizedKey, "x-select-fields"):
h.parseSelectFields(&options, decodedValue)
case strings.HasPrefix(normalizedKey, "x-not-select-fields"):
h.parseNotSelectFields(&options, decodedValue)
case strings.HasPrefix(normalizedKey, "x-clean-json"):
options.CleanJSON = strings.ToLower(decodedValue) == "true"
// Filtering & Search
case strings.HasPrefix(normalizedKey, "x-fieldfilter-"):
h.parseFieldFilter(&options, normalizedKey, decodedValue)
case strings.HasPrefix(normalizedKey, "x-searchfilter-"):
h.parseSearchFilter(&options, normalizedKey, decodedValue)
case strings.HasPrefix(normalizedKey, "x-searchop-"):
h.parseSearchOp(&options, normalizedKey, decodedValue, "AND")
case strings.HasPrefix(normalizedKey, "x-searchor-"):
h.parseSearchOp(&options, normalizedKey, decodedValue, "OR")
case strings.HasPrefix(normalizedKey, "x-searchand-"):
h.parseSearchOp(&options, normalizedKey, decodedValue, "AND")
case strings.HasPrefix(normalizedKey, "x-searchcols"):
options.SearchColumns = h.parseCommaSeparated(decodedValue)
case strings.HasPrefix(normalizedKey, "x-custom-sql-w"):
options.CustomSQLWhere = decodedValue
case strings.HasPrefix(normalizedKey, "x-custom-sql-or"):
options.CustomSQLOr = decodedValue
// Joins & Relations
case strings.HasPrefix(normalizedKey, "x-preload"):
h.parsePreload(&options, decodedValue)
case strings.HasPrefix(normalizedKey, "x-expand"):
h.parseExpand(&options, decodedValue)
case strings.HasPrefix(normalizedKey, "x-custom-sql-join"):
// TODO: Implement custom SQL join
logger.Debug("Custom SQL join not yet implemented: %s", decodedValue)
// Sorting & Pagination
case strings.HasPrefix(normalizedKey, "x-sort"):
h.parseSorting(&options, decodedValue)
case strings.HasPrefix(normalizedKey, "x-limit"):
if limit, err := strconv.Atoi(decodedValue); err == nil {
options.Limit = &limit
}
case strings.HasPrefix(normalizedKey, "x-offset"):
if offset, err := strconv.Atoi(decodedValue); err == nil {
options.Offset = &offset
}
case strings.HasPrefix(normalizedKey, "x-cursor-forward"):
options.CursorForward = decodedValue
case strings.HasPrefix(normalizedKey, "x-cursor-backward"):
options.CursorBackward = decodedValue
// Advanced Features
case strings.HasPrefix(normalizedKey, "x-advsql-"):
colName := strings.TrimPrefix(normalizedKey, "x-advsql-")
options.AdvancedSQL[colName] = decodedValue
case strings.HasPrefix(normalizedKey, "x-cql-sel-"):
colName := strings.TrimPrefix(normalizedKey, "x-cql-sel-")
options.ComputedQL[colName] = decodedValue
case strings.HasPrefix(normalizedKey, "x-distinct"):
options.Distinct = strings.ToLower(decodedValue) == "true"
case strings.HasPrefix(normalizedKey, "x-skipcount"):
options.SkipCount = strings.ToLower(decodedValue) == "true"
case strings.HasPrefix(normalizedKey, "x-skipcache"):
options.SkipCache = strings.ToLower(decodedValue) == "true"
case strings.HasPrefix(normalizedKey, "x-fetch-rownumber"):
options.FetchRowNumber = &decodedValue
case strings.HasPrefix(normalizedKey, "x-pkrow"):
options.PKRow = &decodedValue
// Response Format
case strings.HasPrefix(normalizedKey, "x-simpleapi"):
options.ResponseFormat = "simple"
case strings.HasPrefix(normalizedKey, "x-detailapi"):
options.ResponseFormat = "detail"
case strings.HasPrefix(normalizedKey, "x-syncfusion"):
options.ResponseFormat = "syncfusion"
// Transaction Control
case strings.HasPrefix(normalizedKey, "x-transaction-atomic"):
options.AtomicTransaction = strings.ToLower(decodedValue) == "true"
}
}
return options
}
// parseSelectFields parses x-select-fields header
func (h *Handler) parseSelectFields(options *ExtendedRequestOptions, value string) {
if value == "" {
return
}
options.Columns = h.parseCommaSeparated(value)
if len(options.Columns) > 1 {
options.CleanJSON = true
}
}
// parseNotSelectFields parses x-not-select-fields header
func (h *Handler) parseNotSelectFields(options *ExtendedRequestOptions, value string) {
if value == "" {
return
}
options.OmitColumns = h.parseCommaSeparated(value)
if len(options.OmitColumns) > 1 {
options.CleanJSON = true
}
}
// parseFieldFilter parses x-fieldfilter-{colname} header (exact match)
func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, value string) {
colName := strings.TrimPrefix(headerKey, "x-fieldfilter-")
options.Filters = append(options.Filters, common.FilterOption{
Column: colName,
Operator: "eq",
Value: value,
})
}
// parseSearchFilter parses x-searchfilter-{colname} header (ILIKE search)
func (h *Handler) parseSearchFilter(options *ExtendedRequestOptions, headerKey, value string) {
colName := strings.TrimPrefix(headerKey, "x-searchfilter-")
// Use ILIKE for fuzzy search
options.Filters = append(options.Filters, common.FilterOption{
Column: colName,
Operator: "ilike",
Value: "%" + value + "%",
})
}
// parseSearchOp parses x-searchop-{operator}-{colname} and x-searchor-{operator}-{colname}
func (h *Handler) parseSearchOp(options *ExtendedRequestOptions, headerKey, value, logicOp string) {
// Extract operator and column name
// Format: x-searchop-{operator}-{colname} or x-searchor-{operator}-{colname}
var prefix string
if logicOp == "OR" {
prefix = "x-searchor-"
} else {
prefix = "x-searchop-"
if strings.HasPrefix(headerKey, "x-searchand-") {
prefix = "x-searchand-"
}
}
rest := strings.TrimPrefix(headerKey, prefix)
parts := strings.SplitN(rest, "-", 2)
if len(parts) != 2 {
logger.Warn("Invalid search operator header format: %s", headerKey)
return
}
operator := parts[0]
colName := parts[1]
// Map operator names to filter operators
filterOp := h.mapSearchOperator(operator, value)
options.Filters = append(options.Filters, filterOp)
// Note: OR logic would need special handling in query builder
// For now, we'll add a comment to indicate OR logic
if logicOp == "OR" {
// TODO: Implement OR logic in query builder
logger.Debug("OR logic filter: %s %s %v", colName, filterOp.Operator, filterOp.Value)
}
}
// mapSearchOperator maps search operator names to filter operators
func (h *Handler) mapSearchOperator(operator, value string) common.FilterOption {
operator = strings.ToLower(operator)
switch operator {
case "contains":
return common.FilterOption{Operator: "ilike", Value: "%" + value + "%"}
case "beginswith", "startswith":
return common.FilterOption{Operator: "ilike", Value: value + "%"}
case "endswith":
return common.FilterOption{Operator: "ilike", Value: "%" + value}
case "equals", "eq":
return common.FilterOption{Operator: "eq", Value: value}
case "notequals", "neq", "ne":
return common.FilterOption{Operator: "neq", Value: value}
case "greaterthan", "gt":
return common.FilterOption{Operator: "gt", Value: value}
case "lessthan", "lt":
return common.FilterOption{Operator: "lt", Value: value}
case "greaterthanorequal", "gte", "ge":
return common.FilterOption{Operator: "gte", Value: value}
case "lessthanorequal", "lte", "le":
return common.FilterOption{Operator: "lte", Value: value}
case "between":
// Parse between values (format: "value1,value2")
// Between is exclusive (> value1 AND < value2)
parts := strings.Split(value, ",")
if len(parts) == 2 {
return common.FilterOption{Operator: "between", Value: parts}
}
return common.FilterOption{Operator: "eq", Value: value}
case "betweeninclusive":
// Parse between values (format: "value1,value2")
// Between inclusive is >= value1 AND <= value2
parts := strings.Split(value, ",")
if len(parts) == 2 {
return common.FilterOption{Operator: "between_inclusive", Value: parts}
}
return common.FilterOption{Operator: "eq", Value: value}
case "in":
// Parse IN values (format: "value1,value2,value3")
values := strings.Split(value, ",")
return common.FilterOption{Operator: "in", Value: values}
case "empty", "isnull", "null":
// Check for NULL or empty string
return common.FilterOption{Operator: "is_null", Value: nil}
case "notempty", "isnotnull", "notnull":
// Check for NOT NULL
return common.FilterOption{Operator: "is_not_null", Value: nil}
default:
logger.Warn("Unknown search operator: %s, defaulting to equals", operator)
return common.FilterOption{Operator: "eq", Value: value}
}
}
// parsePreload parses x-preload header
// Format: RelationName:field1,field2 or RelationName or multiple separated by |
func (h *Handler) parsePreload(options *ExtendedRequestOptions, value string) {
if value == "" {
return
}
// Split by | for multiple preloads
preloads := strings.Split(value, "|")
for _, preloadStr := range preloads {
preloadStr = strings.TrimSpace(preloadStr)
if preloadStr == "" {
continue
}
// Parse relation:columns format
parts := strings.SplitN(preloadStr, ":", 2)
preload := common.PreloadOption{
Relation: strings.TrimSpace(parts[0]),
}
if len(parts) == 2 {
// Parse columns
preload.Columns = h.parseCommaSeparated(parts[1])
}
options.Preload = append(options.Preload, preload)
}
}
// parseExpand parses x-expand header (LEFT JOIN expansion)
// Format: RelationName:field1,field2 or RelationName or multiple separated by |
func (h *Handler) parseExpand(options *ExtendedRequestOptions, value string) {
if value == "" {
return
}
// Split by | for multiple expands
expands := strings.Split(value, "|")
for _, expandStr := range expands {
expandStr = strings.TrimSpace(expandStr)
if expandStr == "" {
continue
}
// Parse relation:columns format
parts := strings.SplitN(expandStr, ":", 2)
expand := ExpandOption{
Relation: strings.TrimSpace(parts[0]),
}
if len(parts) == 2 {
// Parse columns
expand.Columns = h.parseCommaSeparated(parts[1])
}
options.Expand = append(options.Expand, expand)
}
}
// parseSorting parses x-sort header
// Format: +field1,-field2,field3 (+ for ASC, - for DESC, default ASC)
func (h *Handler) parseSorting(options *ExtendedRequestOptions, value string) {
if value == "" {
return
}
sortFields := h.parseCommaSeparated(value)
for _, field := range sortFields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
direction := "ASC"
colName := field
if strings.HasPrefix(field, "-") {
direction = "DESC"
colName = strings.TrimPrefix(field, "-")
} else if strings.HasPrefix(field, "+") {
direction = "ASC"
colName = strings.TrimPrefix(field, "+")
}
options.Sort = append(options.Sort, common.SortOption{
Column: colName,
Direction: direction,
})
}
}
// parseCommaSeparated parses comma-separated values and trims whitespace
func (h *Handler) parseCommaSeparated(value string) []string {
if value == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
return result
}
// parseJSONHeader parses a header value as JSON
func (h *Handler) parseJSONHeader(value string) (map[string]interface{}, error) {
var result map[string]interface{}
err := json.Unmarshal([]byte(value), &result)
if err != nil {
return nil, fmt.Errorf("failed to parse JSON header: %w", err)
}
return result, nil
}

View File

@@ -0,0 +1,255 @@
// Package restheadspec provides the Rest Header Spec API framework.
//
// Rest Header Spec (restheadspec) is a RESTful API framework that reads query options,
// filters, sorting, pagination, and other parameters from HTTP headers instead of
// request bodies or query parameters. This approach provides a clean separation between
// data and metadata in API requests.
//
// # Key Features
//
// - Header-based API configuration: All query options are passed via HTTP headers
// - Database-agnostic: Works with both GORM and Bun ORM through adapters
// - Router-agnostic: Supports multiple HTTP routers (Mux, BunRouter, etc.)
// - Advanced filtering: Supports complex filter operations (eq, gt, lt, like, between, etc.)
// - Pagination and sorting: Built-in support for limit, offset, and multi-column sorting
// - Preloading and expansion: Support for eager loading relationships
// - Multiple response formats: Default, simple, and Syncfusion formats
//
// # HTTP Headers
//
// The following headers are supported for configuring API requests:
//
// - X-Filters: JSON array of filter conditions
// - X-Columns: Comma-separated list of columns to select
// - X-Sort: JSON array of sort specifications
// - X-Limit: Maximum number of records to return
// - X-Offset: Number of records to skip
// - X-Preload: Comma-separated list of relations to preload
// - X-Expand: Comma-separated list of relations to expand (LEFT JOIN)
// - X-Distinct: Boolean to enable DISTINCT queries
// - X-Skip-Count: Boolean to skip total count query
// - X-Response-Format: Response format (detail, simple, syncfusion)
// - X-Clean-JSON: Boolean to remove null/empty fields
// - X-Custom-SQL-Where: Custom SQL WHERE clause (AND)
// - X-Custom-SQL-Or: Custom SQL WHERE clause (OR)
//
// # Usage Example
//
// // Create a handler with GORM
// handler := restheadspec.NewHandlerWithGORM(db)
//
// // Register models
// handler.Registry.RegisterModel("users", User{})
//
// // Setup routes with Mux
// muxRouter := mux.NewRouter()
// restheadspec.SetupMuxRoutes(muxRouter, handler)
//
// // Make a request with headers
// // GET /public/users
// // X-Filters: [{"column":"age","operator":"gt","value":18}]
// // X-Sort: [{"column":"name","direction":"asc"}]
// // X-Limit: 10
package restheadspec
import (
"net/http"
"github.com/Warky-Devs/ResolveSpec/pkg/common/adapters/database"
"github.com/Warky-Devs/ResolveSpec/pkg/common/adapters/router"
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
"github.com/gorilla/mux"
"github.com/uptrace/bun"
"github.com/uptrace/bunrouter"
"gorm.io/gorm"
)
// NewHandlerWithGORM creates a new Handler with GORM adapter
func NewHandlerWithGORM(db *gorm.DB) *Handler {
gormAdapter := database.NewGormAdapter(db)
registry := modelregistry.NewModelRegistry()
return NewHandler(gormAdapter, registry)
}
// NewHandlerWithBun creates a new Handler with Bun adapter
func NewHandlerWithBun(db *bun.DB) *Handler {
bunAdapter := database.NewBunAdapter(db)
registry := modelregistry.NewModelRegistry()
return NewHandler(bunAdapter, registry)
}
// NewStandardMuxRouter creates a router with standard Mux HTTP handlers
func NewStandardMuxRouter() *router.StandardMuxAdapter {
return router.NewStandardMuxAdapter()
}
// NewStandardBunRouter creates a router with standard BunRouter handlers
func NewStandardBunRouter() *router.StandardBunRouterAdapter {
return router.NewStandardBunRouterAdapter()
}
// SetupMuxRoutes sets up routes for the RestHeadSpec API with Mux
func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler) {
// GET, POST, PUT, PATCH, DELETE for /{schema}/{entity}
muxRouter.HandleFunc("/{schema}/{entity}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
reqAdapter := router.NewHTTPRequest(r)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, vars)
}).Methods("GET", "POST")
// GET, PUT, PATCH, DELETE for /{schema}/{entity}/{id}
muxRouter.HandleFunc("/{schema}/{entity}/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
reqAdapter := router.NewHTTPRequest(r)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, vars)
}).Methods("GET", "PUT", "PATCH", "DELETE")
// GET for metadata (using HandleGet)
muxRouter.HandleFunc("/{schema}/{entity}/metadata", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
reqAdapter := router.NewHTTPRequest(r)
respAdapter := router.NewHTTPResponseWriter(w)
handler.HandleGet(respAdapter, reqAdapter, vars)
}).Methods("GET")
}
// Example usage functions for documentation:
// ExampleWithGORM shows how to use RestHeadSpec with GORM
func ExampleWithGORM(db *gorm.DB) {
// Create handler using GORM
handler := NewHandlerWithGORM(db)
// Setup router
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler)
// Register models
// handler.registry.RegisterModel("public.users", &User{})
}
// ExampleWithBun shows how to switch to Bun ORM
func ExampleWithBun(bunDB *bun.DB) {
// Create Bun adapter
dbAdapter := database.NewBunAdapter(bunDB)
// Create model registry
registry := modelregistry.NewModelRegistry()
// registry.RegisterModel("public.users", &User{})
// Create handler
handler := NewHandler(dbAdapter, registry)
// Setup routes
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler)
}
// SetupBunRouterRoutes sets up bunrouter routes for the RestHeadSpec API
func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) {
r := bunRouter.GetBunRouter()
// GET and POST for /:schema/:entity
r.Handle("GET", "/:schema/:entity", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
}
reqAdapter := router.NewBunRouterRequest(req)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("POST", "/:schema/:entity", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
}
reqAdapter := router.NewBunRouterRequest(req)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
// GET, PUT, PATCH, DELETE for /:schema/:entity/:id
r.Handle("GET", "/:schema/:entity/:id", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("PUT", "/:schema/:entity/:id", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("PATCH", "/:schema/:entity/:id", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("DELETE", "/:schema/:entity/:id", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
// Metadata endpoint
r.Handle("GET", "/:schema/:entity/metadata", func(w http.ResponseWriter, req bunrouter.Request) error {
params := map[string]string{
"schema": req.Param("schema"),
"entity": req.Param("entity"),
}
reqAdapter := router.NewBunRouterRequest(req)
respAdapter := router.NewHTTPResponseWriter(w)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
}
// ExampleBunRouterWithBunDB shows usage with both BunRouter and Bun DB
func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
// Create handler
handler := NewHandlerWithBun(bunDB)
// Create BunRouter adapter
routerAdapter := NewStandardBunRouter()
// Setup routes
SetupBunRouterRoutes(routerAdapter, handler)
// Get the underlying router for server setup
r := routerAdapter.GetBunRouter()
// Start server
http.ListenAndServe(":8080", r)
}

View File

@@ -3,7 +3,7 @@ package testmodels
import (
"time"
"github.com/Warky-Devs/ResolveSpec/pkg/models"
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
)
// Department represents a company department
@@ -138,11 +138,24 @@ 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")
// RegisterTestModels registers all test models with the provided registry
func RegisterTestModels(registry *modelregistry.DefaultModelRegistry) {
registry.RegisterModel("departments", Department{})
registry.RegisterModel("employees", Employee{})
registry.RegisterModel("projects", Project{})
registry.RegisterModel("project_tasks", ProjectTask{})
registry.RegisterModel("documents", Document{})
registry.RegisterModel("comments", Comment{})
}
// GetTestModels returns a list of all test model instances
func GetTestModels() []interface{} {
return []interface{}{
Department{},
Employee{},
Project{},
ProjectTask{},
Document{},
Comment{},
}
}

View File

@@ -11,7 +11,7 @@ import (
"testing"
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
"github.com/Warky-Devs/ResolveSpec/pkg/models"
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
"github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
"github.com/Warky-Devs/ResolveSpec/pkg/testmodels"
"github.com/glebarez/sqlite"
@@ -104,9 +104,6 @@ func setupTestDB() (*gorm.DB, error) {
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 {
@@ -119,17 +116,24 @@ func setupTestDB() (*gorm.DB, error) {
// 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")
// Create a new registry instance
registry := modelregistry.NewModelRegistry()
r.HandleFunc("/{schema}/{entity}/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
handler.Handle(w, r, vars)
}).Methods("POST")
// Register test models with the registry
testmodels.RegisterTestModels(registry)
// Create handler with GORM adapter and the registry
handler := resolvespec.NewHandlerWithGORM(db)
// Register test models with the handler for the "test" schema
models := testmodels.GetTestModels()
modelNames := []string{"departments", "employees", "projects", "project_tasks", "documents", "comments"}
for i, model := range models {
handler.RegisterModel("test", modelNames[i], model)
}
resolvespec.SetupMuxRoutes(r, handler)
return r
}
@@ -147,6 +151,6 @@ func cleanup() {
// autoMigrateModels performs automigration for all test models
func autoMigrateModels(db *gorm.DB) error {
modelList := models.GetModels()
modelList := testmodels.GetTestModels()
return db.AutoMigrate(modelList...)
}