fix(go.sum): update ResolveSpec dependency to v1.0.87
This commit is contained in:
+572
@@ -0,0 +1,572 @@
|
||||
# ResolveSpec Query Features Examples
|
||||
|
||||
This document provides examples of using the advanced query features in ResolveSpec, including OR logic filters, Custom Operators, and FetchRowNumber.
|
||||
|
||||
## OR Logic in Filters (SearchOr)
|
||||
|
||||
### Basic OR Filter Example
|
||||
|
||||
Find all users with status "active" OR "pending":
|
||||
|
||||
```json
|
||||
POST /users
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "pending",
|
||||
"logic_operator": "OR"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Combined AND/OR Filters
|
||||
|
||||
Find users with (status="active" OR status="pending") AND age >= 18:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "pending",
|
||||
"logic_operator": "OR"
|
||||
},
|
||||
{
|
||||
"column": "age",
|
||||
"operator": "gte",
|
||||
"value": 18
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SQL Generated:** `WHERE (status = 'active' OR status = 'pending') AND age >= 18`
|
||||
|
||||
**Important Notes:**
|
||||
- By default, filters use AND logic
|
||||
- Consecutive filters with `"logic_operator": "OR"` are automatically grouped with parentheses
|
||||
- This grouping ensures OR conditions don't interfere with AND conditions
|
||||
- You don't need to specify `"logic_operator": "AND"` as it's the default
|
||||
|
||||
### Multiple OR Groups
|
||||
|
||||
You can have multiple separate OR groups:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "pending",
|
||||
"logic_operator": "OR"
|
||||
},
|
||||
{
|
||||
"column": "priority",
|
||||
"operator": "eq",
|
||||
"value": "high"
|
||||
},
|
||||
{
|
||||
"column": "priority",
|
||||
"operator": "eq",
|
||||
"value": "urgent",
|
||||
"logic_operator": "OR"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SQL Generated:** `WHERE (status = 'active' OR status = 'pending') AND (priority = 'high' OR priority = 'urgent')`
|
||||
|
||||
## Custom Operators
|
||||
|
||||
### Simple Custom SQL Condition
|
||||
|
||||
Filter by email domain using custom SQL:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"customOperators": [
|
||||
{
|
||||
"name": "company_emails",
|
||||
"sql": "email LIKE '%@company.com'"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Custom Operators
|
||||
|
||||
Combine multiple custom SQL conditions:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"customOperators": [
|
||||
{
|
||||
"name": "recent_active",
|
||||
"sql": "last_login > NOW() - INTERVAL '30 days'"
|
||||
},
|
||||
{
|
||||
"name": "high_score",
|
||||
"sql": "score > 1000"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Custom Operator
|
||||
|
||||
Use complex SQL expressions:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"customOperators": [
|
||||
{
|
||||
"name": "priority_users",
|
||||
"sql": "(subscription = 'premium' AND points > 500) OR (subscription = 'enterprise')"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Combining Custom Operators with Regular Filters
|
||||
|
||||
Mix custom operators with standard filters:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "country",
|
||||
"operator": "eq",
|
||||
"value": "USA"
|
||||
}
|
||||
],
|
||||
"customOperators": [
|
||||
{
|
||||
"name": "active_last_month",
|
||||
"sql": "last_activity > NOW() - INTERVAL '1 month'"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Row Numbers
|
||||
|
||||
### Two Ways to Get Row Numbers
|
||||
|
||||
There are two different features for row numbers:
|
||||
|
||||
1. **`fetch_row_number`** - Get the position of ONE specific record in a sorted/filtered set
|
||||
2. **`RowNumber` field in models** - Automatically number all records in the response
|
||||
|
||||
### 1. FetchRowNumber - Get Position of Specific Record
|
||||
|
||||
Get the rank/position of a specific user in a leaderboard. **Important:** When `fetch_row_number` is specified, the response contains **ONLY that specific record**, not all records.
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"sort": [
|
||||
{
|
||||
"column": "score",
|
||||
"direction": "desc"
|
||||
}
|
||||
],
|
||||
"fetch_row_number": "12345"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response - Contains ONLY the specified user:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 12345,
|
||||
"name": "Alice Smith",
|
||||
"score": 9850,
|
||||
"level": 42
|
||||
},
|
||||
"metadata": {
|
||||
"total": 10000,
|
||||
"count": 1,
|
||||
"filtered": 10000,
|
||||
"row_number": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** User "12345" is ranked #42 out of 10,000 users. The response includes only Alice's data, not the other 9,999 users.
|
||||
|
||||
### Row Number with Filters
|
||||
|
||||
Find position within a filtered subset (e.g., "What's my rank in my country?"):
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "country",
|
||||
"operator": "eq",
|
||||
"value": "USA"
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
}
|
||||
],
|
||||
"sort": [
|
||||
{
|
||||
"column": "score",
|
||||
"direction": "desc"
|
||||
}
|
||||
],
|
||||
"fetch_row_number": "12345"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 12345,
|
||||
"name": "Bob Johnson",
|
||||
"country": "USA",
|
||||
"score": 7200,
|
||||
"status": "active"
|
||||
},
|
||||
"metadata": {
|
||||
"total": 2500,
|
||||
"count": 1,
|
||||
"filtered": 2500,
|
||||
"row_number": 156
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Bob is ranked #156 out of 2,500 active USA users. Only Bob's record is returned.
|
||||
|
||||
### 2. RowNumber Field - Auto-Number All Records
|
||||
|
||||
If your model has a `RowNumber int64` field, restheadspec will automatically populate it for paginated results.
|
||||
|
||||
**Model Definition:**
|
||||
```go
|
||||
type Player struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Score int64 `json:"score"`
|
||||
RowNumber int64 `json:"row_number"` // Will be auto-populated
|
||||
}
|
||||
```
|
||||
|
||||
**Request (with pagination):**
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"sort": [{"column": "score", "direction": "desc"}],
|
||||
"limit": 10,
|
||||
"offset": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response - RowNumber automatically set:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 456,
|
||||
"name": "Player21",
|
||||
"score": 8900,
|
||||
"row_number": 21
|
||||
},
|
||||
{
|
||||
"id": 789,
|
||||
"name": "Player22",
|
||||
"score": 8850,
|
||||
"row_number": 22
|
||||
},
|
||||
{
|
||||
"id": 123,
|
||||
"name": "Player23",
|
||||
"score": 8800,
|
||||
"row_number": 23
|
||||
}
|
||||
// ... records 24-30 ...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
- `row_number = offset + index + 1` (1-based)
|
||||
- With offset=20, first record gets row_number=21
|
||||
- With offset=20, second record gets row_number=22
|
||||
- Perfect for displaying "Rank" in paginated tables
|
||||
|
||||
**Use Case:** Displaying leaderboards with rank numbers:
|
||||
```
|
||||
Rank | Player | Score
|
||||
-----|-----------|-------
|
||||
21 | Player21 | 8900
|
||||
22 | Player22 | 8850
|
||||
23 | Player23 | 8800
|
||||
```
|
||||
|
||||
**Note:** This feature is available in all three packages: resolvespec, restheadspec, and websocketspec.
|
||||
|
||||
### When to Use Each Feature
|
||||
|
||||
| Feature | Use Case | Returns | Performance |
|
||||
|---------|----------|---------|-------------|
|
||||
| `fetch_row_number` | "What's my rank?" | 1 record with position | Fast - 1 record |
|
||||
| `RowNumber` field | "Show top 10 with ranks" | Many records numbered | Fast - simple math |
|
||||
|
||||
**Combined Example - Full Leaderboard UI:**
|
||||
|
||||
```javascript
|
||||
// Request 1: Get current user's rank
|
||||
const userRank = await api.read({
|
||||
fetch_row_number: currentUserId,
|
||||
sort: [{column: "score", direction: "desc"}]
|
||||
});
|
||||
// Returns: {id: 123, name: "You", score: 7500, row_number: 156}
|
||||
|
||||
// Request 2: Get top 10 with rank numbers
|
||||
const top10 = await api.read({
|
||||
sort: [{column: "score", direction: "desc"}],
|
||||
limit: 10,
|
||||
offset: 0
|
||||
});
|
||||
// Returns: [{row_number: 1, ...}, {row_number: 2, ...}, ...]
|
||||
|
||||
// Display:
|
||||
// "Your Rank: #156"
|
||||
// "Top Players:"
|
||||
// "#1 - Alice - 9999"
|
||||
// "#2 - Bob - 9876"
|
||||
// ...
|
||||
```
|
||||
|
||||
## Complete Example: Advanced Query
|
||||
|
||||
Combine all features for a complex query:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"columns": ["id", "name", "email", "score", "status"],
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "trial",
|
||||
"logic_operator": "OR"
|
||||
},
|
||||
{
|
||||
"column": "score",
|
||||
"operator": "gte",
|
||||
"value": 100
|
||||
}
|
||||
],
|
||||
"customOperators": [
|
||||
{
|
||||
"name": "recent_activity",
|
||||
"sql": "last_login > NOW() - INTERVAL '7 days'"
|
||||
},
|
||||
{
|
||||
"name": "verified_email",
|
||||
"sql": "email_verified = true"
|
||||
}
|
||||
],
|
||||
"sort": [
|
||||
{
|
||||
"column": "score",
|
||||
"direction": "desc"
|
||||
},
|
||||
{
|
||||
"column": "created_at",
|
||||
"direction": "asc"
|
||||
}
|
||||
],
|
||||
"fetch_row_number": "12345",
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This query:
|
||||
- Selects specific columns
|
||||
- Filters for users with status "active" OR "trial"
|
||||
- AND score >= 100
|
||||
- Applies custom SQL conditions for recent activity and verified emails
|
||||
- Sorts by score (descending) then creation date (ascending)
|
||||
- Returns the row number of user "12345" in this filtered/sorted set
|
||||
- Returns 50 records starting from the first one
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Leaderboards - Get Current User's Rank
|
||||
|
||||
Get the current user's position and data (returns only their record):
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "game_id",
|
||||
"operator": "eq",
|
||||
"value": "game123"
|
||||
}
|
||||
],
|
||||
"sort": [
|
||||
{
|
||||
"column": "score",
|
||||
"direction": "desc"
|
||||
}
|
||||
],
|
||||
"fetch_row_number": "current_user_id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tip:** For full leaderboards, make two requests:
|
||||
1. One with `fetch_row_number` to get user's rank
|
||||
2. One with `limit` and `offset` to get top players list
|
||||
|
||||
### 2. Multi-Status Search
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "order_status",
|
||||
"operator": "eq",
|
||||
"value": "pending"
|
||||
},
|
||||
{
|
||||
"column": "order_status",
|
||||
"operator": "eq",
|
||||
"value": "processing",
|
||||
"logic_operator": "OR"
|
||||
},
|
||||
{
|
||||
"column": "order_status",
|
||||
"operator": "eq",
|
||||
"value": "shipped",
|
||||
"logic_operator": "OR"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Advanced Date Filtering
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"customOperators": [
|
||||
{
|
||||
"name": "this_month",
|
||||
"sql": "created_at >= DATE_TRUNC('month', CURRENT_DATE)"
|
||||
},
|
||||
{
|
||||
"name": "business_hours",
|
||||
"sql": "EXTRACT(HOUR FROM created_at) BETWEEN 9 AND 17"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Warning:** Custom operators allow raw SQL, which can be a security risk if not properly handled:
|
||||
|
||||
1. **Never** directly interpolate user input into custom operator SQL
|
||||
2. Always validate and sanitize custom operator SQL on the backend
|
||||
3. Consider using a whitelist of allowed custom operators
|
||||
4. Use prepared statements or parameterized queries when possible
|
||||
5. Implement proper authorization checks before executing queries
|
||||
|
||||
Example of safe custom operator handling in Go:
|
||||
|
||||
```go
|
||||
// Whitelist of allowed custom operators
|
||||
allowedOperators := map[string]string{
|
||||
"recent_week": "created_at > NOW() - INTERVAL '7 days'",
|
||||
"active_users": "status = 'active' AND last_login > NOW() - INTERVAL '30 days'",
|
||||
"premium_only": "subscription_level = 'premium'",
|
||||
}
|
||||
|
||||
// Validate custom operators from request
|
||||
for _, op := range req.Options.CustomOperators {
|
||||
if sql, ok := allowedOperators[op.Name]; ok {
|
||||
op.SQL = sql // Use whitelisted SQL
|
||||
} else {
|
||||
return errors.New("custom operator not allowed: " + op.Name)
|
||||
}
|
||||
}
|
||||
```
|
||||
+853
@@ -0,0 +1,853 @@
|
||||
# ResolveSpec - Body-Based REST API
|
||||
|
||||
ResolveSpec provides a REST API where query options are passed in the JSON request body. This approach offers GraphQL-like flexibility while maintaining RESTful principles, making it ideal for complex queries and operations.
|
||||
|
||||
## Features
|
||||
|
||||
* **Body-Based Querying**: All query options passed via JSON request body
|
||||
* **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations
|
||||
* **Cursor Pagination**: Efficient cursor-based pagination with complex sorting
|
||||
* **Offset Pagination**: Traditional limit/offset pagination support
|
||||
* **Advanced Filtering**: Multiple operators, AND/OR logic, and custom SQL
|
||||
* **Relationship Preloading**: Load related entities with custom column selection and filters
|
||||
* **Recursive CRUD**: Automatically handle nested object graphs with foreign key resolution
|
||||
* **Computed Columns**: Define virtual columns with SQL expressions
|
||||
* **Database-Agnostic**: Works with GORM, Bun, or custom database adapters
|
||||
* **Router-Agnostic**: Integrates with any HTTP router through standard interfaces
|
||||
* **Type-Safe**: Strong type validation and conversion
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Setup with GORM
|
||||
|
||||
```go
|
||||
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||
import "github.com/gorilla/mux"
|
||||
|
||||
// Create handler
|
||||
handler := resolvespec.NewHandlerWithGORM(db)
|
||||
|
||||
// IMPORTANT: Register models BEFORE setting up routes
|
||||
handler.registry.RegisterModel("core.users", &User{})
|
||||
handler.registry.RegisterModel("core.posts", &Post{})
|
||||
|
||||
// Setup routes
|
||||
router := mux.NewRouter()
|
||||
resolvespec.SetupMuxRoutes(router, handler, nil)
|
||||
|
||||
// Start server
|
||||
http.ListenAndServe(":8080", router)
|
||||
```
|
||||
|
||||
### Setup with Bun ORM
|
||||
|
||||
```go
|
||||
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||
import "github.com/uptrace/bun"
|
||||
|
||||
// Create handler with Bun
|
||||
handler := resolvespec.NewHandlerWithBun(bunDB)
|
||||
|
||||
// Register models
|
||||
handler.registry.RegisterModel("core.users", &User{})
|
||||
|
||||
// Setup routes (same as GORM)
|
||||
router := mux.NewRouter()
|
||||
resolvespec.SetupMuxRoutes(router, handler, nil)
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Read Request
|
||||
|
||||
```http
|
||||
POST /core/users HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
### With Preloading
|
||||
|
||||
```http
|
||||
POST /core/users HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
## Request Structure
|
||||
|
||||
### Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read|create|update|delete",
|
||||
"data": {
|
||||
// For create/update operations
|
||||
},
|
||||
"options": {
|
||||
"columns": [...],
|
||||
"preload": [...],
|
||||
"filters": [...],
|
||||
"sort": [...],
|
||||
"limit": number,
|
||||
"offset": number,
|
||||
"cursor_forward": "string",
|
||||
"cursor_backward": "string",
|
||||
"customOperators": [...],
|
||||
"computedColumns": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation | Description | Requires Data | Requires ID |
|
||||
|-----------|-------------|---------------|-------------|
|
||||
| `read` | Fetch records | No | Optional (single record) |
|
||||
| `create` | Create new record(s) | Yes | No |
|
||||
| `update` | Update existing record(s) | Yes | Yes (in URL) |
|
||||
| `delete` | Delete record(s) | No | Yes (in URL) |
|
||||
|
||||
### Options Fields
|
||||
|
||||
| Field | Type | Description | Example |
|
||||
|-------|------|-------------|---------|
|
||||
| `columns` | `[]string` | Columns to select | `["id", "name", "email"]` |
|
||||
| `preload` | `[]PreloadConfig` | Relations to load | See [Preloading](#preloading) |
|
||||
| `filters` | `[]Filter` | Filter conditions | See [Filtering](#filtering) |
|
||||
| `sort` | `[]Sort` | Sort criteria | `[{"column": "created_at", "direction": "desc"}]` |
|
||||
| `limit` | `int` | Max records to return | `50` |
|
||||
| `offset` | `int` | Number of records to skip | `100` |
|
||||
| `cursor_forward` | `string` | Cursor for next page | `"12345"` |
|
||||
| `cursor_backward` | `string` | Cursor for previous page | `"12300"` |
|
||||
| `customOperators` | `[]CustomOperator` | Custom SQL conditions | See [Custom Operators](#custom-operators) |
|
||||
| `computedColumns` | `[]ComputedColumn` | Virtual columns | See [Computed Columns](#computed-columns) |
|
||||
|
||||
## Filtering
|
||||
|
||||
### Available Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `eq` | Equal | `{"column": "status", "operator": "eq", "value": "active"}` |
|
||||
| `neq` | Not Equal | `{"column": "status", "operator": "neq", "value": "deleted"}` |
|
||||
| `gt` | Greater Than | `{"column": "age", "operator": "gt", "value": 18}` |
|
||||
| `gte` | Greater Than or Equal | `{"column": "age", "operator": "gte", "value": 18}` |
|
||||
| `lt` | Less Than | `{"column": "price", "operator": "lt", "value": 100}` |
|
||||
| `lte` | Less Than or Equal | `{"column": "price", "operator": "lte", "value": 100}` |
|
||||
| `like` | LIKE pattern | `{"column": "name", "operator": "like", "value": "%john%"}` |
|
||||
| `ilike` | Case-insensitive LIKE | `{"column": "email", "operator": "ilike", "value": "%@example.com"}` |
|
||||
| `in` | IN clause | `{"column": "status", "operator": "in", "value": ["active", "pending"]}` |
|
||||
| `contains` | Contains string | `{"column": "description", "operator": "contains", "value": "important"}` |
|
||||
| `startswith` | Starts with string | `{"column": "name", "operator": "startswith", "value": "John"}` |
|
||||
| `endswith` | Ends with string | `{"column": "email", "operator": "endswith", "value": "@example.com"}` |
|
||||
| `between` | Between (exclusive) | `{"column": "age", "operator": "between", "value": [18, 65]}` |
|
||||
| `betweeninclusive` | Between (inclusive) | `{"column": "price", "operator": "betweeninclusive", "value": [10, 100]}` |
|
||||
| `empty` | IS NULL or empty | `{"column": "deleted_at", "operator": "empty"}` |
|
||||
| `notempty` | IS NOT NULL | `{"column": "email", "operator": "notempty"}` |
|
||||
|
||||
### Complex Filtering Example
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"column": "age",
|
||||
"operator": "gte",
|
||||
"value": 18
|
||||
},
|
||||
{
|
||||
"column": "email",
|
||||
"operator": "ilike",
|
||||
"value": "%@company.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OR Logic in Filters (SearchOr)
|
||||
|
||||
Use the `logic_operator` field to combine filters with OR logic instead of the default AND:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "pending",
|
||||
"logic_operator": "OR"
|
||||
},
|
||||
{
|
||||
"column": "priority",
|
||||
"operator": "eq",
|
||||
"value": "high",
|
||||
"logic_operator": "OR"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will produce: `WHERE (status = 'active' OR status = 'pending' OR priority = 'high')`
|
||||
|
||||
**Important:** Consecutive OR filters are automatically grouped together with parentheses to ensure proper query logic.
|
||||
|
||||
#### Mixing AND and OR
|
||||
|
||||
Consecutive OR filters are grouped, then combined with AND filters:
|
||||
|
||||
```json
|
||||
{
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "pending",
|
||||
"logic_operator": "OR"
|
||||
},
|
||||
{
|
||||
"column": "age",
|
||||
"operator": "gte",
|
||||
"value": 18
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Produces: `WHERE (status = 'active' OR status = 'pending') AND age >= 18`
|
||||
|
||||
This grouping ensures OR conditions don't interfere with other AND conditions in the query.
|
||||
|
||||
### Custom Operators
|
||||
|
||||
Add custom SQL conditions when needed:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"customOperators": [
|
||||
{
|
||||
"name": "email_domain_filter",
|
||||
"sql": "LOWER(email) LIKE '%@example.com'"
|
||||
},
|
||||
{
|
||||
"name": "recent_records",
|
||||
"sql": "created_at > NOW() - INTERVAL '7 days'"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Custom operators are applied as additional WHERE conditions to your query.
|
||||
|
||||
### Fetch Row Number
|
||||
|
||||
Get the row number (position) of a specific record in the filtered and sorted result set. **When `fetch_row_number` is specified, only that specific record is returned** (not all records).
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "active"
|
||||
}
|
||||
],
|
||||
"sort": [
|
||||
{
|
||||
"column": "score",
|
||||
"direction": "desc"
|
||||
}
|
||||
],
|
||||
"fetch_row_number": "12345"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response - Returns ONLY the specified record with its position:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 12345,
|
||||
"name": "John Doe",
|
||||
"score": 850,
|
||||
"status": "active"
|
||||
},
|
||||
"metadata": {
|
||||
"total": 1000,
|
||||
"count": 1,
|
||||
"filtered": 1000,
|
||||
"row_number": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use Case:** Perfect for "Show me this user and their ranking" - you get just that one user with their position in the leaderboard.
|
||||
|
||||
**Note:** This is different from the `RowNumber` field feature, which automatically numbers all records in a paginated response based on offset. That feature uses simple math (`offset + index + 1`), while `fetch_row_number` uses SQL window functions to calculate the actual position in a sorted/filtered set. To use the `RowNumber` field feature, simply add a `RowNumber int64` field to your model - it will be automatically populated with the row position based on pagination.
|
||||
|
||||
## Preloading
|
||||
|
||||
Load related entities with custom configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"columns": ["id", "name", "email"],
|
||||
"preload": [
|
||||
{
|
||||
"relation": "posts",
|
||||
"columns": ["id", "title", "created_at"],
|
||||
"filters": [
|
||||
{
|
||||
"column": "status",
|
||||
"operator": "eq",
|
||||
"value": "published"
|
||||
}
|
||||
],
|
||||
"sort": [
|
||||
{
|
||||
"column": "created_at",
|
||||
"direction": "desc"
|
||||
}
|
||||
],
|
||||
"limit": 5
|
||||
},
|
||||
{
|
||||
"relation": "profile",
|
||||
"columns": ["bio", "website"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cursor Pagination
|
||||
|
||||
Efficient pagination for large datasets:
|
||||
|
||||
### First Request (No Cursor)
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"sort": [
|
||||
{
|
||||
"column": "created_at",
|
||||
"direction": "desc"
|
||||
},
|
||||
{
|
||||
"column": "id",
|
||||
"direction": "asc"
|
||||
}
|
||||
],
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Next Page (Forward Cursor)
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"sort": [
|
||||
{
|
||||
"column": "created_at",
|
||||
"direction": "desc"
|
||||
},
|
||||
{
|
||||
"column": "id",
|
||||
"direction": "asc"
|
||||
}
|
||||
],
|
||||
"limit": 50,
|
||||
"cursor_forward": "12345"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Previous Page (Backward Cursor)
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"sort": [
|
||||
{
|
||||
"column": "created_at",
|
||||
"direction": "desc"
|
||||
},
|
||||
{
|
||||
"column": "id",
|
||||
"direction": "asc"
|
||||
}
|
||||
],
|
||||
"limit": 50,
|
||||
"cursor_backward": "12300"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits over offset pagination**:
|
||||
* Consistent results when data changes
|
||||
* Better performance for large offsets
|
||||
* Prevents "skipped" or duplicate records
|
||||
* Works with complex sort expressions
|
||||
|
||||
## Recursive CRUD Operations
|
||||
|
||||
Automatically handle nested object graphs with intelligent foreign key resolution.
|
||||
|
||||
### Creating Nested Objects
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "create",
|
||||
"data": {
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"posts": [
|
||||
{
|
||||
"title": "My First Post",
|
||||
"content": "Hello World",
|
||||
"tags": [
|
||||
{"name": "tech"},
|
||||
{"name": "programming"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Second Post",
|
||||
"content": "More content"
|
||||
}
|
||||
],
|
||||
"profile": {
|
||||
"bio": "Software Developer",
|
||||
"website": "https://example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-Record Operation Control with `_request`
|
||||
|
||||
Control individual operations for each nested record:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "update",
|
||||
"data": {
|
||||
"name": "John Updated",
|
||||
"posts": [
|
||||
{
|
||||
"_request": "insert",
|
||||
"title": "New Post",
|
||||
"content": "Fresh content"
|
||||
},
|
||||
{
|
||||
"_request": "update",
|
||||
"id": 456,
|
||||
"title": "Updated Post Title"
|
||||
},
|
||||
{
|
||||
"_request": "delete",
|
||||
"id": 789
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Supported `_request` values**:
|
||||
* `insert` - Create a new related record
|
||||
* `update` - Update an existing related record
|
||||
* `delete` - Delete a related record
|
||||
* `upsert` - Create if doesn't exist, update if exists
|
||||
|
||||
**How It Works**:
|
||||
1. Automatic foreign key resolution - parent IDs propagate to children
|
||||
2. Recursive processing - handles nested relationships at any depth
|
||||
3. Transaction safety - all operations execute atomically
|
||||
4. Relationship detection - automatically detects belongsTo, hasMany, hasOne, many2many
|
||||
5. Flexible operations - mix create, update, and delete in one request
|
||||
|
||||
## Computed Columns
|
||||
|
||||
Define virtual columns using SQL expressions:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"columns": ["id", "first_name", "last_name"],
|
||||
"computedColumns": [
|
||||
{
|
||||
"name": "full_name",
|
||||
"expression": "CONCAT(first_name, ' ', last_name)"
|
||||
},
|
||||
{
|
||||
"name": "age_years",
|
||||
"expression": "EXTRACT(YEAR FROM AGE(birth_date))"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Operators
|
||||
|
||||
Add custom SQL conditions when standard filters aren't sufficient:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "read",
|
||||
"options": {
|
||||
"customOperators": [
|
||||
{
|
||||
"name": "email_domain_filter",
|
||||
"sql": "LOWER(email) LIKE '%@example.com'"
|
||||
},
|
||||
{
|
||||
"name": "recent_records",
|
||||
"sql": "created_at > NOW() - INTERVAL '7 days'"
|
||||
},
|
||||
{
|
||||
"name": "complex_condition",
|
||||
"sql": "(status = 'active' AND score > 100) OR (status = 'pending' AND priority = 'high')"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Custom operators are applied as WHERE conditions. Make sure to properly escape and sanitize any user input to prevent SQL injection.
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
Register hooks for all CRUD operations:
|
||||
|
||||
```go
|
||||
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||
|
||||
// Create handler
|
||||
handler := resolvespec.NewHandlerWithGORM(db)
|
||||
|
||||
// Register a before-read hook (e.g., for authorization)
|
||||
handler.Hooks().Register(resolvespec.BeforeRead, func(ctx *resolvespec.HookContext) error {
|
||||
// Check permissions
|
||||
if !userHasPermission(ctx.Context, ctx.Entity) {
|
||||
return fmt.Errorf("unauthorized access to %s", ctx.Entity)
|
||||
}
|
||||
|
||||
// Modify query options
|
||||
if ctx.Options.Limit == nil || *ctx.Options.Limit > 100 {
|
||||
ctx.Options.Limit = ptr(100) // Enforce max limit
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// Register an after-read hook (e.g., for data transformation)
|
||||
handler.Hooks().Register(resolvespec.AfterRead, func(ctx *resolvespec.HookContext) error {
|
||||
// Transform or filter results
|
||||
if users, ok := ctx.Result.([]User); ok {
|
||||
for i := range users {
|
||||
users[i].Email = maskEmail(users[i].Email)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Register a before-create hook (e.g., for validation)
|
||||
handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookContext) error {
|
||||
// Validate data
|
||||
if user, ok := ctx.Data.(*User); ok {
|
||||
if user.Email == "" {
|
||||
return fmt.Errorf("email is required")
|
||||
}
|
||||
// Add timestamps
|
||||
user.CreatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
**Available Hook Types**:
|
||||
* `BeforeHandle` — fires after model resolution, before operation dispatch (auth checks)
|
||||
* `BeforeRead`, `AfterRead`
|
||||
* `BeforeCreate`, `AfterCreate`
|
||||
* `BeforeUpdate`, `AfterUpdate`
|
||||
* `BeforeDelete`, `AfterDelete`
|
||||
|
||||
**HookContext** provides:
|
||||
* `Context`: Request context
|
||||
* `Handler`: Access to handler, database, and registry
|
||||
* `Schema`, `Entity`, `TableName`: Request info
|
||||
* `Model`: The registered model type
|
||||
* `Operation`: Current operation string (`"read"`, `"create"`, `"update"`, `"delete"`)
|
||||
* `Options`: Parsed request options (filters, sorting, etc.)
|
||||
* `ID`: Record ID (for single-record operations)
|
||||
* `Data`: Request data (for create/update)
|
||||
* `Result`: Operation result (for after hooks)
|
||||
* `Writer`: Response writer (allows hooks to modify response)
|
||||
* `Abort`, `AbortMessage`, `AbortCode`: Set in hook to abort with an error response
|
||||
|
||||
## Model Registration
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Posts []Post `json:"posts,omitempty" gorm:"foreignKey:UserID"`
|
||||
Profile *Profile `json:"profile,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:post_tags"`
|
||||
}
|
||||
|
||||
// Schema.Table format
|
||||
handler.registry.RegisterModel("core.users", &User{})
|
||||
handler.registry.RegisterModel("core.posts", &Post{})
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||
"github.com/gorilla/mux"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
Posts []Post `json:"posts,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Connect to database
|
||||
db, err := gorm.Open(postgres.Open("your-connection-string"), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create handler
|
||||
handler := resolvespec.NewHandlerWithGORM(db)
|
||||
|
||||
// Register models
|
||||
handler.registry.RegisterModel("core.users", &User{})
|
||||
handler.registry.RegisterModel("core.posts", &Post{})
|
||||
|
||||
// Add hooks
|
||||
handler.Hooks().Register(resolvespec.BeforeRead, func(ctx *resolvespec.HookContext) error {
|
||||
log.Printf("Reading %s", ctx.Entity)
|
||||
return nil
|
||||
})
|
||||
|
||||
// Setup routes
|
||||
router := mux.NewRouter()
|
||||
resolvespec.SetupMuxRoutes(router, handler, nil)
|
||||
|
||||
// Start server
|
||||
log.Println("Server starting on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", router))
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
ResolveSpec is designed for testability:
|
||||
|
||||
```go
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserRead(t *testing.T) {
|
||||
handler := resolvespec.NewHandlerWithGORM(testDB)
|
||||
handler.registry.RegisterModel("core.users", &User{})
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"operation": "read",
|
||||
"options": map[string]interface{}{
|
||||
"columns": []string{"id", "name"},
|
||||
"limit": 10,
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/core/users", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// Test your handler...
|
||||
}
|
||||
```
|
||||
|
||||
## Router Integration
|
||||
|
||||
### Gorilla Mux
|
||||
|
||||
```go
|
||||
router := mux.NewRouter()
|
||||
resolvespec.SetupMuxRoutes(router, handler, nil)
|
||||
```
|
||||
|
||||
### BunRouter
|
||||
|
||||
```go
|
||||
router := bunrouter.New()
|
||||
resolvespec.SetupBunRouterWithResolveSpec(router, handler)
|
||||
```
|
||||
|
||||
### Custom Routers
|
||||
|
||||
```go
|
||||
// Implement custom integration using common.Request and common.ResponseWriter
|
||||
router.POST("/:schema/:entity", func(w http.ResponseWriter, r *http.Request) {
|
||||
params := extractParams(r) // Your param extraction logic
|
||||
reqAdapter := router.NewHTTPRequest(r)
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
handler.Handle(respAdapter, reqAdapter, params)
|
||||
})
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
### Success Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [...],
|
||||
"metadata": {
|
||||
"total": 100,
|
||||
"filtered": 50,
|
||||
"limit": 10,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "validation_error",
|
||||
"message": "Invalid request",
|
||||
"details": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
* [Main README](../../README.md) - ResolveSpec overview
|
||||
* [RestHeadSpec Package](../restheadspec/README.md) - Header-based API
|
||||
* [StaticWeb Package](../server/staticweb/README.md) - Static file server
|
||||
|
||||
## License
|
||||
|
||||
This package is part of ResolveSpec and is licensed under the MIT License.
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
### Success Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [...],
|
||||
"metadata": {
|
||||
"total": 100,
|
||||
"filtered": 50,
|
||||
"limit": 10,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "validation_error",
|
||||
"message": "Invalid request",
|
||||
"details": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
* [Main README](../../README.md) - ResolveSpec overview
|
||||
* [RestHeadSpec Package](../restheadspec/README.md) - Header-based API
|
||||
* [StaticWeb Package](../server/staticweb/README.md) - Static file server
|
||||
|
||||
## License
|
||||
|
||||
This package is part of ResolveSpec and is licensed under the MIT License.
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/cache"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
)
|
||||
|
||||
// queryCacheKey represents the components used to build a cache key for query total count
|
||||
type queryCacheKey struct {
|
||||
TableName string `json:"table_name"`
|
||||
Filters []common.FilterOption `json:"filters"`
|
||||
Sort []common.SortOption `json:"sort"`
|
||||
CustomSQLWhere string `json:"custom_sql_where,omitempty"`
|
||||
CustomSQLOr string `json:"custom_sql_or,omitempty"`
|
||||
CursorForward string `json:"cursor_forward,omitempty"`
|
||||
CursorBackward string `json:"cursor_backward,omitempty"`
|
||||
}
|
||||
|
||||
// cachedTotal represents a cached total count
|
||||
type cachedTotal struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// buildQueryCacheKey builds a cache key from query parameters for total count caching
|
||||
func buildQueryCacheKey(tableName string, filters []common.FilterOption, sort []common.SortOption, customWhere, customOr string) string {
|
||||
key := queryCacheKey{
|
||||
TableName: tableName,
|
||||
Filters: filters,
|
||||
Sort: sort,
|
||||
CustomSQLWhere: customWhere,
|
||||
CustomSQLOr: customOr,
|
||||
}
|
||||
|
||||
// Serialize to JSON for consistent hashing
|
||||
jsonData, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
// Fallback to simple string concatenation if JSON fails
|
||||
return hashString(fmt.Sprintf("%s_%v_%v_%s_%s", tableName, filters, sort, customWhere, customOr))
|
||||
}
|
||||
|
||||
return hashString(string(jsonData))
|
||||
}
|
||||
|
||||
// buildExtendedQueryCacheKey builds a cache key for extended query options with cursor pagination
|
||||
func buildExtendedQueryCacheKey(tableName string, filters []common.FilterOption, sort []common.SortOption,
|
||||
customWhere, customOr string, cursorFwd, cursorBwd string) string {
|
||||
|
||||
key := queryCacheKey{
|
||||
TableName: tableName,
|
||||
Filters: filters,
|
||||
Sort: sort,
|
||||
CustomSQLWhere: customWhere,
|
||||
CustomSQLOr: customOr,
|
||||
CursorForward: cursorFwd,
|
||||
CursorBackward: cursorBwd,
|
||||
}
|
||||
|
||||
// Serialize to JSON for consistent hashing
|
||||
jsonData, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
// Fallback to simple string concatenation if JSON fails
|
||||
return hashString(fmt.Sprintf("%s_%v_%v_%s_%s_%s_%s",
|
||||
tableName, filters, sort, customWhere, customOr, cursorFwd, cursorBwd))
|
||||
}
|
||||
|
||||
return hashString(string(jsonData))
|
||||
}
|
||||
|
||||
// hashString computes SHA256 hash of a string
|
||||
func hashString(s string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(s))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// getQueryTotalCacheKey returns a formatted cache key for storing/retrieving total count
|
||||
func getQueryTotalCacheKey(hash string) string {
|
||||
return fmt.Sprintf("query_total:%s", hash)
|
||||
}
|
||||
|
||||
// buildCacheTags creates cache tags from schema and table name
|
||||
func buildCacheTags(schema, tableName string) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("schema:%s", strings.ToLower(schema)),
|
||||
fmt.Sprintf("table:%s", strings.ToLower(tableName)),
|
||||
}
|
||||
}
|
||||
|
||||
// setQueryTotalCache stores a query total in the cache with schema and table tags
|
||||
func setQueryTotalCache(ctx context.Context, cacheKey string, total int, schema, tableName string, ttl time.Duration) error {
|
||||
c := cache.GetDefaultCache()
|
||||
cacheData := cachedTotal{Total: total}
|
||||
tags := buildCacheTags(schema, tableName)
|
||||
|
||||
return c.SetWithTags(ctx, cacheKey, cacheData, ttl, tags)
|
||||
}
|
||||
|
||||
// invalidateCacheForTags removes all cached items matching the specified tags
|
||||
func invalidateCacheForTags(ctx context.Context, tags []string) error {
|
||||
c := cache.GetDefaultCache()
|
||||
|
||||
// Invalidate for each tag
|
||||
for _, tag := range tags {
|
||||
if err := c.DeleteByTag(ctx, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Context keys for request-scoped data
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
contextKeySchema contextKey = "schema"
|
||||
contextKeyEntity contextKey = "entity"
|
||||
contextKeyTableName contextKey = "tableName"
|
||||
contextKeyModel contextKey = "model"
|
||||
contextKeyModelPtr contextKey = "modelPtr"
|
||||
)
|
||||
|
||||
// WithSchema adds schema to context
|
||||
func WithSchema(ctx context.Context, schema string) context.Context {
|
||||
return context.WithValue(ctx, contextKeySchema, schema)
|
||||
}
|
||||
|
||||
// GetSchema retrieves schema from context
|
||||
func GetSchema(ctx context.Context) string {
|
||||
if v := ctx.Value(contextKeySchema); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithEntity adds entity to context
|
||||
func WithEntity(ctx context.Context, entity string) context.Context {
|
||||
return context.WithValue(ctx, contextKeyEntity, entity)
|
||||
}
|
||||
|
||||
// GetEntity retrieves entity from context
|
||||
func GetEntity(ctx context.Context) string {
|
||||
if v := ctx.Value(contextKeyEntity); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithTableName adds table name to context
|
||||
func WithTableName(ctx context.Context, tableName string) context.Context {
|
||||
return context.WithValue(ctx, contextKeyTableName, tableName)
|
||||
}
|
||||
|
||||
// GetTableName retrieves table name from context
|
||||
func GetTableName(ctx context.Context) string {
|
||||
if v := ctx.Value(contextKeyTableName); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithModel adds model to context
|
||||
func WithModel(ctx context.Context, model interface{}) context.Context {
|
||||
return context.WithValue(ctx, contextKeyModel, model)
|
||||
}
|
||||
|
||||
// GetModel retrieves model from context
|
||||
func GetModel(ctx context.Context) interface{} {
|
||||
return ctx.Value(contextKeyModel)
|
||||
}
|
||||
|
||||
// WithModelPtr adds model pointer to context
|
||||
func WithModelPtr(ctx context.Context, modelPtr interface{}) context.Context {
|
||||
return context.WithValue(ctx, contextKeyModelPtr, modelPtr)
|
||||
}
|
||||
|
||||
// GetModelPtr retrieves model pointer from context
|
||||
func GetModelPtr(ctx context.Context) interface{} {
|
||||
return ctx.Value(contextKeyModelPtr)
|
||||
}
|
||||
|
||||
// WithRequestData adds all request-scoped data to context at once
|
||||
func WithRequestData(ctx context.Context, schema, entity, tableName string, model, modelPtr interface{}) context.Context {
|
||||
ctx = WithSchema(ctx, schema)
|
||||
ctx = WithEntity(ctx, entity)
|
||||
ctx = WithTableName(ctx, tableName)
|
||||
ctx = WithModel(ctx, model)
|
||||
ctx = WithModelPtr(ctx, modelPtr)
|
||||
return ctx
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||
)
|
||||
|
||||
// CursorDirection defines pagination direction
|
||||
type CursorDirection int
|
||||
|
||||
const (
|
||||
CursorForward CursorDirection = 1
|
||||
CursorBackward CursorDirection = -1
|
||||
)
|
||||
|
||||
// GetCursorFilter generates a SQL `EXISTS` subquery for cursor-based pagination.
|
||||
// It uses the current request's sort and cursor values.
|
||||
//
|
||||
// Parameters:
|
||||
// - tableName: name of the main table (e.g. "posts")
|
||||
// - pkName: primary key column (e.g. "id")
|
||||
// - modelColumns: optional list of valid main-table columns (for validation). Pass nil to skip.
|
||||
// - options: the request options containing sort and cursor information
|
||||
// - expandJoins: optional map[alias]string of JOIN clauses for join-column sort support
|
||||
//
|
||||
// Returns SQL snippet to embed in WHERE clause.
|
||||
func GetCursorFilter(
|
||||
tableName string,
|
||||
pkName string,
|
||||
modelColumns []string,
|
||||
options common.RequestOptions,
|
||||
expandJoins map[string]string,
|
||||
) (string, error) {
|
||||
// Separate schema prefix from bare table name
|
||||
fullTableName := tableName
|
||||
if strings.Contains(tableName, ".") {
|
||||
tableName = strings.SplitN(tableName, ".", 2)[1]
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// 1. Determine active cursor
|
||||
// --------------------------------------------------------------------- //
|
||||
cursorID, direction := getActiveCursor(options)
|
||||
if cursorID == "" {
|
||||
return "", fmt.Errorf("no cursor provided for table %s", tableName)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// 2. Extract sort columns
|
||||
// --------------------------------------------------------------------- //
|
||||
sortItems := options.Sort
|
||||
if len(sortItems) == 0 {
|
||||
return "", fmt.Errorf("no sort columns defined")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// 3. Prepare
|
||||
// --------------------------------------------------------------------- //
|
||||
var whereClauses []string
|
||||
joinSQL := ""
|
||||
reverse := direction < 0
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// 4. Process each sort column
|
||||
// --------------------------------------------------------------------- //
|
||||
for _, s := range sortItems {
|
||||
col := strings.Trim(strings.TrimSpace(s.Column), "()")
|
||||
if col == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse: "created_at", "user.name", "fn.sortorder", etc.
|
||||
parts := strings.Split(col, ".")
|
||||
field := strings.TrimSpace(parts[len(parts)-1])
|
||||
prefix := strings.Join(parts[:len(parts)-1], ".")
|
||||
|
||||
// Direction from struct
|
||||
desc := strings.EqualFold(s.Direction, "desc")
|
||||
|
||||
if reverse {
|
||||
desc = !desc
|
||||
}
|
||||
|
||||
// Resolve column
|
||||
cursorCol, targetCol, isJoin, err := resolveColumn(
|
||||
field, prefix, tableName, modelColumns,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("Skipping invalid sort column %q: %v", col, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle joins
|
||||
if isJoin {
|
||||
if expandJoins != nil {
|
||||
if joinClause, ok := expandJoins[prefix]; ok {
|
||||
jSQL, cRef := rewriteJoin(joinClause, tableName, prefix)
|
||||
joinSQL = jSQL
|
||||
cursorCol = cRef + "." + field
|
||||
targetCol = prefix + "." + field
|
||||
}
|
||||
}
|
||||
if cursorCol == "" {
|
||||
logger.Warn("Skipping cursor sort column %q: join alias %q not in expandJoins", col, prefix)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Build inequality
|
||||
op := "<"
|
||||
if desc {
|
||||
op = ">"
|
||||
}
|
||||
whereClauses = append(whereClauses, fmt.Sprintf("%s %s %s", cursorCol, op, targetCol))
|
||||
}
|
||||
|
||||
if len(whereClauses) == 0 {
|
||||
return "", fmt.Errorf("no valid sort columns after filtering")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// 5. Build priority OR-AND chain
|
||||
// --------------------------------------------------------------------- //
|
||||
orSQL := buildPriorityChain(whereClauses)
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// 6. Final EXISTS subquery
|
||||
// --------------------------------------------------------------------- //
|
||||
query := fmt.Sprintf(`EXISTS (
|
||||
SELECT 1
|
||||
FROM %s cursor_select
|
||||
%s
|
||||
WHERE cursor_select.%s = %s
|
||||
AND (%s)
|
||||
)`,
|
||||
fullTableName,
|
||||
joinSQL,
|
||||
pkName,
|
||||
cursorID,
|
||||
orSQL,
|
||||
)
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- //
|
||||
// Helper: get active cursor (forward or backward)
|
||||
func getActiveCursor(options common.RequestOptions) (id string, direction CursorDirection) {
|
||||
if options.CursorForward != "" {
|
||||
return options.CursorForward, CursorForward
|
||||
}
|
||||
if options.CursorBackward != "" {
|
||||
return options.CursorBackward, CursorBackward
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// Helper: resolve column (main table or join)
|
||||
func resolveColumn(
|
||||
field, prefix, tableName string,
|
||||
modelColumns []string,
|
||||
) (cursorCol, targetCol string, isJoin bool, err error) {
|
||||
|
||||
// JSON field
|
||||
if strings.Contains(field, "->") {
|
||||
return "cursor_select." + field, tableName + "." + field, false, nil
|
||||
}
|
||||
|
||||
// Main table column
|
||||
if modelColumns != nil {
|
||||
for _, col := range modelColumns {
|
||||
if strings.EqualFold(col, field) {
|
||||
return "cursor_select." + field, tableName + "." + field, false, nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No validation → allow all main-table fields
|
||||
return "cursor_select." + field, tableName + "." + field, false, nil
|
||||
}
|
||||
|
||||
// Joined column
|
||||
if prefix != "" && prefix != tableName {
|
||||
return "", "", true, nil
|
||||
}
|
||||
|
||||
return "", "", false, fmt.Errorf("invalid column: %s", field)
|
||||
}
|
||||
|
||||
// Helper: rewrite JOIN clause for cursor subquery
|
||||
func rewriteJoin(joinClause, mainTable, alias string) (joinSQL, cursorAlias string) {
|
||||
joinSQL = strings.ReplaceAll(joinClause, mainTable+".", "cursor_select.")
|
||||
cursorAlias = "cursor_select_" + alias
|
||||
joinSQL = strings.ReplaceAll(joinSQL, " "+alias+" ", " "+cursorAlias+" ")
|
||||
joinSQL = strings.ReplaceAll(joinSQL, " "+alias+".", " "+cursorAlias+".")
|
||||
return joinSQL, cursorAlias
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- //
|
||||
// Helper: build OR-AND priority chain
|
||||
func buildPriorityChain(clauses []string) string {
|
||||
var or []string
|
||||
for i := 0; i < len(clauses); i++ {
|
||||
and := strings.Join(clauses[:i+1], "\n AND ")
|
||||
or = append(or, "("+and+")")
|
||||
}
|
||||
return strings.Join(or, "\n OR ")
|
||||
}
|
||||
+2231
File diff suppressed because it is too large
Load Diff
+163
@@ -0,0 +1,163 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||
)
|
||||
|
||||
// HookType defines the type of hook to execute
|
||||
type HookType string
|
||||
|
||||
const (
|
||||
// BeforeHandle fires after model resolution, before operation dispatch.
|
||||
// Use this for auth checks that need model rules and user context simultaneously.
|
||||
BeforeHandle HookType = "before_handle"
|
||||
|
||||
// Read operation hooks
|
||||
BeforeRead HookType = "before_read"
|
||||
AfterRead HookType = "after_read"
|
||||
|
||||
// Create operation hooks
|
||||
BeforeCreate HookType = "before_create"
|
||||
AfterCreate HookType = "after_create"
|
||||
|
||||
// Update operation hooks
|
||||
BeforeUpdate HookType = "before_update"
|
||||
AfterUpdate HookType = "after_update"
|
||||
|
||||
// Delete operation hooks
|
||||
BeforeDelete HookType = "before_delete"
|
||||
AfterDelete HookType = "after_delete"
|
||||
|
||||
// Scan/Execute operation hooks (for query building)
|
||||
BeforeScan HookType = "before_scan"
|
||||
)
|
||||
|
||||
// HookContext contains all the data available to a hook
|
||||
type HookContext struct {
|
||||
Context context.Context
|
||||
Handler *Handler // Reference to the handler for accessing database, registry, etc.
|
||||
Schema string
|
||||
Entity string
|
||||
Model interface{}
|
||||
Options common.RequestOptions
|
||||
Writer common.ResponseWriter
|
||||
Request common.Request
|
||||
|
||||
// Operation being dispatched (e.g. "read", "create", "update", "delete")
|
||||
Operation string
|
||||
|
||||
// Operation-specific fields
|
||||
ID string
|
||||
Data interface{} // For create/update operations
|
||||
Result interface{} // For after hooks
|
||||
Error error // For after hooks
|
||||
|
||||
// Query chain - allows hooks to modify the query before execution
|
||||
Query common.SelectQuery
|
||||
|
||||
// Allow hooks to abort the operation
|
||||
Abort bool // If set to true, the operation will be aborted
|
||||
AbortMessage string // Message to return if aborted
|
||||
AbortCode int // HTTP status code if aborted
|
||||
|
||||
// Tx provides access to the database/transaction for executing additional SQL
|
||||
// This allows hooks to run custom queries in addition to the main Query chain
|
||||
Tx common.Database
|
||||
}
|
||||
|
||||
// HookFunc is the signature for hook functions
|
||||
// It receives a HookContext and can modify it or return an error
|
||||
// If an error is returned, the operation will be aborted
|
||||
type HookFunc func(*HookContext) error
|
||||
|
||||
// HookRegistry manages all registered hooks
|
||||
type HookRegistry struct {
|
||||
hooks map[HookType][]HookFunc
|
||||
}
|
||||
|
||||
// NewHookRegistry creates a new hook registry
|
||||
func NewHookRegistry() *HookRegistry {
|
||||
return &HookRegistry{
|
||||
hooks: make(map[HookType][]HookFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a new hook for the specified hook type
|
||||
func (r *HookRegistry) Register(hookType HookType, hook HookFunc) {
|
||||
if r.hooks == nil {
|
||||
r.hooks = make(map[HookType][]HookFunc)
|
||||
}
|
||||
r.hooks[hookType] = append(r.hooks[hookType], hook)
|
||||
logger.Info("Registered resolvespec hook for %s (total: %d)", hookType, len(r.hooks[hookType]))
|
||||
}
|
||||
|
||||
// RegisterMultiple registers a hook for multiple hook types
|
||||
func (r *HookRegistry) RegisterMultiple(hookTypes []HookType, hook HookFunc) {
|
||||
for _, hookType := range hookTypes {
|
||||
r.Register(hookType, hook)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs all hooks for the specified type in order
|
||||
// If any hook returns an error, execution stops and the error is returned
|
||||
func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
|
||||
hooks, exists := r.hooks[hookType]
|
||||
if !exists || len(hooks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("Executing %d resolvespec hook(s) for %s", len(hooks), hookType)
|
||||
|
||||
for i, hook := range hooks {
|
||||
if err := hook(ctx); err != nil {
|
||||
logger.Error("Resolvespec hook %d for %s failed: %v", i+1, hookType, err)
|
||||
return fmt.Errorf("hook execution failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if hook requested abort
|
||||
if ctx.Abort {
|
||||
logger.Warn("Resolvespec hook %d for %s requested abort: %s", i+1, hookType, ctx.AbortMessage)
|
||||
return fmt.Errorf("operation aborted by hook: %s", ctx.AbortMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear removes all hooks for the specified type
|
||||
func (r *HookRegistry) Clear(hookType HookType) {
|
||||
delete(r.hooks, hookType)
|
||||
logger.Info("Cleared all resolvespec hooks for %s", hookType)
|
||||
}
|
||||
|
||||
// ClearAll removes all registered hooks
|
||||
func (r *HookRegistry) ClearAll() {
|
||||
r.hooks = make(map[HookType][]HookFunc)
|
||||
logger.Info("Cleared all resolvespec hooks")
|
||||
}
|
||||
|
||||
// Count returns the number of hooks registered for a specific type
|
||||
func (r *HookRegistry) Count(hookType HookType) int {
|
||||
if hooks, exists := r.hooks[hookType]; exists {
|
||||
return len(hooks)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// HasHooks returns true if there are any hooks registered for the specified type
|
||||
func (r *HookRegistry) HasHooks(hookType HookType) bool {
|
||||
return r.Count(hookType) > 0
|
||||
}
|
||||
|
||||
// GetAllHookTypes returns all hook types that have registered hooks
|
||||
func (r *HookRegistry) GetAllHookTypes() []HookType {
|
||||
types := make([]HookType, 0, len(r.hooks))
|
||||
for hookType := range r.hooks {
|
||||
types = append(types, hookType)
|
||||
}
|
||||
return types
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package resolvespec
|
||||
|
||||
// Legacy interfaces for backward compatibility
|
||||
type GormTableNameInterface interface {
|
||||
TableName() string
|
||||
}
|
||||
|
||||
type GormTableSchemaInterface interface {
|
||||
TableSchema() string
|
||||
}
|
||||
|
||||
type GormTableCRUDRequest struct {
|
||||
Request *string `json:"_request"`
|
||||
}
|
||||
|
||||
func (r *GormTableCRUDRequest) SetRequest(request string) {
|
||||
r.Request = &request
|
||||
}
|
||||
|
||||
func (r GormTableCRUDRequest) GetRequest() string {
|
||||
return *r.Request
|
||||
}
|
||||
|
||||
// New interfaces that replace the legacy ones above
|
||||
// These are now defined in database.go:
|
||||
// - TableNameProvider (replaces GormTableNameInterface)
|
||||
// - SchemaProvider (replaces GormTableSchemaInterface)
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bunrouter"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/router"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// MiddlewareFunc is a function that wraps an http.Handler with additional functionality
|
||||
type MiddlewareFunc func(http.Handler) http.Handler
|
||||
|
||||
// SetupMuxRoutes sets up routes for the ResolveSpec API with Mux
|
||||
// authMiddleware is optional - if provided, routes will be protected with the middleware
|
||||
// Example: SetupMuxRoutes(router, handler, func(h http.Handler) http.Handler { return security.NewAuthHandler(securityList, h) })
|
||||
func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware MiddlewareFunc) {
|
||||
// Add global /openapi route
|
||||
openAPIHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
corsConfig := common.DefaultCORSConfig()
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(r)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
|
||||
handler.HandleOpenAPI(respAdapter, reqAdapter)
|
||||
})
|
||||
muxRouter.Handle("/openapi", openAPIHandler).Methods("GET", "OPTIONS")
|
||||
|
||||
// Get all registered models from the registry
|
||||
allModels := handler.registry.GetAllModels()
|
||||
|
||||
// Loop through each registered model and create explicit routes
|
||||
for fullName := range allModels {
|
||||
// Parse the full name (e.g., "public.users" or just "users")
|
||||
schema, entity := parseModelName(fullName)
|
||||
|
||||
// Build the route paths
|
||||
entityPath := buildRoutePath(schema, entity)
|
||||
entityWithIDPath := buildRoutePath(schema, entity) + "/{id}"
|
||||
|
||||
// Create handler functions for this specific entity
|
||||
var postEntityHandler http.Handler = createMuxHandler(handler, schema, entity, "")
|
||||
var postEntityWithIDHandler http.Handler = createMuxHandler(handler, schema, entity, "id")
|
||||
var getEntityHandler http.Handler = createMuxGetHandler(handler, schema, entity, "")
|
||||
optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"})
|
||||
optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"POST", "OPTIONS"})
|
||||
|
||||
// Apply authentication middleware if provided
|
||||
if authMiddleware != nil {
|
||||
postEntityHandler = authMiddleware(postEntityHandler)
|
||||
postEntityWithIDHandler = authMiddleware(postEntityWithIDHandler)
|
||||
getEntityHandler = authMiddleware(getEntityHandler)
|
||||
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
|
||||
}
|
||||
|
||||
// Register routes for this entity
|
||||
muxRouter.Handle(entityPath, postEntityHandler).Methods("POST")
|
||||
muxRouter.Handle(entityWithIDPath, postEntityWithIDHandler).Methods("POST")
|
||||
muxRouter.Handle(entityPath, getEntityHandler).Methods("GET")
|
||||
muxRouter.Handle(entityPath, optionsEntityHandler).Methods("OPTIONS")
|
||||
muxRouter.Handle(entityWithIDPath, optionsEntityWithIDHandler).Methods("OPTIONS")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create Mux handler for a specific entity with CORS support
|
||||
func createMuxHandler(handler *Handler, schema, entity, idParam string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers
|
||||
corsConfig := common.DefaultCORSConfig()
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(r)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
|
||||
vars := make(map[string]string)
|
||||
vars["schema"] = schema
|
||||
vars["entity"] = entity
|
||||
if idParam != "" {
|
||||
vars["id"] = mux.Vars(r)[idParam]
|
||||
}
|
||||
|
||||
handler.Handle(respAdapter, reqAdapter, vars)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create Mux GET handler for a specific entity with CORS support
|
||||
func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers
|
||||
corsConfig := common.DefaultCORSConfig()
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(r)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
|
||||
vars := make(map[string]string)
|
||||
vars["schema"] = schema
|
||||
vars["entity"] = entity
|
||||
if idParam != "" {
|
||||
vars["id"] = mux.Vars(r)[idParam]
|
||||
}
|
||||
|
||||
handler.HandleGet(respAdapter, reqAdapter, vars)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create Mux OPTIONS handler that returns metadata
|
||||
func createMuxOptionsHandler(handler *Handler, schema, entity string, allowedMethods []string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers with the allowed methods for this route
|
||||
corsConfig := common.DefaultCORSConfig()
|
||||
corsConfig.AllowedMethods = allowedMethods
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(r)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
|
||||
// Return metadata in the OPTIONS response body
|
||||
vars := make(map[string]string)
|
||||
vars["schema"] = schema
|
||||
vars["entity"] = entity
|
||||
|
||||
handler.HandleGet(respAdapter, reqAdapter, vars)
|
||||
}
|
||||
}
|
||||
|
||||
// parseModelName parses a model name like "public.users" into schema and entity
|
||||
// If no schema is present, returns empty string for schema
|
||||
func parseModelName(fullName string) (schema, entity string) {
|
||||
parts := strings.Split(fullName, ".")
|
||||
if len(parts) == 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return "", fullName
|
||||
}
|
||||
|
||||
// buildRoutePath builds a route path from schema and entity
|
||||
// If schema is empty, returns just "/entity", otherwise "/{schema}/{entity}"
|
||||
func buildRoutePath(schema, entity string) string {
|
||||
if schema == "" {
|
||||
return "/" + entity
|
||||
}
|
||||
return "/" + schema + "/" + entity
|
||||
}
|
||||
|
||||
// Example usage functions for documentation:
|
||||
|
||||
// ExampleWithGORM shows how to use ResolveSpec with GORM
|
||||
func ExampleWithGORM(db *gorm.DB) {
|
||||
// Create handler using GORM
|
||||
handler := NewHandlerWithGORM(db)
|
||||
|
||||
// Setup router without authentication
|
||||
muxRouter := mux.NewRouter()
|
||||
SetupMuxRoutes(muxRouter, handler, nil)
|
||||
|
||||
// Register models
|
||||
// handler.RegisterModel("public", "users", &User{})
|
||||
|
||||
// To add authentication, pass a middleware function:
|
||||
// import "github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
// secList := security.NewSecurityList(myProvider)
|
||||
// authMiddleware := func(h http.Handler) http.Handler {
|
||||
// return security.NewAuthHandler(secList, h)
|
||||
// }
|
||||
// SetupMuxRoutes(muxRouter, handler, authMiddleware)
|
||||
}
|
||||
|
||||
// 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 without authentication
|
||||
muxRouter := mux.NewRouter()
|
||||
SetupMuxRoutes(muxRouter, handler, nil)
|
||||
}
|
||||
|
||||
// BunRouterHandler is an interface that both bunrouter.Router and bunrouter.Group implement
|
||||
type BunRouterHandler interface {
|
||||
Handle(method, path string, handler bunrouter.HandlerFunc)
|
||||
}
|
||||
|
||||
// wrapBunRouterHandler wraps a bunrouter handler with auth middleware if provided
|
||||
func wrapBunRouterHandler(handler bunrouter.HandlerFunc, authMiddleware MiddlewareFunc) bunrouter.HandlerFunc {
|
||||
if authMiddleware == nil {
|
||||
return handler
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
// Create an http.Handler that calls the bunrouter handler
|
||||
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Replace the embedded *http.Request with the middleware-enriched one
|
||||
// so that auth context (user ID, etc.) is visible to the handler.
|
||||
enrichedReq := req
|
||||
enrichedReq.Request = r
|
||||
_ = handler(w, enrichedReq)
|
||||
})
|
||||
|
||||
// Wrap with auth middleware and execute
|
||||
wrappedHandler := authMiddleware(httpHandler)
|
||||
wrappedHandler.ServeHTTP(w, req.Request)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetupBunRouterRoutes sets up bunrouter routes for the ResolveSpec API
|
||||
// Accepts bunrouter.Router or bunrouter.Group
|
||||
// authMiddleware is optional - if provided, routes will be protected with the middleware
|
||||
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler, authMiddleware MiddlewareFunc) {
|
||||
|
||||
// CORS config
|
||||
corsConfig := common.DefaultCORSConfig()
|
||||
|
||||
// Add global /openapi route
|
||||
r.Handle("GET", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(req.Request)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
handler.HandleOpenAPI(respAdapter, reqAdapter)
|
||||
return nil
|
||||
})
|
||||
|
||||
r.Handle("OPTIONS", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(req.Request)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
return nil
|
||||
})
|
||||
|
||||
// Get all registered models from the registry
|
||||
allModels := handler.registry.GetAllModels()
|
||||
|
||||
// Loop through each registered model and create explicit routes
|
||||
for fullName := range allModels {
|
||||
// Parse the full name (e.g., "public.users" or just "users")
|
||||
schema, entity := parseModelName(fullName)
|
||||
|
||||
// Build the route paths
|
||||
entityPath := buildRoutePath(schema, entity)
|
||||
entityWithIDPath := entityPath + "/:id"
|
||||
|
||||
// Create closure variables to capture current schema and entity
|
||||
currentSchema := schema
|
||||
currentEntity := entity
|
||||
|
||||
// POST route without ID
|
||||
postEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(req.Request)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
params := map[string]string{
|
||||
"schema": currentSchema,
|
||||
"entity": currentEntity,
|
||||
}
|
||||
|
||||
handler.Handle(respAdapter, reqAdapter, params)
|
||||
return nil
|
||||
}
|
||||
r.Handle("POST", entityPath, wrapBunRouterHandler(postEntityHandler, authMiddleware))
|
||||
|
||||
// POST route with ID
|
||||
postEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(req.Request)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
params := map[string]string{
|
||||
"schema": currentSchema,
|
||||
"entity": currentEntity,
|
||||
"id": req.Param("id"),
|
||||
}
|
||||
|
||||
handler.Handle(respAdapter, reqAdapter, params)
|
||||
return nil
|
||||
}
|
||||
r.Handle("POST", entityWithIDPath, wrapBunRouterHandler(postEntityWithIDHandler, authMiddleware))
|
||||
|
||||
// GET route without ID
|
||||
getEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(req.Request)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
params := map[string]string{
|
||||
"schema": currentSchema,
|
||||
"entity": currentEntity,
|
||||
}
|
||||
|
||||
handler.HandleGet(respAdapter, reqAdapter, params)
|
||||
return nil
|
||||
}
|
||||
r.Handle("GET", entityPath, wrapBunRouterHandler(getEntityHandler, authMiddleware))
|
||||
|
||||
// GET route with ID
|
||||
getEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(req.Request)
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
|
||||
params := map[string]string{
|
||||
"schema": currentSchema,
|
||||
"entity": currentEntity,
|
||||
"id": req.Param("id"),
|
||||
}
|
||||
|
||||
handler.HandleGet(respAdapter, reqAdapter, params)
|
||||
return nil
|
||||
}
|
||||
r.Handle("GET", entityWithIDPath, wrapBunRouterHandler(getEntityWithIDHandler, authMiddleware))
|
||||
|
||||
// OPTIONS route without ID (returns metadata)
|
||||
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
|
||||
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(req.Request)
|
||||
optionsCorsConfig := corsConfig
|
||||
optionsCorsConfig.AllowedMethods = []string{"GET", "POST", "OPTIONS"}
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
|
||||
params := map[string]string{
|
||||
"schema": currentSchema,
|
||||
"entity": currentEntity,
|
||||
}
|
||||
|
||||
handler.HandleGet(respAdapter, reqAdapter, params)
|
||||
return nil
|
||||
})
|
||||
|
||||
// OPTIONS route with ID (returns metadata)
|
||||
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
|
||||
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
|
||||
respAdapter := router.NewHTTPResponseWriter(w)
|
||||
reqAdapter := router.NewHTTPRequest(req.Request)
|
||||
optionsCorsConfig := corsConfig
|
||||
optionsCorsConfig.AllowedMethods = []string{"POST", "OPTIONS"}
|
||||
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
|
||||
params := map[string]string{
|
||||
"schema": currentSchema,
|
||||
"entity": currentEntity,
|
||||
}
|
||||
|
||||
handler.HandleGet(respAdapter, reqAdapter, params)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ExampleWithBunRouter shows how to use bunrouter from uptrace
|
||||
func ExampleWithBunRouter(bunDB *bun.DB) {
|
||||
// Create handler with Bun adapter
|
||||
handler := NewHandlerWithBun(bunDB)
|
||||
|
||||
// Create bunrouter
|
||||
bunRouter := bunrouter.New()
|
||||
|
||||
// Setup ResolveSpec routes with bunrouter without authentication
|
||||
SetupBunRouterRoutes(bunRouter, handler, nil)
|
||||
|
||||
// Start server
|
||||
// http.ListenAndServe(":8080", bunRouter)
|
||||
}
|
||||
|
||||
// ExampleBunRouterWithBunDB shows the full uptrace stack (bunrouter + Bun ORM)
|
||||
func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
|
||||
// Create Bun database adapter
|
||||
dbAdapter := database.NewBunAdapter(bunDB)
|
||||
|
||||
// Create model registry
|
||||
registry := modelregistry.NewModelRegistry()
|
||||
// registry.RegisterModel("public.users", &User{})
|
||||
|
||||
// Create handler with Bun
|
||||
handler := NewHandler(dbAdapter, registry)
|
||||
|
||||
// Create bunrouter
|
||||
bunRouter := bunrouter.New()
|
||||
|
||||
// Setup ResolveSpec routes without authentication
|
||||
SetupBunRouterRoutes(bunRouter, handler, nil)
|
||||
|
||||
// This gives you the full uptrace stack: bunrouter + Bun ORM
|
||||
// http.ListenAndServe(":8080", bunRouter)
|
||||
}
|
||||
|
||||
// ExampleBunRouterWithGroup shows how to use SetupBunRouterRoutes with a bunrouter.Group
|
||||
func ExampleBunRouterWithGroup(bunDB *bun.DB) {
|
||||
// Create handler with Bun adapter
|
||||
handler := NewHandlerWithBun(bunDB)
|
||||
|
||||
// Create bunrouter
|
||||
bunRouter := bunrouter.New()
|
||||
|
||||
// Create a route group with a prefix
|
||||
apiGroup := bunRouter.NewGroup("/api")
|
||||
|
||||
// Setup ResolveSpec routes on the group - routes will be under /api
|
||||
SetupBunRouterRoutes(apiGroup, handler, nil)
|
||||
|
||||
// Start server
|
||||
// http.ListenAndServe(":8080", bunRouter)
|
||||
}
|
||||
|
||||
// ExampleWithGORMAndAuth shows how to use ResolveSpec with GORM and authentication
|
||||
func ExampleWithGORMAndAuth(db *gorm.DB) {
|
||||
// Create handler using GORM
|
||||
_ = NewHandlerWithGORM(db)
|
||||
|
||||
// Create auth middleware
|
||||
// import "github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
// secList := security.NewSecurityList(myProvider)
|
||||
// authMiddleware := func(h http.Handler) http.Handler {
|
||||
// return security.NewAuthHandler(secList, h)
|
||||
// }
|
||||
|
||||
// Setup router with authentication
|
||||
_ = mux.NewRouter()
|
||||
// SetupMuxRoutes(muxRouter, handler, authMiddleware)
|
||||
|
||||
// Register models
|
||||
// handler.RegisterModel("public", "users", &User{})
|
||||
|
||||
// Start server
|
||||
// http.ListenAndServe(":8080", muxRouter)
|
||||
}
|
||||
|
||||
// ExampleWithBunAndAuth shows how to use ResolveSpec with Bun and authentication
|
||||
func ExampleWithBunAndAuth(bunDB *bun.DB) {
|
||||
// Create Bun adapter
|
||||
dbAdapter := database.NewBunAdapter(bunDB)
|
||||
|
||||
// Create model registry
|
||||
registry := modelregistry.NewModelRegistry()
|
||||
// registry.RegisterModel("public.users", &User{})
|
||||
|
||||
// Create handler
|
||||
_ = NewHandler(dbAdapter, registry)
|
||||
|
||||
// Create auth middleware
|
||||
// import "github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
// secList := security.NewSecurityList(myProvider)
|
||||
// authMiddleware := func(h http.Handler) http.Handler {
|
||||
// return security.NewAuthHandler(secList, h)
|
||||
// }
|
||||
|
||||
// Setup routes with authentication
|
||||
_ = mux.NewRouter()
|
||||
// SetupMuxRoutes(muxRouter, handler, authMiddleware)
|
||||
|
||||
// Start server
|
||||
// http.ListenAndServe(":8080", muxRouter)
|
||||
}
|
||||
|
||||
// ExampleBunRouterWithBunDBAndAuth shows the full uptrace stack with authentication
|
||||
func ExampleBunRouterWithBunDBAndAuth(bunDB *bun.DB) {
|
||||
// Create Bun database adapter
|
||||
dbAdapter := database.NewBunAdapter(bunDB)
|
||||
|
||||
// Create model registry
|
||||
registry := modelregistry.NewModelRegistry()
|
||||
// registry.RegisterModel("public.users", &User{})
|
||||
|
||||
// Create handler with Bun
|
||||
_ = NewHandler(dbAdapter, registry)
|
||||
|
||||
// Create auth middleware
|
||||
// import "github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
// secList := security.NewSecurityList(myProvider)
|
||||
// authMiddleware := func(h http.Handler) http.Handler {
|
||||
// return security.NewAuthHandler(secList, h)
|
||||
// }
|
||||
|
||||
// Create bunrouter
|
||||
_ = bunrouter.New()
|
||||
|
||||
// Setup ResolveSpec routes with authentication
|
||||
// SetupBunRouterRoutes(bunRouter, handler, authMiddleware)
|
||||
|
||||
// This gives you the full uptrace stack: bunrouter + Bun ORM with authentication
|
||||
// http.ListenAndServe(":8080", bunRouter)
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
)
|
||||
|
||||
// RegisterSecurityHooks registers all security-related hooks with the handler
|
||||
func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) {
|
||||
// Hook 0: BeforeHandle - enforce auth after model resolution
|
||||
handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error {
|
||||
if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil {
|
||||
hookCtx.Abort = true
|
||||
hookCtx.AbortMessage = err.Error()
|
||||
hookCtx.AbortCode = http.StatusUnauthorized
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Hook 1: BeforeRead - Load security rules
|
||||
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
|
||||
secCtx := newSecurityContext(hookCtx)
|
||||
return security.LoadSecurityRules(secCtx, securityList)
|
||||
})
|
||||
|
||||
// Hook 2: BeforeScan - Apply row-level security filters
|
||||
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
|
||||
secCtx := newSecurityContext(hookCtx)
|
||||
return security.ApplyRowSecurity(secCtx, securityList)
|
||||
})
|
||||
|
||||
// Hook 3: AfterRead - Apply column-level security (masking)
|
||||
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
|
||||
secCtx := newSecurityContext(hookCtx)
|
||||
return security.ApplyColumnSecurity(secCtx, securityList)
|
||||
})
|
||||
|
||||
// Hook 4 (Optional): Audit logging
|
||||
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
|
||||
secCtx := newSecurityContext(hookCtx)
|
||||
return security.LogDataAccess(secCtx)
|
||||
})
|
||||
|
||||
// Hook 5: BeforeUpdate - enforce CanUpdate rule from context/registry
|
||||
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
|
||||
secCtx := newSecurityContext(hookCtx)
|
||||
return security.CheckModelUpdateAllowed(secCtx)
|
||||
})
|
||||
|
||||
// Hook 6: BeforeDelete - enforce CanDelete rule from context/registry
|
||||
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
|
||||
secCtx := newSecurityContext(hookCtx)
|
||||
return security.CheckModelDeleteAllowed(secCtx)
|
||||
})
|
||||
|
||||
logger.Info("Security hooks registered for resolvespec handler")
|
||||
}
|
||||
|
||||
// securityContext adapts resolvespec.HookContext to security.SecurityContext interface
|
||||
type securityContext struct {
|
||||
ctx *HookContext
|
||||
}
|
||||
|
||||
func newSecurityContext(ctx *HookContext) security.SecurityContext {
|
||||
return &securityContext{ctx: ctx}
|
||||
}
|
||||
|
||||
func (s *securityContext) GetContext() context.Context {
|
||||
return s.ctx.Context
|
||||
}
|
||||
|
||||
func (s *securityContext) GetUserID() (int, bool) {
|
||||
return security.GetUserID(s.ctx.Context)
|
||||
}
|
||||
|
||||
func (s *securityContext) GetSchema() string {
|
||||
return s.ctx.Schema
|
||||
}
|
||||
|
||||
func (s *securityContext) GetEntity() string {
|
||||
return s.ctx.Entity
|
||||
}
|
||||
|
||||
func (s *securityContext) GetModel() interface{} {
|
||||
return s.ctx.Model
|
||||
}
|
||||
|
||||
func (s *securityContext) GetQuery() interface{} {
|
||||
return s.ctx.Query
|
||||
}
|
||||
|
||||
func (s *securityContext) SetQuery(query interface{}) {
|
||||
if q, ok := query.(common.SelectQuery); ok {
|
||||
s.ctx.Query = q
|
||||
}
|
||||
}
|
||||
|
||||
func (s *securityContext) GetResult() interface{} {
|
||||
return s.ctx.Result
|
||||
}
|
||||
|
||||
func (s *securityContext) SetResult(result interface{}) {
|
||||
s.ctx.Result = result
|
||||
}
|
||||
Reference in New Issue
Block a user