mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-31 17:28:58 +00:00
Massive refactor and introduction of restheadspec
This commit is contained in:
614
pkg/restheadspec/HEADERS.md
Normal file
614
pkg/restheadspec/HEADERS.md
Normal 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
616
pkg/restheadspec/handler.go
Normal 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
441
pkg/restheadspec/headers.go
Normal 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
|
||||
}
|
||||
203
pkg/restheadspec/restheadspec.go
Normal file
203
pkg/restheadspec/restheadspec.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user