17 KiB
ResolveSpec - Body-Based REST API
ResolveSpec provides a REST API where query options are passed in the JSON request body. This approach offers GraphQL-like flexibility while maintaining RESTful principles, making it ideal for complex queries and operations.
Features
- Body-Based Querying: All query options passed via JSON request body
- Lifecycle Hooks: Before/after hooks for create, read, update, delete operations
- Cursor Pagination: Efficient cursor-based pagination with complex sorting
- Offset Pagination: Traditional limit/offset pagination support
- Advanced Filtering: Multiple operators, AND/OR logic, and custom SQL
- Relationship Preloading: Load related entities with custom column selection and filters
- Recursive CRUD: Automatically handle nested object graphs with foreign key resolution
- Computed Columns: Define virtual columns with SQL expressions
- Database-Agnostic: Works with GORM, Bun, or custom database adapters
- Router-Agnostic: Integrates with any HTTP router through standard interfaces
- Type-Safe: Strong type validation and conversion
Quick Start
Setup with GORM
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
import "github.com/gorilla/mux"
// Create handler
handler := resolvespec.NewHandlerWithGORM(db)
// IMPORTANT: Register models BEFORE setting up routes
handler.registry.RegisterModel("core.users", &User{})
handler.registry.RegisterModel("core.posts", &Post{})
// Setup routes
router := mux.NewRouter()
resolvespec.SetupMuxRoutes(router, handler, nil)
// Start server
http.ListenAndServe(":8080", router)
Setup with Bun ORM
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
import "github.com/uptrace/bun"
// Create handler with Bun
handler := resolvespec.NewHandlerWithBun(bunDB)
// Register models
handler.registry.RegisterModel("core.users", &User{})
// Setup routes (same as GORM)
router := mux.NewRouter()
resolvespec.SetupMuxRoutes(router, handler, nil)
Basic Usage
Simple Read Request
POST /core/users HTTP/1.1
Content-Type: application/json
{
"operation": "read",
"options": {
"columns": ["id", "name", "email"],
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
}
],
"sort": [
{
"column": "created_at",
"direction": "desc"
}
],
"limit": 10,
"offset": 0
}
}
With Preloading
POST /core/users HTTP/1.1
Content-Type: application/json
{
"operation": "read",
"options": {
"columns": ["id", "name", "email"],
"preload": [
{
"relation": "posts",
"columns": ["id", "title", "created_at"],
"filters": [
{
"column": "status",
"operator": "eq",
"value": "published"
}
]
}
],
"limit": 10
}
}
Request Structure
Request Format
{
"operation": "read|create|update|delete",
"data": {
// For create/update operations
},
"options": {
"columns": [...],
"preload": [...],
"filters": [...],
"sort": [...],
"limit": number,
"offset": number,
"cursor_forward": "string",
"cursor_backward": "string",
"customOperators": [...],
"computedColumns": [...]
}
}
Operations
| Operation | Description | Requires Data | Requires ID |
|---|---|---|---|
read |
Fetch records | No | Optional (single record) |
create |
Create new record(s) | Yes | No |
update |
Update existing record(s) | Yes | Yes (in URL) |
delete |
Delete record(s) | No | Yes (in URL) |
Options Fields
| Field | Type | Description | Example |
|---|---|---|---|
columns |
[]string |
Columns to select | ["id", "name", "email"] |
preload |
[]PreloadConfig |
Relations to load | See Preloading |
filters |
[]Filter |
Filter conditions | See Filtering |
sort |
[]Sort |
Sort criteria | [{"column": "created_at", "direction": "desc"}] |
limit |
int |
Max records to return | 50 |
offset |
int |
Number of records to skip | 100 |
cursor_forward |
string |
Cursor for next page | "12345" |
cursor_backward |
string |
Cursor for previous page | "12300" |
customOperators |
[]CustomOperator |
Custom SQL conditions | See Custom Operators |
computedColumns |
[]ComputedColumn |
Virtual columns | See Computed Columns |
Filtering
Available Operators
| Operator | Description | Example |
|---|---|---|
eq |
Equal | {"column": "status", "operator": "eq", "value": "active"} |
neq |
Not Equal | {"column": "status", "operator": "neq", "value": "deleted"} |
gt |
Greater Than | {"column": "age", "operator": "gt", "value": 18} |
gte |
Greater Than or Equal | {"column": "age", "operator": "gte", "value": 18} |
lt |
Less Than | {"column": "price", "operator": "lt", "value": 100} |
lte |
Less Than or Equal | {"column": "price", "operator": "lte", "value": 100} |
like |
LIKE pattern | {"column": "name", "operator": "like", "value": "%john%"} |
ilike |
Case-insensitive LIKE | {"column": "email", "operator": "ilike", "value": "%@example.com"} |
in |
IN clause | {"column": "status", "operator": "in", "value": ["active", "pending"]} |
contains |
Contains string | {"column": "description", "operator": "contains", "value": "important"} |
startswith |
Starts with string | {"column": "name", "operator": "startswith", "value": "John"} |
endswith |
Ends with string | {"column": "email", "operator": "endswith", "value": "@example.com"} |
between |
Between (exclusive) | {"column": "age", "operator": "between", "value": [18, 65]} |
betweeninclusive |
Between (inclusive) | {"column": "price", "operator": "betweeninclusive", "value": [10, 100]} |
empty |
IS NULL or empty | {"column": "deleted_at", "operator": "empty"} |
notempty |
IS NOT NULL | {"column": "email", "operator": "notempty"} |
Complex Filtering Example
{
"operation": "read",
"options": {
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
},
{
"column": "age",
"operator": "gte",
"value": 18
},
{
"column": "email",
"operator": "ilike",
"value": "%@company.com"
}
]
}
}
Preloading
Load related entities with custom configuration:
{
"operation": "read",
"options": {
"columns": ["id", "name", "email"],
"preload": [
{
"relation": "posts",
"columns": ["id", "title", "created_at"],
"filters": [
{
"column": "status",
"operator": "eq",
"value": "published"
}
],
"sort": [
{
"column": "created_at",
"direction": "desc"
}
],
"limit": 5
},
{
"relation": "profile",
"columns": ["bio", "website"]
}
]
}
}
Cursor Pagination
Efficient pagination for large datasets:
First Request (No Cursor)
{
"operation": "read",
"options": {
"sort": [
{
"column": "created_at",
"direction": "desc"
},
{
"column": "id",
"direction": "asc"
}
],
"limit": 50
}
}
Next Page (Forward Cursor)
{
"operation": "read",
"options": {
"sort": [
{
"column": "created_at",
"direction": "desc"
},
{
"column": "id",
"direction": "asc"
}
],
"limit": 50,
"cursor_forward": "12345"
}
}
Previous Page (Backward Cursor)
{
"operation": "read",
"options": {
"sort": [
{
"column": "created_at",
"direction": "desc"
},
{
"column": "id",
"direction": "asc"
}
],
"limit": 50,
"cursor_backward": "12300"
}
}
Benefits over offset pagination:
- Consistent results when data changes
- Better performance for large offsets
- Prevents "skipped" or duplicate records
- Works with complex sort expressions
Recursive CRUD Operations
Automatically handle nested object graphs with intelligent foreign key resolution.
Creating Nested Objects
{
"operation": "create",
"data": {
"name": "John Doe",
"email": "john@example.com",
"posts": [
{
"title": "My First Post",
"content": "Hello World",
"tags": [
{"name": "tech"},
{"name": "programming"}
]
},
{
"title": "Second Post",
"content": "More content"
}
],
"profile": {
"bio": "Software Developer",
"website": "https://example.com"
}
}
}
Per-Record Operation Control with _request
Control individual operations for each nested record:
{
"operation": "update",
"data": {
"name": "John Updated",
"posts": [
{
"_request": "insert",
"title": "New Post",
"content": "Fresh content"
},
{
"_request": "update",
"id": 456,
"title": "Updated Post Title"
},
{
"_request": "delete",
"id": 789
}
]
}
}
Supported _request values:
insert- Create a new related recordupdate- Update an existing related recorddelete- Delete a related recordupsert- Create if doesn't exist, update if exists
How It Works:
- Automatic foreign key resolution - parent IDs propagate to children
- Recursive processing - handles nested relationships at any depth
- Transaction safety - all operations execute atomically
- Relationship detection - automatically detects belongsTo, hasMany, hasOne, many2many
- Flexible operations - mix create, update, and delete in one request
Computed Columns
Define virtual columns using SQL expressions:
{
"operation": "read",
"options": {
"columns": ["id", "first_name", "last_name"],
"computedColumns": [
{
"name": "full_name",
"expression": "CONCAT(first_name, ' ', last_name)"
},
{
"name": "age_years",
"expression": "EXTRACT(YEAR FROM AGE(birth_date))"
}
]
}
}
Custom Operators
Add custom SQL conditions when needed:
{
"operation": "read",
"options": {
"customOperators": [
{
"condition": "LOWER(email) LIKE ?",
"values": ["%@example.com"]
},
{
"condition": "created_at > NOW() - INTERVAL '7 days'"
}
]
}
}
Lifecycle Hooks
Register hooks for all CRUD operations:
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
// Create handler
handler := resolvespec.NewHandlerWithGORM(db)
// Register a before-read hook (e.g., for authorization)
handler.Hooks().Register(resolvespec.BeforeRead, func(ctx *resolvespec.HookContext) error {
// Check permissions
if !userHasPermission(ctx.Context, ctx.Entity) {
return fmt.Errorf("unauthorized access to %s", ctx.Entity)
}
// Modify query options
if ctx.Options.Limit == nil || *ctx.Options.Limit > 100 {
ctx.Options.Limit = ptr(100) // Enforce max limit
}
return nil
})
// Register an after-read hook (e.g., for data transformation)
handler.Hooks().Register(resolvespec.AfterRead, func(ctx *resolvespec.HookContext) error {
// Transform or filter results
if users, ok := ctx.Result.([]User); ok {
for i := range users {
users[i].Email = maskEmail(users[i].Email)
}
}
return nil
})
// Register a before-create hook (e.g., for validation)
handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookContext) error {
// Validate data
if user, ok := ctx.Data.(*User); ok {
if user.Email == "" {
return fmt.Errorf("email is required")
}
// Add timestamps
user.CreatedAt = time.Now()
}
return nil
})
Available Hook Types:
BeforeRead,AfterReadBeforeCreate,AfterCreateBeforeUpdate,AfterUpdateBeforeDelete,AfterDelete
HookContext provides:
Context: Request contextHandler: Access to handler, database, and registrySchema,Entity,TableName: Request infoModel: The registered model typeOptions: Parsed request options (filters, sorting, etc.)ID: Record ID (for single-record operations)Data: Request data (for create/update)Result: Operation result (for after hooks)Writer: Response writer (allows hooks to modify response)
Model Registration
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
Posts []Post `json:"posts,omitempty" gorm:"foreignKey:UserID"`
Profile *Profile `json:"profile,omitempty" gorm:"foreignKey:UserID"`
}
type Post struct {
ID uint `json:"id" gorm:"primaryKey"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
Tags []Tag `json:"tags,omitempty" gorm:"many2many:post_tags"`
}
// Schema.Table format
handler.registry.RegisterModel("core.users", &User{})
handler.registry.RegisterModel("core.posts", &Post{})
Complete Example
package main
import (
"log"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
"github.com/gorilla/mux"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name"`
Email string `json:"email"`
Status string `json:"status"`
Posts []Post `json:"posts,omitempty" gorm:"foreignKey:UserID"`
}
type Post struct {
ID uint `json:"id" gorm:"primaryKey"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Status string `json:"status"`
}
func main() {
// Connect to database
db, err := gorm.Open(postgres.Open("your-connection-string"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
// Create handler
handler := resolvespec.NewHandlerWithGORM(db)
// Register models
handler.registry.RegisterModel("core.users", &User{})
handler.registry.RegisterModel("core.posts", &Post{})
// Add hooks
handler.Hooks().Register(resolvespec.BeforeRead, func(ctx *resolvespec.HookContext) error {
log.Printf("Reading %s", ctx.Entity)
return nil
})
// Setup routes
router := mux.NewRouter()
resolvespec.SetupMuxRoutes(router, handler, nil)
// Start server
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", router))
}
Testing
ResolveSpec is designed for testability:
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
)
func TestUserRead(t *testing.T) {
handler := resolvespec.NewHandlerWithGORM(testDB)
handler.registry.RegisterModel("core.users", &User{})
reqBody := map[string]interface{}{
"operation": "read",
"options": map[string]interface{}{
"columns": []string{"id", "name"},
"limit": 10,
},
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/core/users", bytes.NewReader(body))
rec := httptest.NewRecorder()
// Test your handler...
}
Router Integration
Gorilla Mux
router := mux.NewRouter()
resolvespec.SetupMuxRoutes(router, handler, nil)
BunRouter
router := bunrouter.New()
resolvespec.SetupBunRouterWithResolveSpec(router, handler)
Custom Routers
// Implement custom integration using common.Request and common.ResponseWriter
router.POST("/:schema/:entity", func(w http.ResponseWriter, r *http.Request) {
params := extractParams(r) // Your param extraction logic
reqAdapter := router.NewHTTPRequest(r)
respAdapter := router.NewHTTPResponseWriter(w)
handler.Handle(respAdapter, reqAdapter, params)
})
Response Format
Success Response
{
"success": true,
"data": [...],
"metadata": {
"total": 100,
"filtered": 50,
"limit": 10,
"offset": 0
}
}
Error Response
{
"success": false,
"error": {
"code": "validation_error",
"message": "Invalid request",
"details": "..."
}
}
See Also
- Main README - ResolveSpec overview
- RestHeadSpec Package - Header-based API
- StaticWeb Package - Static file server
License
This package is part of ResolveSpec and is licensed under the MIT License.