mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2b0a4ef4 | ||
|
|
daeea241af | ||
|
|
3bd3e46409 | ||
|
|
247111c32e | ||
|
|
ec8d4d2c77 | ||
|
|
a3287f3b53 | ||
|
|
1e4a76643d |
@@ -13,71 +13,69 @@ ResolveSpec is a flexible and powerful REST API specification and implementation
|
|||||||
|
|
||||||
All share the same core architecture and provide dynamic data querying, relationship preloading, and complex filtering.
|
All share the same core architecture and provide dynamic data querying, relationship preloading, and complex filtering.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
* [Features](#features)
|
- [Features](#features)
|
||||||
* [Installation](#installation)
|
- [Installation](#installation)
|
||||||
* [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
* [ResolveSpec (Body-Based API)](#resolvespec---body-based-api)
|
- [ResolveSpec (Body-Based API)](#resolvespec---body-based-api)
|
||||||
* [RestHeadSpec (Header-Based API)](#restheadspec---header-based-api)
|
- [RestHeadSpec (Header-Based API)](#restheadspec---header-based-api)
|
||||||
* [ResolveMCP (MCP Server)](#resolvemcp---mcp-server)
|
- [ResolveMCP (MCP Server)](#resolvemcp---mcp-server)
|
||||||
* [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
* [API Structure](#api-structure)
|
- [API Structure](#api-structure)
|
||||||
* [RestHeadSpec Overview](#restheadspec-header-based-api)
|
- [RestHeadSpec Overview](#restheadspec-header-based-api)
|
||||||
* [Example Usage](#example-usage)
|
- [Example Usage](#example-usage)
|
||||||
* [Testing](#testing)
|
- [Testing](#testing)
|
||||||
* [Additional Packages](#additional-packages)
|
- [Additional Packages](#additional-packages)
|
||||||
* [Security Considerations](#security-considerations)
|
- [Security Considerations](#security-considerations)
|
||||||
* [What's New](#whats-new)
|
- [What's New](#whats-new)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Core Features
|
### Core Features
|
||||||
|
|
||||||
* **Dynamic Data Querying**: Select specific columns and relationships to return
|
- **Dynamic Data Querying**: Select specific columns and relationships to return
|
||||||
* **Relationship Preloading**: Load related entities with custom column selection and filters
|
- **Relationship Preloading**: Load related entities with custom column selection and filters
|
||||||
* **Complex Filtering**: Apply multiple filters with various operators
|
- **Complex Filtering**: Apply multiple filters with various operators
|
||||||
* **Sorting**: Multi-column sort support
|
- **Sorting**: Multi-column sort support
|
||||||
* **Pagination**: Built-in limit/offset and cursor-based pagination (both ResolveSpec and RestHeadSpec)
|
- **Pagination**: Built-in limit/offset and cursor-based pagination (both ResolveSpec and RestHeadSpec)
|
||||||
* **Computed Columns**: Define virtual columns for complex calculations
|
- **Computed Columns**: Define virtual columns for complex calculations
|
||||||
* **Custom Operators**: Add custom SQL conditions when needed
|
- **Custom Operators**: Add custom SQL conditions when needed
|
||||||
* **🆕 One Transaction Per Request**: Every statement and DB-touching hook of a request runs on one transaction; `OnTxBegin` hook stamps transaction-local settings (RLS) first. See [pkg/common/TRANSACTIONS.md](pkg/common/TRANSACTIONS.md)
|
- **🆕 One Transaction Per Request**: Every statement and DB-touching hook of a request runs on one transaction; `OnTxBegin` hook stamps transaction-local settings (RLS) first. See [pkg/common/TRANSACTIONS.md](pkg/common/TRANSACTIONS.md)
|
||||||
* **🆕 Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field
|
- **🆕 Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field
|
||||||
|
|
||||||
### Architecture (v2.0+)
|
### Architecture (v2.0+)
|
||||||
|
|
||||||
* **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
|
- **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
|
||||||
* **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers
|
- **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers
|
||||||
* **🆕 Backward Compatible**: Existing code works without changes
|
- **🆕 Backward Compatible**: Existing code works without changes
|
||||||
* **🆕 Better Testing**: Mockable interfaces for easy unit testing
|
- **🆕 Better Testing**: Mockable interfaces for easy unit testing
|
||||||
|
|
||||||
### ResolveMCP (v3.2+)
|
### ResolveMCP (v3.2+)
|
||||||
|
|
||||||
* **🆕 MCP Server**: Expose any registered database model as Model Context Protocol tools and resources
|
- **🆕 MCP Server**: Expose any registered database model as Model Context Protocol tools and resources
|
||||||
* **🆕 AI-Ready Descriptions**: Tool descriptions include the full column schema, primary key, nullable flags, and relations — giving AI models everything they need to query correctly without guessing
|
- **🆕 AI-Ready Descriptions**: Tool descriptions include the full column schema, primary key, nullable flags, and relations — giving AI models everything they need to query correctly without guessing
|
||||||
* **🆕 Four Tools Per Model**: `read_`, `create_`, `update_`, `delete_` tools auto-registered per model
|
- **🆕 Four Tools Per Model**: `read_`, `create_`, `update_`, `delete_` tools auto-registered per model
|
||||||
* **🆕 Full Query Support**: Filters, sort, limit/offset, cursor pagination, column selection, and relation preloading all available as tool parameters
|
- **🆕 Full Query Support**: Filters, sort, limit/offset, cursor pagination, column selection, and relation preloading all available as tool parameters
|
||||||
* **🆕 HTTP/SSE Transport**: Standards-compliant SSE transport for use with Claude Desktop, Cursor, and any MCP-compatible client
|
- **🆕 HTTP/SSE Transport**: Standards-compliant SSE transport for use with Claude Desktop, Cursor, and any MCP-compatible client
|
||||||
* **🆕 Lifecycle Hooks**: Same Before/After hook system as ResolveSpec for auth and side-effects
|
- **🆕 Lifecycle Hooks**: Same Before/After hook system as ResolveSpec for auth and side-effects
|
||||||
|
|
||||||
### RestHeadSpec (v2.1+)
|
### RestHeadSpec (v2.1+)
|
||||||
|
|
||||||
* **🆕 Header-Based API**: All query options passed via HTTP headers instead of request body
|
- **🆕 Header-Based API**: All query options passed via HTTP headers instead of request body
|
||||||
* **🆕 Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations
|
- **🆕 Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations
|
||||||
* **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support
|
- **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support
|
||||||
* **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats
|
- **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats
|
||||||
* **🆕 Single Record as Object**: Automatically normalize single-element arrays to objects (enabled by default)
|
- **🆕 Single Record as Object**: Automatically normalize single-element arrays to objects (enabled by default)
|
||||||
* **🆕 Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL
|
- **🆕 Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL
|
||||||
* **🆕 Base64 Encoding**: Support for base64-encoded header values
|
- **🆕 Base64 Encoding**: Support for base64-encoded header values
|
||||||
|
|
||||||
### Routing & CORS (v3.0+)
|
### Routing & CORS (v3.0+)
|
||||||
|
|
||||||
* **🆕 Explicit Route Registration**: Routes created per registered model instead of dynamic lookups
|
- **🆕 Explicit Route Registration**: Routes created per registered model instead of dynamic lookups
|
||||||
* **🆕 OPTIONS Method Support**: Full OPTIONS method support returning model metadata
|
- **🆕 OPTIONS Method Support**: Full OPTIONS method support returning model metadata
|
||||||
* **🆕 CORS Headers**: Comprehensive CORS support with all HeadSpec headers allowed
|
- **🆕 CORS Headers**: Comprehensive CORS support with all HeadSpec headers allowed
|
||||||
* **🆕 Better Route Control**: Customize routes per model with more flexibility
|
- **🆕 Better Route Control**: Customize routes per model with more flexibility
|
||||||
|
|
||||||
## API Structure
|
## API Structure
|
||||||
|
|
||||||
@@ -131,7 +129,6 @@ X-DetailApi: true
|
|||||||
|
|
||||||
For complete documentation including setup, headers, lifecycle hooks, cursor pagination, and more, see [pkg/restheadspec/README.md](pkg/restheadspec/README.md).
|
For complete documentation including setup, headers, lifecycle hooks, cursor pagination, and more, see [pkg/restheadspec/README.md](pkg/restheadspec/README.md).
|
||||||
|
|
||||||
|
|
||||||
## Example Usage
|
## Example Usage
|
||||||
|
|
||||||
For detailed examples of reading data, cursor pagination, recursive CRUD operations, filtering, sorting, and more, see [pkg/resolvespec/README.md](pkg/resolvespec/README.md).
|
For detailed examples of reading data, cursor pagination, recursive CRUD operations, filtering, sorting, and more, see [pkg/resolvespec/README.md](pkg/resolvespec/README.md).
|
||||||
@@ -143,7 +140,7 @@ First-class support for PostGIS geometry/geography and pgvector columns in `reso
|
|||||||
### Column types (`pkg/spectypes`)
|
### Column types (`pkg/spectypes`)
|
||||||
|
|
||||||
| Go type | SQL type | Wire / JSON |
|
| Go type | SQL type | Wire / JSON |
|
||||||
|--------------------|--------------|--------------------------------------------------------|
|
| ----------------- | -------------- | ------------------------------------------------------- |
|
||||||
| `SqlGeometry` | `geometry` | JSON in/out = **GeoJSON**; also accepts EWKT / hex-EWKB |
|
| `SqlGeometry` | `geometry` | JSON in/out = **GeoJSON**; also accepts EWKT / hex-EWKB |
|
||||||
| `SqlGeography` | `geography` | same as `SqlGeometry` |
|
| `SqlGeography` | `geography` | same as `SqlGeometry` |
|
||||||
| `SqlVector` | `vector` | `[]float32` ⇄ `[1,2,3]` |
|
| `SqlVector` | `vector` | `[]float32` ⇄ `[1,2,3]` |
|
||||||
@@ -160,7 +157,7 @@ First-class support for PostGIS geometry/geography and pgvector columns in `reso
|
|||||||
`value` is a geometry (GeoJSON object, EWKT string, or hex-EWKB) unless noted.
|
`value` is a geometry (GeoJSON object, EWKT string, or hex-EWKB) unless noted.
|
||||||
|
|
||||||
| Operator | Value shape |
|
| Operator | Value shape |
|
||||||
|----------|-------------|
|
| ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
| `st_intersects`, `st_contains`, `st_within`, `st_covers`, `st_coveredby`, `st_overlaps`, `st_touches`, `st_crosses`, `st_equals`, `st_disjoint` | geometry |
|
| `st_intersects`, `st_contains`, `st_within`, `st_covers`, `st_coveredby`, `st_overlaps`, `st_touches`, `st_crosses`, `st_equals`, `st_disjoint` | geometry |
|
||||||
| `st_dwithin` | `{"geom": <geometry>, "distance": <meters>}` |
|
| `st_dwithin` | `{"geom": <geometry>, "distance": <meters>}` |
|
||||||
| `bbox` (alias `&&`) | geometry, or `{"bbox":[minx,miny,maxx,maxy],"srid":4326}` |
|
| `bbox` (alias `&&`) | geometry, or `{"bbox":[minx,miny,maxx,maxy],"srid":4326}` |
|
||||||
@@ -168,7 +165,7 @@ First-class support for PostGIS geometry/geography and pgvector columns in `reso
|
|||||||
### Vector similarity filter operators
|
### Vector similarity filter operators
|
||||||
|
|
||||||
| Operator | pgvector op | Value shape |
|
| Operator | pgvector op | Value shape |
|
||||||
|----------|-------------|-------------|
|
| -------------------------------- | ----------- | ----------------------------------------------------------------- |
|
||||||
| `l2_within` / `euclidean_within` | `<->` | `{"vector":[...], "distance": <n>}` |
|
| `l2_within` / `euclidean_within` | `<->` | `{"vector":[...], "distance": <n>}` |
|
||||||
| `cosine_within` | `<=>` | same (also `"lt"`/`"lte"`/`"gt"`/`"gte"` instead of `"distance"`) |
|
| `cosine_within` | `<=>` | same (also `"lt"`/`"lte"`/`"gt"`/`"gte"` instead of `"distance"`) |
|
||||||
| `ip_within` / `inner_within` | `<#>` | same |
|
| `ip_within` / `inner_within` | `<#>` | same |
|
||||||
@@ -178,13 +175,17 @@ First-class support for PostGIS geometry/geography and pgvector columns in `reso
|
|||||||
**resolvespec** — `options.vector_search`:
|
**resolvespec** — `options.vector_search`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "options": { "vector_search": {
|
{
|
||||||
|
"options": {
|
||||||
|
"vector_search": {
|
||||||
"column": "embedding",
|
"column": "embedding",
|
||||||
"vector": [0.1, 0.2, 0.3],
|
"vector": [0.1, 0.2, 0.3],
|
||||||
"metric": "cosine",
|
"metric": "cosine",
|
||||||
"as": "_distance",
|
"as": "_distance",
|
||||||
"direction": "asc"
|
"direction": "asc"
|
||||||
}}}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Orders rows by distance; when `as` is set, returns the distance as an extra column (all model columns are auto-selected).
|
Orders rows by distance; when `as` is set, returns the distance as an extra column (all model columns are auto-selected).
|
||||||
@@ -343,25 +344,25 @@ Your Application Code
|
|||||||
|
|
||||||
### Supported Database Layers
|
### Supported Database Layers
|
||||||
|
|
||||||
* **GORM** - Full support for PostgreSQL, SQLite, MSSQL
|
- **GORM** - Full support for PostgreSQL, SQLite, MSSQL
|
||||||
* **Bun** - Full support for PostgreSQL, SQLite, MSSQL
|
- **Bun** - Full support for PostgreSQL, SQLite, MSSQL
|
||||||
* **Native SQL** - Standard library `*sql.DB` with all supported databases
|
- **Native SQL** - Standard library `*sql.DB` with all supported databases
|
||||||
* **Custom ORMs** - Implement the `Database` interface
|
- **Custom ORMs** - Implement the `Database` interface
|
||||||
|
|
||||||
### Supported Databases
|
### Supported Databases
|
||||||
|
|
||||||
* **PostgreSQL** - Full schema support
|
- **PostgreSQL** - Full schema support
|
||||||
* **SQLite** - Automatic schema.table to schema_table translation
|
- **SQLite** - Automatic schema.table to schema_table translation
|
||||||
* **Microsoft SQL Server** - Full schema support
|
- **Microsoft SQL Server** - Full schema support
|
||||||
* **MongoDB** - NoSQL document database (via MQTTSpec and custom handlers)
|
- **MongoDB** - NoSQL document database (via MQTTSpec and custom handlers)
|
||||||
|
|
||||||
### Supported Routers
|
### Supported Routers
|
||||||
|
|
||||||
* **Gorilla Mux** (built-in support with `SetupRoutes()`)
|
- **Gorilla Mux** (built-in support with `SetupRoutes()`)
|
||||||
* **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`)
|
- **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`)
|
||||||
* **Gin** (manual integration, see examples above)
|
- **Gin** (manual integration, see examples above)
|
||||||
* **Echo** (manual integration, see examples above)
|
- **Echo** (manual integration, see examples above)
|
||||||
* **Custom Routers** (implement request/response adapters)
|
- **Custom Routers** (implement request/response adapters)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -373,11 +374,11 @@ ResolveSpec is designed for testability with mockable interfaces. For testing ex
|
|||||||
|
|
||||||
### Test Server (dbtrace, real PostgreSQL)
|
### Test Server (dbtrace, real PostgreSQL)
|
||||||
|
|
||||||
* `make testserver-up` / `make testserver-down`: testserver + PostgreSQL via compose (host networking)
|
- `make testserver-up` / `make testserver-down`: testserver + PostgreSQL via compose (host networking)
|
||||||
* `make testserver-smoke`: create, read, update, delete, batch create/delete against the testserver
|
- `make testserver-smoke`: create, read, update, delete, batch create/delete against the testserver
|
||||||
* Ports: testserver `8123`, PostgreSQL `8124`
|
- Ports: testserver `8123`, PostgreSQL `8124`
|
||||||
* `dbtrace` logs per request `tx`, `tx_queries`, `pooled`, `raw`; `pooled=0` is the target
|
- `dbtrace` logs per request `tx`, `tx_queries`, `pooled`, `raw`; `pooled=0` is the target
|
||||||
* Integration tests default to PostgreSQL on `localhost:8124`
|
- Integration tests default to PostgreSQL on `localhost:8124`
|
||||||
|
|
||||||
## Continuous Integration
|
## Continuous Integration
|
||||||
|
|
||||||
@@ -387,10 +388,10 @@ ResolveSpec uses GitHub Actions for automated testing and quality checks. The CI
|
|||||||
|
|
||||||
The project includes automated workflows that:
|
The project includes automated workflows that:
|
||||||
|
|
||||||
* **Test**: Run all tests with race detection and code coverage
|
- **Test**: Run all tests with race detection and code coverage
|
||||||
* **Lint**: Check code quality with golangci-lint
|
- **Lint**: Check code quality with golangci-lint
|
||||||
* **Build**: Verify the project builds successfully
|
- **Build**: Verify the project builds successfully
|
||||||
* **Multi-version**: Test against multiple Go versions (1.23.x, 1.24.x)
|
- **Multi-version**: Test against multiple Go versions (1.23.x, 1.24.x)
|
||||||
|
|
||||||
### Running Tests Locally
|
### Running Tests Locally
|
||||||
|
|
||||||
@@ -412,9 +413,9 @@ golangci-lint run
|
|||||||
|
|
||||||
The project includes comprehensive test coverage:
|
The project includes comprehensive test coverage:
|
||||||
|
|
||||||
* **Unit Tests**: Individual component testing
|
- **Unit Tests**: Individual component testing
|
||||||
* **Integration Tests**: End-to-end API testing
|
- **Integration Tests**: End-to-end API testing
|
||||||
* **CRUD Tests**: Standalone tests for both ResolveSpec and RestHeadSpec APIs
|
- **CRUD Tests**: Standalone tests for both ResolveSpec and RestHeadSpec APIs
|
||||||
|
|
||||||
To run only the CRUD standalone tests:
|
To run only the CRUD standalone tests:
|
||||||
|
|
||||||
@@ -445,6 +446,7 @@ ResolveSpec includes several complementary packages that work together to provid
|
|||||||
The core body-based REST API with GraphQL-like capabilities.
|
The core body-based REST API with GraphQL-like capabilities.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- JSON request body with operation and options
|
- JSON request body with operation and options
|
||||||
- Recursive CRUD with nested object support
|
- Recursive CRUD with nested object support
|
||||||
- Cursor and offset pagination
|
- Cursor and offset pagination
|
||||||
@@ -458,6 +460,7 @@ For complete documentation, see [pkg/resolvespec/README.md](pkg/resolvespec/READ
|
|||||||
Alternative REST API where query options are passed via HTTP headers.
|
Alternative REST API where query options are passed via HTTP headers.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- All query options via HTTP headers
|
- All query options via HTTP headers
|
||||||
- Same capabilities as ResolveSpec
|
- Same capabilities as ResolveSpec
|
||||||
- Cleaner separation of data and metadata
|
- Cleaner separation of data and metadata
|
||||||
@@ -470,6 +473,7 @@ For complete documentation, see [pkg/restheadspec/README.md](pkg/restheadspec/RE
|
|||||||
Expose any registered model as Model Context Protocol tools and resources consumable by AI models over HTTP/SSE.
|
Expose any registered model as Model Context Protocol tools and resources consumable by AI models over HTTP/SSE.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- Four tools per model: `read_`, `create_`, `update_`, `delete_`
|
- Four tools per model: `read_`, `create_`, `update_`, `delete_`
|
||||||
- Rich AI-readable descriptions: column names, types, primary key, nullable flags, and preloadable relations
|
- Rich AI-readable descriptions: column names, types, primary key, nullable flags, and preloadable relations
|
||||||
- Full query support: filters, sort, limit/offset, cursor pagination, column selection, preloads
|
- Full query support: filters, sort, limit/offset, cursor pagination, column selection, preloads
|
||||||
@@ -483,6 +487,7 @@ For complete documentation, see [pkg/resolvemcp/](pkg/resolvemcp/).
|
|||||||
Execute SQL functions and queries through a simple HTTP API with header-based parameters.
|
Execute SQL functions and queries through a simple HTTP API with header-based parameters.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- Direct SQL function invocation
|
- Direct SQL function invocation
|
||||||
- Header-based parameter passing
|
- Header-based parameter passing
|
||||||
- Automatic pagination and counting
|
- Automatic pagination and counting
|
||||||
@@ -496,7 +501,7 @@ For complete documentation, see [pkg/funcspec/](pkg/funcspec/).
|
|||||||
All clients are under [clients/](clients/README.md); wire behaviour is identical across them.
|
All clients are under [clients/](clients/README.md); wire behaviour is identical across them.
|
||||||
|
|
||||||
| Client | Language | Specs | Docs |
|
| Client | Language | Specs | Docs |
|
||||||
|---|---|---|---|
|
| -------------------- | -------------- | ---------------------------------------------------- | ---------------------------------------------- |
|
||||||
| `resolvespec-js` | TypeScript | ResolveSpec, HeaderSpec, FunctionSpec, WebSocketSpec | [README](clients/resolvespec-js/README.md) |
|
| `resolvespec-js` | TypeScript | ResolveSpec, HeaderSpec, FunctionSpec, WebSocketSpec | [README](clients/resolvespec-js/README.md) |
|
||||||
| `resolvespec-python` | Python >= 3.11 | ResolveSpec, HeaderSpec, FunctionSpec, WebSocketSpec | [README](clients/resolvespec-python/README.md) |
|
| `resolvespec-python` | Python >= 3.11 | ResolveSpec, HeaderSpec, FunctionSpec, WebSocketSpec | [README](clients/resolvespec-python/README.md) |
|
||||||
| `resolvespec-go` | Go | ResolveSpec, FunctionSpec | [README](clients/resolvespec-go/README.md) |
|
| `resolvespec-go` | Go | ResolveSpec, FunctionSpec | [README](clients/resolvespec-go/README.md) |
|
||||||
@@ -509,6 +514,7 @@ All clients are under [clients/](clients/README.md); wire behaviour is identical
|
|||||||
TypeScript/JavaScript client library supporting all three REST and WebSocket protocols.
|
TypeScript/JavaScript client library supporting all three REST and WebSocket protocols.
|
||||||
|
|
||||||
**Clients**:
|
**Clients**:
|
||||||
|
|
||||||
- Body-based REST client (`read`, `create`, `update`, `deleteEntity`)
|
- Body-based REST client (`read`, `create`, `update`, `deleteEntity`)
|
||||||
- Header-based REST client (`HeaderSpecClient`)
|
- Header-based REST client (`HeaderSpecClient`)
|
||||||
- WebSocket client (`WebSocketClient`) with CRUD, subscriptions, heartbeat, reconnect
|
- WebSocket client (`WebSocketClient`) with CRUD, subscriptions, heartbeat, reconnect
|
||||||
@@ -522,6 +528,7 @@ For complete documentation, see [clients/resolvespec-js/README.md](clients/resol
|
|||||||
Real-time bidirectional communication with full CRUD operations and subscriptions.
|
Real-time bidirectional communication with full CRUD operations and subscriptions.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- Persistent WebSocket connections
|
- Persistent WebSocket connections
|
||||||
- Real-time subscriptions to entity changes
|
- Real-time subscriptions to entity changes
|
||||||
- Automatic push notifications
|
- Automatic push notifications
|
||||||
@@ -535,6 +542,7 @@ For complete documentation, see [pkg/websocketspec/README.md](pkg/websocketspec/
|
|||||||
MQTT-based database operations ideal for IoT and mobile applications.
|
MQTT-based database operations ideal for IoT and mobile applications.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- Embedded or external MQTT broker support
|
- Embedded or external MQTT broker support
|
||||||
- QoS 1 (at-least-once delivery)
|
- QoS 1 (at-least-once delivery)
|
||||||
- Real-time subscriptions
|
- Real-time subscriptions
|
||||||
@@ -550,6 +558,7 @@ For complete documentation, see [pkg/mqttspec/README.md](pkg/mqttspec/README.md)
|
|||||||
Flexible, interface-driven static file server.
|
Flexible, interface-driven static file server.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- Router-agnostic with standard `http.Handler`
|
- Router-agnostic with standard `http.Handler`
|
||||||
- Multiple filesystem backends (local, zip, embedded)
|
- Multiple filesystem backends (local, zip, embedded)
|
||||||
- Pluggable cache, MIME, and fallback policies
|
- Pluggable cache, MIME, and fallback policies
|
||||||
@@ -557,6 +566,7 @@ Flexible, interface-driven static file server.
|
|||||||
- 140+ MIME types including modern formats
|
- 140+ MIME types including modern formats
|
||||||
|
|
||||||
**Quick Example**:
|
**Quick Example**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "github.com/bitechdev/ResolveSpec/pkg/server/staticweb"
|
import "github.com/bitechdev/ResolveSpec/pkg/server/staticweb"
|
||||||
|
|
||||||
@@ -581,6 +591,7 @@ For complete documentation, see [pkg/server/staticweb/README.md](pkg/server/stat
|
|||||||
Comprehensive event handling system for real-time event publishing and cross-instance communication.
|
Comprehensive event handling system for real-time event publishing and cross-instance communication.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- Multiple event sources (database, websockets, frontend, system)
|
- Multiple event sources (database, websockets, frontend, system)
|
||||||
- Multiple providers (in-memory, Redis Streams, NATS, PostgreSQL)
|
- Multiple providers (in-memory, Redis Streams, NATS, PostgreSQL)
|
||||||
- Pattern-based subscriptions
|
- Pattern-based subscriptions
|
||||||
@@ -595,6 +606,7 @@ For complete documentation, see [pkg/eventbroker/README.md](pkg/eventbroker/READ
|
|||||||
Centralized management of multiple database connections with support for PostgreSQL, SQLite, MSSQL, and MongoDB.
|
Centralized management of multiple database connections with support for PostgreSQL, SQLite, MSSQL, and MongoDB.
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
|
|
||||||
- Multiple named database connections
|
- Multiple named database connections
|
||||||
- Multi-ORM access (Bun, GORM, Native SQL) sharing the same connection pool
|
- Multi-ORM access (Bun, GORM, Native SQL) sharing the same connection pool
|
||||||
- Automatic SQLite schema translation (`schema.table` → `schema_table`)
|
- Automatic SQLite schema translation (`schema.table` → `schema_table`)
|
||||||
@@ -691,7 +703,7 @@ For documentation, see [pkg/dbtrace/README.md](pkg/dbtrace/README.md).
|
|||||||
### Core Libraries
|
### Core Libraries
|
||||||
|
|
||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---|---|
|
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [`pkg/common`](pkg/common/) | Shared interfaces (database, request/response adapters), validation, recursive CRUD, request transactions ([TRANSACTIONS.md](pkg/common/TRANSACTIONS.md)) |
|
| [`pkg/common`](pkg/common/) | Shared interfaces (database, request/response adapters), validation, recursive CRUD, request transactions ([TRANSACTIONS.md](pkg/common/TRANSACTIONS.md)) |
|
||||||
| [`pkg/modelregistry`](pkg/modelregistry/) | Model registration by schema/entity and per-model access rules |
|
| [`pkg/modelregistry`](pkg/modelregistry/) | Model registration by schema/entity and per-model access rules |
|
||||||
| [`pkg/reflection`](pkg/reflection/) | Model/struct reflection helpers (primary keys, columns, relations) |
|
| [`pkg/reflection`](pkg/reflection/) | Model/struct reflection helpers (primary keys, columns, relations) |
|
||||||
@@ -701,12 +713,12 @@ For documentation, see [pkg/dbtrace/README.md](pkg/dbtrace/README.md).
|
|||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
* Implement proper authentication and authorization
|
- Implement proper authentication and authorization
|
||||||
* Validate all input parameters
|
- Validate all input parameters
|
||||||
* Use prepared statements (handled by GORM/Bun/your ORM)
|
- Use prepared statements (handled by GORM/Bun/your ORM)
|
||||||
* Implement rate limiting (`middleware.RateLimiter`) and per-client request queueing (`middleware.ClientQueue`)
|
- Implement rate limiting (`middleware.RateLimiter`) and per-client request queueing (`middleware.ClientQueue`)
|
||||||
* Control access at schema/entity level
|
- Control access at schema/entity level
|
||||||
* **New**: Database abstraction layer provides additional security through interface boundaries
|
- **New**: Database abstraction layer provides additional security through interface boundaries
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -726,15 +738,15 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
|
|
||||||
**Single transaction per request**:
|
**Single transaction per request**:
|
||||||
|
|
||||||
* **One tx per request**: hooks get the transaction in `hookCtx.Tx`, never the pool (`BeforeHandle` runs before any tx and must not touch the DB)
|
- **One tx per request**: hooks get the transaction in `hookCtx.Tx`, never the pool (`BeforeHandle` runs before any tx and must not touch the DB)
|
||||||
* **`OnTxBegin` hook**: all specs (mqttspec re-exports websocketspec's); fires once, first, in every tx; error or abort rolls back with no detail to the client
|
- **`OnTxBegin` hook**: all specs (mqttspec re-exports websocketspec's); fires once, first, in every tx; error or abort rolls back with no detail to the client
|
||||||
* **Second short tx**: create/update re-fetch, `BeforeScan` and post-commit hooks (`AfterCreate`, `AfterUpdate`, restheadspec `AfterRead`, funcspec `BeforeResponse`) run on a new tx after the first commits
|
- **Second short tx**: create/update re-fetch, `BeforeScan` and post-commit hooks (`AfterCreate`, `AfterUpdate`, restheadspec `AfterRead`, funcspec `BeforeResponse`) run on a new tx after the first commits
|
||||||
* **Delete**: single and batch delete, hooks included, in one tx
|
- **Delete**: single and batch delete, hooks included, in one tx
|
||||||
* **websocketspec / mqttspec**: one tx per message; begin/commit failures answer `transaction_error`
|
- **websocketspec / mqttspec**: one tx per message; begin/commit failures answer `transaction_error`
|
||||||
* **resolvemcp**: read, create, update, delete transactional
|
- **resolvemcp**: read, create, update, delete transactional
|
||||||
* **RLS stamping**: `SecurityList.SetTxSettings(fn)`; `RegisterSecurityHooks` of every spec stamps `set_config(name, value, true)` on `OnTxBegin`; fails closed
|
- **RLS stamping**: `SecurityList.SetTxSettings(fn)`; `RegisterSecurityHooks` of every spec stamps `set_config(name, value, true)` on `OnTxBegin`; fails closed
|
||||||
* **New**: `common.RunRequestTx`, `common.TxContext`, `common.TxHookName`
|
- **New**: `common.RunRequestTx`, `common.TxContext`, `common.TxHookName`
|
||||||
* **Behavior changes**: `AfterDelete` failure now rolls the delete back; funcspec begin/commit failure answers 500 `transaction_error`
|
- **Behavior changes**: `AfterDelete` failure now rolls the delete back; funcspec begin/commit failure answers 500 `transaction_error`
|
||||||
|
|
||||||
**Clients**: Go, Rust, C# and Dart clients for ResolveSpec and FunctionSpec under `clients/`.
|
**Clients**: Go, Rust, C# and Dart clients for ResolveSpec and FunctionSpec under `clients/`.
|
||||||
|
|
||||||
@@ -744,121 +756,133 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
|
|
||||||
**ResolveMCP - Model Context Protocol Server (🆕)**:
|
**ResolveMCP - Model Context Protocol Server (🆕)**:
|
||||||
|
|
||||||
* **MCP Tools**: Four tools auto-registered per model (`read_`, `create_`, `update_`, `delete_`) over HTTP/SSE transport
|
- **MCP Tools**: Four tools auto-registered per model (`read_`, `create_`, `update_`, `delete_`) over HTTP/SSE transport
|
||||||
* **AI-Ready Descriptions**: Full column schema, primary key, nullable flags, and relation names surfaced in tool descriptions so AI models can query without guessing
|
- **AI-Ready Descriptions**: Full column schema, primary key, nullable flags, and relation names surfaced in tool descriptions so AI models can query without guessing
|
||||||
* **Full Query Support**: Filters, sort, limit/offset, cursor pagination, column selection, and relation preloading all available as tool parameters
|
- **Full Query Support**: Filters, sort, limit/offset, cursor pagination, column selection, and relation preloading all available as tool parameters
|
||||||
* **HTTP/SSE Transport**: Standards-compliant transport compatible with Claude Desktop, Cursor, and any MCP 2024-11-05 client
|
- **HTTP/SSE Transport**: Standards-compliant transport compatible with Claude Desktop, Cursor, and any MCP 2024-11-05 client
|
||||||
* **Lifecycle Hooks**: Same Before/After hook system as ResolveSpec for auth, auditing, and side-effects
|
- **Lifecycle Hooks**: Same Before/After hook system as ResolveSpec for auth, auditing, and side-effects
|
||||||
* **MCP Resources**: Each model also exposed as a named resource for direct data access by AI clients
|
- **MCP Resources**: Each model also exposed as a named resource for direct data access by AI clients
|
||||||
|
|
||||||
### v3.1 (February 2026)
|
### v3.1 (February 2026)
|
||||||
|
|
||||||
**SQLite Schema Translation (🆕)**:
|
**SQLite Schema Translation (🆕)**:
|
||||||
|
|
||||||
* **Automatic Schema Translation**: SQLite support with automatic `schema.table` to `schema_table` conversion
|
- **Automatic Schema Translation**: SQLite support with automatic `schema.table` to `schema_table` conversion
|
||||||
* **Database Agnostic Models**: Write models once, use across PostgreSQL, SQLite, and MSSQL
|
- **Database Agnostic Models**: Write models once, use across PostgreSQL, SQLite, and MSSQL
|
||||||
* **Transparent Handling**: Translation occurs automatically in all operations (SELECT, INSERT, UPDATE, DELETE, preloads)
|
- **Transparent Handling**: Translation occurs automatically in all operations (SELECT, INSERT, UPDATE, DELETE, preloads)
|
||||||
* **All ORMs Supported**: Works with Bun, GORM, and Native SQL adapters
|
- **All ORMs Supported**: Works with Bun, GORM, and Native SQL adapters
|
||||||
|
|
||||||
### v3.0 (December 2025)
|
### v3.0 (December 2025)
|
||||||
|
|
||||||
**Explicit Route Registration (🆕)**:
|
**Explicit Route Registration (🆕)**:
|
||||||
|
|
||||||
* **Breaking Change**: Routes are now created explicitly for each registered model
|
- **Breaking Change**: Routes are now created explicitly for each registered model
|
||||||
* **Better Control**: Customize routes per model with more flexibility
|
- **Better Control**: Customize routes per model with more flexibility
|
||||||
* **Registration Order**: Models must be registered BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes
|
- **Registration Order**: Models must be registered BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes
|
||||||
* **Benefits**: More flexible routing, easier to add custom routes per model, better performance
|
- **Benefits**: More flexible routing, easier to add custom routes per model, better performance
|
||||||
|
|
||||||
**OPTIONS Method & CORS Support (🆕)**:
|
**OPTIONS Method & CORS Support (🆕)**:
|
||||||
|
|
||||||
* **OPTIONS Endpoint**: Full OPTIONS method support for CORS preflight requests
|
- **OPTIONS Endpoint**: Full OPTIONS method support for CORS preflight requests
|
||||||
* **Metadata Response**: OPTIONS returns model metadata (same as GET /metadata)
|
- **Metadata Response**: OPTIONS returns model metadata (same as GET /metadata)
|
||||||
* **CORS Headers**: Comprehensive CORS headers on all responses
|
- **CORS Headers**: Comprehensive CORS headers on all responses
|
||||||
* **Header Support**: All HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) allowed
|
- **Header Support**: All HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) allowed
|
||||||
* **No Auth on OPTIONS**: CORS preflight requests don't require authentication
|
- **No Auth on OPTIONS**: CORS preflight requests don't require authentication
|
||||||
* **Configurable**: Customize CORS settings via `common.CORSConfig`
|
- **Configurable**: Customize CORS settings via `common.CORSConfig`
|
||||||
|
|
||||||
### v2.1
|
### v2.1
|
||||||
|
|
||||||
**Cursor Pagination for ResolveSpec (🆕 Dec 9, 2025)**:
|
**Cursor Pagination for ResolveSpec (🆕 Dec 9, 2025)**:
|
||||||
|
|
||||||
* **Cursor-Based Pagination**: Efficient cursor pagination now available in ResolveSpec (body-based API)
|
- **Cursor-Based Pagination**: Efficient cursor pagination now available in ResolveSpec (body-based API)
|
||||||
* **Consistent with RestHeadSpec**: Both APIs now support cursor pagination for feature parity
|
- **Consistent with RestHeadSpec**: Both APIs now support cursor pagination for feature parity
|
||||||
* **Multi-Column Sort Support**: Works seamlessly with complex sorting requirements
|
- **Multi-Column Sort Support**: Works seamlessly with complex sorting requirements
|
||||||
* **Better Performance**: Improved performance for large datasets compared to offset pagination
|
- **Better Performance**: Improved performance for large datasets compared to offset pagination
|
||||||
* **SQL Safety**: Proper SQL sanitization for cursor values
|
- **SQL Safety**: Proper SQL sanitization for cursor values
|
||||||
|
|
||||||
**Recursive CRUD Handler (🆕 Nov 11, 2025)**:
|
**Recursive CRUD Handler (🆕 Nov 11, 2025)**:
|
||||||
|
|
||||||
* **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships
|
- **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships
|
||||||
* **Foreign Key Resolution**: Automatic propagation of parent IDs to child records
|
- **Foreign Key Resolution**: Automatic propagation of parent IDs to child records
|
||||||
* **Per-Record Operations**: Control create/update/delete operations per record via `_request` field
|
- **Per-Record Operations**: Control create/update/delete operations per record via `_request` field
|
||||||
* **Transaction Safety**: All nested operations execute atomically within database transactions
|
- **Transaction Safety**: All nested operations execute atomically within database transactions
|
||||||
* **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships
|
- **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships
|
||||||
* **Deep Nesting Support**: Handle relationships at any depth level
|
- **Deep Nesting Support**: Handle relationships at any depth level
|
||||||
* **Mixed Operations**: Combine insert, update, and delete operations in a single request
|
- **Mixed Operations**: Combine insert, update, and delete operations in a single request
|
||||||
|
|
||||||
**Primary Key Improvements (Nov 11, 2025)**:
|
**Primary Key Improvements (Nov 11, 2025)**:
|
||||||
|
|
||||||
* **GetPrimaryKeyName**: Enhanced primary key detection for better preload and ID field handling
|
- **GetPrimaryKeyName**: Enhanced primary key detection for better preload and ID field handling
|
||||||
* **Better GORM/Bun Support**: Improved compatibility with both ORMs for primary key operations
|
- **Better GORM/Bun Support**: Improved compatibility with both ORMs for primary key operations
|
||||||
* **Computed Column Support**: Fixed computed columns functionality across handlers
|
- **Computed Column Support**: Fixed computed columns functionality across handlers
|
||||||
|
|
||||||
**Database Adapter Enhancements (Nov 11, 2025)**:
|
**Database Adapter Enhancements (Nov 11, 2025)**:
|
||||||
|
|
||||||
* **Bun ORM Relations**: Using Scan model method for better has-many and many-to-many relationship handling
|
- **Bun ORM Relations**: Using Scan model method for better has-many and many-to-many relationship handling
|
||||||
* **Model Method Support**: Enhanced query building with proper model registration
|
- **Model Method Support**: Enhanced query building with proper model registration
|
||||||
* **Improved Type Safety**: Better handling of relationship queries with type-aware scanning
|
- **Improved Type Safety**: Better handling of relationship queries with type-aware scanning
|
||||||
|
|
||||||
**RestHeadSpec - Header-Based REST API**:
|
**RestHeadSpec - Header-Based REST API**:
|
||||||
|
|
||||||
* **Header-Based Querying**: All query options via HTTP headers instead of request body
|
- **Header-Based Querying**: All query options via HTTP headers instead of request body
|
||||||
* **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations
|
- **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations
|
||||||
* **Cursor Pagination**: Efficient cursor-based pagination with complex sorting
|
- **Cursor Pagination**: Efficient cursor-based pagination with complex sorting
|
||||||
* **Advanced Filtering**: Field filters, search operators, AND/OR logic
|
- **Advanced Filtering**: Field filters, search operators, AND/OR logic
|
||||||
* **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses
|
- **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses
|
||||||
* **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header)
|
- **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header)
|
||||||
* **Base64 Support**: Base64-encoded header values for complex queries
|
- **Base64 Support**: Base64-encoded header values for complex queries
|
||||||
* **Type-Aware Filtering**: Automatic type detection and conversion for filters
|
- **Type-Aware Filtering**: Automatic type detection and conversion for filters
|
||||||
|
|
||||||
**Core Improvements**:
|
**Core Improvements**:
|
||||||
|
|
||||||
* Better model registry with schema.table format support
|
- Better model registry with schema.table format support
|
||||||
* Enhanced validation and error handling
|
- Enhanced validation and error handling
|
||||||
* Improved reflection safety
|
- Improved reflection safety
|
||||||
* Fixed COUNT query issues with table aliasing
|
- Fixed COUNT query issues with table aliasing
|
||||||
* Better pointer handling throughout the codebase
|
- Better pointer handling throughout the codebase
|
||||||
* **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec
|
- **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec
|
||||||
|
|
||||||
### v2.0
|
### v2.0
|
||||||
|
|
||||||
**Breaking Changes**:
|
**Breaking Changes**:
|
||||||
|
|
||||||
* **None!** Full backward compatibility maintained
|
- **None!** Full backward compatibility maintained
|
||||||
|
|
||||||
**New Features**:
|
**New Features**:
|
||||||
|
|
||||||
* **Database Abstraction**: Support for GORM, Bun, and custom ORMs
|
- **Database Abstraction**: Support for GORM, Bun, and custom ORMs
|
||||||
* **Router Flexibility**: Works with any HTTP router through adapters
|
- **Router Flexibility**: Works with any HTTP router through adapters
|
||||||
* **BunRouter Integration**: Built-in support for uptrace/bunrouter
|
- **BunRouter Integration**: Built-in support for uptrace/bunrouter
|
||||||
* **Better Architecture**: Clean separation of concerns with interfaces
|
- **Better Architecture**: Clean separation of concerns with interfaces
|
||||||
* **Enhanced Testing**: Mockable interfaces for comprehensive testing
|
- **Enhanced Testing**: Mockable interfaces for comprehensive testing
|
||||||
|
|
||||||
**Performance Improvements**:
|
**Performance Improvements**:
|
||||||
|
|
||||||
* More efficient query building through interface design
|
- More efficient query building through interface design
|
||||||
* Reduced coupling between components
|
- Reduced coupling between components
|
||||||
* Better memory management with interface boundaries
|
- Better memory management with interface boundaries
|
||||||
|
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please do not open a public issue for security problems.
|
||||||
|
|
||||||
|
Report privately through GitHub: Security → Report a vulnerability
|
||||||
|
(https://github.com/bitechdev/ResolveSpec/security/advisories/new),
|
||||||
|
or email hein@bitechsystems.co.za / hein@warky.dev
|
||||||
|
|
||||||
|
You'll get an acknowledgement within 7 days. We aim to release a fix within
|
||||||
|
90 days and will credit reporters in the advisory unless they prefer otherwise.
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
* Inspired by REST, OData, and GraphQL's flexibility
|
- Inspired by REST, OData, and GraphQL's flexibility
|
||||||
* **Header-based approach**: Inspired by REST best practices and clean API design
|
- **Header-based approach**: Inspired by REST best practices and clean API design
|
||||||
* **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/)
|
- **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/)
|
||||||
* **Router Support**: Gorilla Mux (built-in), BunRouter, Gin, Echo, and others through adapters
|
- **Router Support**: Gorilla Mux (built-in), BunRouter, Gin, Echo, and others through adapters
|
||||||
* Slogan generated using DALL-E
|
- Slogan generated using DALL-E
|
||||||
* AI used for documentation checking and correction
|
- AI used for documentation checking and correction
|
||||||
* Community feedback and contributions that made v2.0 and v2.1 possible
|
- Community feedback and contributions that made v2.0 and v2.1 possible
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
@@ -4,10 +4,11 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -1244,6 +1244,16 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A primary key change is only honoured when the ID was given in the URL and
|
||||||
|
// the body carries a different, non-null primary key value.
|
||||||
|
var newPK interface{}
|
||||||
|
pkChanged := false
|
||||||
|
if urlID != "" {
|
||||||
|
if v, ok := updates[pkName]; ok && v != nil && !reflection.IsEmptyValue(v) && fmt.Sprintf("%v", v) != urlID {
|
||||||
|
newPK, pkChanged = v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap in transaction to ensure BeforeUpdate hook is inside transaction
|
// Wrap in transaction to ensure BeforeUpdate hook is inside transaction
|
||||||
err := h.runInTx(ctx, h.newTxHookContext(ctx, schema, entity, model, "update", options, w), func(tx common.Database) error {
|
err := h.runInTx(ctx, h.newTxHookContext(ctx, schema, entity, model, "update", options, w), func(tx common.Database) error {
|
||||||
// Execute BeforeUpdate hooks inside transaction, before any queries run.
|
// Execute BeforeUpdate hooks inside transaction, before any queries run.
|
||||||
@@ -1337,6 +1347,14 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
|||||||
return fmt.Errorf("no records found to update")
|
return fmt.Errorf("no records found to update")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMap skips primary key columns, so apply a PK change explicitly.
|
||||||
|
if pkChanged {
|
||||||
|
if _, err := tx.NewUpdate().Table(tableName).Set(pkName, newPK).
|
||||||
|
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID).Exec(ctx); err != nil {
|
||||||
|
return fmt.Errorf("error updating primary key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute AfterUpdate hooks inside transaction
|
// Execute AfterUpdate hooks inside transaction
|
||||||
hookCtx.Result = updates
|
hookCtx.Result = updates
|
||||||
hookCtx.Error = nil
|
hookCtx.Error = nil
|
||||||
@@ -1362,7 +1380,9 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
|||||||
updatedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
updatedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||||
if err := h.runInTx(ctx, h.newTxHookContext(ctx, schema, entity, model, "update", options, w), func(tx common.Database) error {
|
if err := h.runInTx(ctx, h.newTxHookContext(ctx, schema, entity, model, "update", options, w), func(tx common.Database) error {
|
||||||
fetchQuery := tx.NewSelect().Model(updatedRecord).Column(reflection.GetSQLModelColumns(model)...)
|
fetchQuery := tx.NewSelect().Model(updatedRecord).Column(reflection.GetSQLModelColumns(model)...)
|
||||||
if urlID != "" {
|
if pkChanged {
|
||||||
|
fetchQuery = fetchQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), newPK)
|
||||||
|
} else if urlID != "" {
|
||||||
fetchQuery = fetchQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
|
fetchQuery = fetchQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
|
||||||
} else if reqID != nil {
|
} else if reqID != nil {
|
||||||
switch id := reqID.(type) {
|
switch id := reqID.(type) {
|
||||||
|
|||||||
@@ -241,9 +241,11 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
|
|||||||
h.sendError(w, http.StatusBadRequest, "invalid_request", "Invalid request body", err)
|
h.sendError(w, http.StatusBadRequest, "invalid_request", "Invalid request body", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
validId, _ := strconv.ParseInt(id, 10, 64)
|
// A URL id is valid when it is a positive integer or any non-numeric
|
||||||
|
// string (string primary keys); "", "0" and negatives mean no id.
|
||||||
|
validId, parseErr := strconv.ParseInt(id, 10, 64)
|
||||||
updateID := id
|
updateID := id
|
||||||
isUpdate := validId > 0
|
isUpdate := id != "" && (parseErr != nil || validId > 0)
|
||||||
if !isUpdate {
|
if !isUpdate {
|
||||||
// No valid /:id in the URL - check whether the body itself carries
|
// No valid /:id in the URL - check whether the body itself carries
|
||||||
// a valid primary key value and treat this as an update if so.
|
// a valid primary key value and treat this as an update if so.
|
||||||
@@ -1539,6 +1541,10 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
// Variable to store the updated record
|
// Variable to store the updated record
|
||||||
var updatedRecord interface{}
|
var updatedRecord interface{}
|
||||||
|
|
||||||
|
// ID used to re-fetch the record after the update; differs from targetID
|
||||||
|
// when the request changes the primary key.
|
||||||
|
finalID := targetID
|
||||||
|
|
||||||
// Hook context used inside and outside transaction
|
// Hook context used inside and outside transaction
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
@@ -1604,11 +1610,25 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
nestedRelations = relations
|
nestedRelations = relations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture a changed primary key from the request before merging. The row is
|
||||||
|
// located by the original targetID (WHERE), while the new value is written via SET.
|
||||||
|
// Only honoured when an ID was given in the URL (id != "").
|
||||||
|
var newPK interface{}
|
||||||
|
var pkChanged bool
|
||||||
|
if id != "" {
|
||||||
|
newPK, pkChanged = h.requestedPrimaryKey(model, pkName, dataMap, targetID)
|
||||||
|
}
|
||||||
|
|
||||||
// Overwrite with every key present in the request (including "" and null unless disallowed)
|
// Overwrite with every key present in the request (including "" and null unless disallowed)
|
||||||
common.MergeUpdateValues(existingMap, dataMap, h.disallowNulls)
|
common.MergeUpdateValues(existingMap, dataMap, h.disallowNulls)
|
||||||
|
|
||||||
// Ensure ID is in the data map for the update
|
// Ensure ID is in the data map for the update (new value if the PK is being changed)
|
||||||
|
if pkChanged {
|
||||||
|
existingMap[pkName] = newPK
|
||||||
|
finalID = newPK
|
||||||
|
} else {
|
||||||
existingMap[pkName] = targetID
|
existingMap[pkName] = targetID
|
||||||
|
}
|
||||||
dataMap = existingMap
|
dataMap = existingMap
|
||||||
|
|
||||||
// Populate model instance from dataMap to preserve custom types (like SqlJSONB)
|
// Populate model instance from dataMap to preserve custom types (like SqlJSONB)
|
||||||
@@ -1651,6 +1671,20 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = result
|
_ = result
|
||||||
|
|
||||||
|
// Primary key changes are not part of the struct SET, so apply them explicitly.
|
||||||
|
if pkChanged {
|
||||||
|
pkResult, err := tx.NewUpdate().Table(tableName).
|
||||||
|
Set(pkName, newPK).
|
||||||
|
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update primary key: %w", err)
|
||||||
|
}
|
||||||
|
if pkResult.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("primary key update affected no rows for ID: %v", targetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1667,7 +1701,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
var errCode, errMsg string
|
var errCode, errMsg string
|
||||||
err = h.runInTx(ctx, hookCtx, func(tx common.Database) error {
|
err = h.runInTx(ctx, hookCtx, func(tx common.Database) error {
|
||||||
fetchedRecord := reflect.New(reflect.TypeOf(model)).Interface()
|
fetchedRecord := reflect.New(reflect.TypeOf(model)).Interface()
|
||||||
selectQuery := tx.NewSelect().Model(fetchedRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
selectQuery := tx.NewSelect().Model(fetchedRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), finalID)
|
||||||
|
|
||||||
// Execute BeforeScan hooks so row security is re-applied to the post-update
|
// Execute BeforeScan hooks so row security is re-applied to the post-update
|
||||||
// re-fetch, same as it is for the initial read and the update query itself.
|
// re-fetch, same as it is for the initial read and the update query itself.
|
||||||
@@ -1711,7 +1745,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Successfully updated record with ID: %v", targetID)
|
logger.Info("Successfully updated record with ID: %v", finalID)
|
||||||
// Invalidate cache for this table
|
// Invalidate cache for this table
|
||||||
cacheTags := buildCacheTags(schema, tableName)
|
cacheTags := buildCacheTags(schema, tableName)
|
||||||
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
|
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
|
||||||
@@ -1720,6 +1754,28 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
h.sendResponseWithOptions(w, mergedData, nil, &options)
|
h.sendResponseWithOptions(w, mergedData, nil, &options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestedPrimaryKey returns the primary key value carried in the request body
|
||||||
|
// (by column name or JSON key) when it differs from the current target ID.
|
||||||
|
func (h *Handler) requestedPrimaryKey(model interface{}, pkName string, dataMap map[string]interface{}, targetID interface{}) (interface{}, bool) {
|
||||||
|
val, exists := dataMap[pkName]
|
||||||
|
if !exists {
|
||||||
|
modelType := reflection.GetPointerElement(reflect.TypeOf(model))
|
||||||
|
for jsonKey, col := range reflection.BuildJSONToDBColumnMap(modelType) {
|
||||||
|
if col == pkName {
|
||||||
|
val, exists = dataMap[jsonKey]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists || val == nil || reflection.IsEmptyValue(val) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if fmt.Sprintf("%v", val) == fmt.Sprintf("%v", targetID) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string, data interface{}) {
|
func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string, data interface{}) {
|
||||||
// Capture panics and return error response
|
// Capture panics and return error response
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package restheadspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
"github.com/uptrace/bun/dialect/pgdialect"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pkAsset struct {
|
||||||
|
bun.BaseModel `bun:"table:public.t_pkasset,alias:t_pkasset"`
|
||||||
|
Category string `json:"category" bun:"category,type:citext"`
|
||||||
|
Description string `json:"description" bun:"description,type:citext,pk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPKTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
pg, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
|
ContainerRequest: testcontainers.ContainerRequest{
|
||||||
|
Image: "postgres:15-alpine",
|
||||||
|
ExposedPorts: []string{"5432/tcp"},
|
||||||
|
Env: map[string]string{
|
||||||
|
"POSTGRES_USER": "testuser", "POSTGRES_PASSWORD": "testpass", "POSTGRES_DB": "testdb",
|
||||||
|
},
|
||||||
|
WaitingFor: wait.ForLog("database system is ready to accept connections").
|
||||||
|
WithOccurrence(2).WithStartupTimeout(60 * time.Second),
|
||||||
|
},
|
||||||
|
Started: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = pg.Terminate(ctx) })
|
||||||
|
|
||||||
|
host, err := pg.Host(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
port, err := pg.MappedPort(ctx, "5432")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := sql.Open("pgx", fmt.Sprintf("postgres://testuser:testpass@%s:%s/testdb?sslmode=disable", host, port.Port()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
require.NoError(t, db.Ping())
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
CREATE TABLE public.t_pkasset (
|
||||||
|
category citext,
|
||||||
|
description citext PRIMARY KEY
|
||||||
|
);
|
||||||
|
INSERT INTO public.t_pkasset VALUES ('cat', 'old-pk');`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func pkUpdateCtx(base context.Context) context.Context {
|
||||||
|
ctx := WithSchema(base, "public")
|
||||||
|
ctx = WithEntity(ctx, "t_pkasset")
|
||||||
|
ctx = WithTableName(ctx, "t_pkasset")
|
||||||
|
return WithModel(ctx, pkAsset{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func countPK(t *testing.T, db *sql.DB, pk string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
require.NoError(t, db.QueryRow(`SELECT count(*) FROM public.t_pkasset WHERE description = $1`, pk).Scan(&n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateChangesPrimaryKeyWhenURLIDGiven(t *testing.T) {
|
||||||
|
db := setupPKTestDB(t)
|
||||||
|
h := NewHandler(database.NewBunAdapter(bun.NewDB(db, pgdialect.New())), modelregistry.NewModelRegistry())
|
||||||
|
|
||||||
|
update := func(id string, body map[string]interface{}) *httptest.ResponseRecorder {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w, _ := common.WrapHTTPRequest(rec, httptest.NewRequest(http.MethodPut, "/", nil))
|
||||||
|
base, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
h.handleUpdate(pkUpdateCtx(base), w, id, nil, body, ExtendedRequestOptions{})
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL id = old PK, body carries a different PK: the PK is changed.
|
||||||
|
rec := update("old-pk", map[string]interface{}{"description": "new-pk", "category": "cat2"})
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
require.Equal(t, 0, countPK(t, db, "old-pk"))
|
||||||
|
require.Equal(t, 1, countPK(t, db, "new-pk"))
|
||||||
|
var cat string
|
||||||
|
require.NoError(t, db.QueryRow(`SELECT category FROM public.t_pkasset WHERE description = 'new-pk'`).Scan(&cat))
|
||||||
|
require.Equal(t, "cat2", cat)
|
||||||
|
|
||||||
|
// Body PK equal to the URL id: ordinary update, PK untouched.
|
||||||
|
rec = update("new-pk", map[string]interface{}{"description": "new-pk", "category": "cat3"})
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
require.Equal(t, 1, countPK(t, db, "new-pk"))
|
||||||
|
|
||||||
|
// Body without a PK: ordinary update.
|
||||||
|
rec = update("new-pk", map[string]interface{}{"category": "cat4"})
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
// Null PK in the body is ignored, not applied.
|
||||||
|
rec = update("new-pk", map[string]interface{}{"description": nil, "category": "cat5"})
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
require.Equal(t, 1, countPK(t, db, "new-pk"))
|
||||||
|
var total int
|
||||||
|
require.NoError(t, db.QueryRow(`SELECT count(*) FROM public.t_pkasset`).Scan(&total))
|
||||||
|
require.Equal(t, 1, total)
|
||||||
|
require.NoError(t, db.QueryRow(`SELECT category FROM public.t_pkasset WHERE description = 'new-pk'`).Scan(&cat))
|
||||||
|
require.Equal(t, "cat5", cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercises the real dispatch: a POST with a string id in the URL and a different
|
||||||
|
// PK in the body must update the row identified by the URL id.
|
||||||
|
func TestHandlePostWithStringURLIDChangesPrimaryKey(t *testing.T) {
|
||||||
|
db := setupPKTestDB(t)
|
||||||
|
reg := modelregistry.NewModelRegistry()
|
||||||
|
require.NoError(t, reg.RegisterModel("public.t_pkasset", pkAsset{}))
|
||||||
|
h := NewHandler(database.NewBunAdapter(bun.NewDB(db, pgdialect.New())), reg)
|
||||||
|
|
||||||
|
for _, method := range []string{http.MethodPost, http.MethodPut} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
from, to := "old-pk", "new-pk-"+method
|
||||||
|
if method == http.MethodPut {
|
||||||
|
from = "new-pk-" + http.MethodPost
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, "/public/t_pkasset/"+from,
|
||||||
|
strings.NewReader(`{"description":"`+to+`","category":"c"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w, r := common.WrapHTTPRequest(rec, req)
|
||||||
|
h.Handle(w, r, map[string]string{"schema": "public", "entity": "t_pkasset", "id": from})
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
require.Equal(t, 0, countPK(t, db, from))
|
||||||
|
require.Equal(t, 1, countPK(t, db, to))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-3
@@ -140,11 +140,19 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error
|
|||||||
|
|
||||||
// A filter that cannot be attached must fail the request; silently
|
// A filter that cannot be attached must fail the request; silently
|
||||||
// skipping it would expose every row.
|
// skipping it would expose every row.
|
||||||
selectQuery, ok := secCtx.GetQuery().(common.SelectQuery)
|
switch q := secCtx.GetQuery().(type) {
|
||||||
if !ok {
|
case common.SelectQuery:
|
||||||
|
secCtx.SetQuery(q.Where(whereClause, whereArgs...))
|
||||||
|
case common.UpdateQuery:
|
||||||
|
secCtx.SetQuery(q.Where(whereClause, whereArgs...))
|
||||||
|
case common.DeleteQuery:
|
||||||
|
secCtx.SetQuery(q.Where(whereClause, whereArgs...))
|
||||||
|
case common.InsertQuery:
|
||||||
|
// Inserts read no existing rows, so there is nothing to filter.
|
||||||
|
logger.Debug("Row security filter not applicable to insert on %s.%s", schema, tablename)
|
||||||
|
default:
|
||||||
return fmt.Errorf("row security: query type %T on %s.%s does not support Where", secCtx.GetQuery(), schema, tablename)
|
return fmt.Errorf("row security: query type %T on %s.%s does not support Where", secCtx.GetQuery(), schema, tablename)
|
||||||
}
|
}
|
||||||
secCtx.SetQuery(selectQuery.Where(whereClause, whereArgs...))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -214,6 +214,32 @@ func (q *recordingQuery) Where(query string, args ...interface{}) common.SelectQ
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordingUpdateQuery is a common.UpdateQuery that records Where calls.
|
||||||
|
type recordingUpdateQuery struct {
|
||||||
|
common.UpdateQuery
|
||||||
|
clauses []string
|
||||||
|
args [][]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *recordingUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
|
||||||
|
q.clauses = append(q.clauses, query)
|
||||||
|
q.args = append(q.args, args)
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingDeleteQuery is a common.DeleteQuery that records Where calls.
|
||||||
|
type recordingDeleteQuery struct {
|
||||||
|
common.DeleteQuery
|
||||||
|
clauses []string
|
||||||
|
args [][]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *recordingDeleteQuery) Where(query string, args ...interface{}) common.DeleteQuery {
|
||||||
|
q.clauses = append(q.clauses, query)
|
||||||
|
q.args = append(q.args, args)
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
// Test applyRowSecurity
|
// Test applyRowSecurity
|
||||||
func TestApplyRowSecurity(t *testing.T) {
|
func TestApplyRowSecurity(t *testing.T) {
|
||||||
type TestModel struct {
|
type TestModel struct {
|
||||||
@@ -278,6 +304,33 @@ func TestApplyRowSecurity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("filter is attached to update and delete queries", func(t *testing.T) {
|
||||||
|
provider := &mockSecurityProvider{rowSecurity: RowSecurity{
|
||||||
|
Schema: "public", Tablename: "orders", Template: "user_id = {UserID}", UserID: 1,
|
||||||
|
}}
|
||||||
|
secList, _ := NewSecurityList(provider)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = secList.LoadRowSecurity(ctx, 1, "public", "orders", false)
|
||||||
|
|
||||||
|
uq := &recordingUpdateQuery{}
|
||||||
|
dq := &recordingDeleteQuery{}
|
||||||
|
for name, q := range map[string]interface{}{"update": uq, "delete": dq} {
|
||||||
|
secCtx := &mockSecurityContext{
|
||||||
|
ctx: ctx, userID: 1, hasUser: true, schema: "public", entity: "orders",
|
||||||
|
model: &TestModel{}, query: q,
|
||||||
|
}
|
||||||
|
if err := ApplyRowSecurity(secCtx, secList); err != nil {
|
||||||
|
t.Fatalf("%s: expected no error, got %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(uq.clauses) != 1 || uq.clauses[0] != "user_id = ?" || uq.args[0][0] != 1 {
|
||||||
|
t.Fatalf("update: filter not attached correctly: %v %v", uq.clauses, uq.args)
|
||||||
|
}
|
||||||
|
if len(dq.clauses) != 1 || dq.clauses[0] != "user_id = ?" || dq.args[0][0] != 1 {
|
||||||
|
t.Fatalf("delete: filter not attached correctly: %v %v", dq.clauses, dq.args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("user context is bound as its id, never rendered into SQL", func(t *testing.T) {
|
t.Run("user context is bound as its id, never rendered into SQL", func(t *testing.T) {
|
||||||
uc := &UserContext{UserID: 7, SessionID: "sess_secret", UserName: "x' OR '1'='1"}
|
uc := &UserContext{UserID: 7, SessionID: "sess_secret", UserName: "x' OR '1'='1"}
|
||||||
provider := &mockSecurityProvider{rowSecurity: RowSecurity{
|
provider := &mockSecurityProvider{rowSecurity: RowSecurity{
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/cache"
|
"github.com/bitechdev/ResolveSpec/pkg/cache"
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
||||||
"golang.org/x/sync/singleflight"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DatabaseKeyStoreOptions configures DatabaseKeyStore.
|
// DatabaseKeyStoreOptions configures DatabaseKeyStore.
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/cache"
|
"github.com/bitechdev/ResolveSpec/pkg/cache"
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
"golang.org/x/sync/singleflight"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Production-Ready Authenticators
|
// Production-Ready Authenticators
|
||||||
|
|||||||
Reference in New Issue
Block a user