Massive refactor and introduction of restheadspec

This commit is contained in:
Hein
2025-11-06 16:15:35 +02:00
parent 399cea9335
commit 8e06736701
22 changed files with 2472 additions and 1046 deletions

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.

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

@@ -0,0 +1,616 @@
package restheadspec
import (
"context"
"encoding/json"
"fmt"
"net/http"
"reflect"
"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,
}
}
// 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) {
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)
switch method {
case "GET":
if id != "" {
// GET with ID - read single record
h.handleRead(ctx, w, schema, entity, id, options)
} else {
// GET without ID - read multiple records
h.handleRead(ctx, w, schema, entity, "", 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, schema, entity, 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, schema, entity, id, nil, data, options)
case "DELETE":
h.handleDelete(ctx, w, schema, entity, 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) {
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, schema, entity, id string, options ExtendedRequestOptions) {
logger.Info("Reading records from %s.%s", schema, entity)
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
}
query := h.db.NewSelect().Model(model)
// Get table name
tableName := h.getTableName(schema, entity, model)
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
resultSlice := reflect.New(reflect.SliceOf(reflect.TypeOf(model))).Interface()
if err := query.Scan(ctx, resultSlice); 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, resultSlice, metadata, options)
}
func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, schema, entity string, data interface{}, options ExtendedRequestOptions) {
logger.Info("Creating record in %s.%s", schema, entity)
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
}
tableName := h.getTableName(schema, entity, model)
// 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
modelValue := reflect.New(reflect.TypeOf(model).Elem()).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
modelValue := reflect.New(reflect.TypeOf(model).Elem()).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, schema, entity, id string, idPtr *int64, data interface{}, options ExtendedRequestOptions) {
logger.Info("Updating record in %s.%s", schema, entity)
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
}
tableName := h.getTableName(schema, entity, model)
// 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, schema, entity, id string) {
logger.Info("Deleting record from %s.%s", schema, entity)
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
}
tableName := h.getTableName(schema, entity, model)
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)
}
}
func (h *Handler) getTableName(schema, entity string, model interface{}) string {
// Check if model implements TableNameProvider
if provider, ok := model.(common.TableNameProvider); ok {
tableName := provider.TableName()
if tableName != "" {
return tableName
}
}
// Default to schema.entity
if schema != "" {
return fmt.Sprintf("%s.%s", schema, entity)
}
return entity
}
func (h *Handler) generateMetadata(schema, entity string, model interface{}) *common.TableMetadata {
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
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)
}
// 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)
}

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

@@ -0,0 +1,441 @@
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 {
// Check for ZIP_ prefix
if strings.HasPrefix(value, "ZIP_") {
decoded, err := base64.StdEncoding.DecodeString(value[4:])
if err == nil {
return string(decoded)
}
logger.Warn("Failed to decode ZIP_ prefixed value: %v", err)
return value
}
// Check for __ prefix
if strings.HasPrefix(value, "__") {
decoded, err := base64.StdEncoding.DecodeString(value[2:])
if err == nil {
return string(decoded)
}
logger.Warn("Failed to decode __ prefixed value: %v", err)
return value
}
return value
}
// 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),
}
// 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)
}
// parseNotSelectFields parses x-not-select-fields header
func (h *Handler) parseNotSelectFields(options *ExtendedRequestOptions, value string) {
if value == "" {
return
}
options.OmitColumns = h.parseCommaSeparated(value)
}
// 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,203 @@
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)
}