Compare commits

...
5 Commits
Author SHA1 Message Date
Hein 3bd3e46409 fix(restheadspec): handle primary key changes in updates
* Allow primary key changes when specified in the request body.
* Ensure correct record fetching after primary key updates.
* Add integration tests for primary key update scenarios.
2026-10-01 10:16:26 +02:00
Hein 247111c32e docs(README): update table of contents and feature descriptions 2026-10-01 09:46:17 +02:00
Hein Puth (Warkanum) ec8d4d2c77 Merge pull request #25 from bitechdev/fix/row-security-update-delete
Fix/row security update delete
2026-10-01 09:40:33 +02:00
Hein a3287f3b53 fix(security): reorder import statements for consistency 2026-10-01 09:33:57 +02:00
Hein 1e4a76643d fix(security): apply row security to update and delete queries
ApplyRowSecurity only accepted common.SelectQuery, so the BeforeScan hook
failed closed on update/delete when a row-security template existed.
Type-switch on SelectQuery, UpdateQuery and DeleteQuery; other types
still return an error.
2026-10-01 09:31:56 +02:00
9 changed files with 493 additions and 209 deletions
+221 -197
View File
@@ -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.
## Table of Contents
* [Features](#features)
* [Installation](#installation)
* [Quick Start](#quick-start)
* [ResolveSpec (Body-Based API)](#resolvespec---body-based-api)
* [RestHeadSpec (Header-Based API)](#restheadspec---header-based-api)
* [ResolveMCP (MCP Server)](#resolvemcp---mcp-server)
* [Architecture](#architecture)
* [API Structure](#api-structure)
* [RestHeadSpec Overview](#restheadspec-header-based-api)
* [Example Usage](#example-usage)
* [Testing](#testing)
* [Additional Packages](#additional-packages)
* [Security Considerations](#security-considerations)
* [What's New](#whats-new)
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [ResolveSpec (Body-Based API)](#resolvespec---body-based-api)
- [RestHeadSpec (Header-Based API)](#restheadspec---header-based-api)
- [ResolveMCP (MCP Server)](#resolvemcp---mcp-server)
- [Architecture](#architecture)
- [API Structure](#api-structure)
- [RestHeadSpec Overview](#restheadspec-header-based-api)
- [Example Usage](#example-usage)
- [Testing](#testing)
- [Additional Packages](#additional-packages)
- [Security Considerations](#security-considerations)
- [What's New](#whats-new)
## Features
### Core Features
* **Dynamic Data Querying**: Select specific columns and relationships to return
* **Relationship Preloading**: Load related entities with custom column selection and filters
* **Complex Filtering**: Apply multiple filters with various operators
* **Sorting**: Multi-column sort support
* **Pagination**: Built-in limit/offset and cursor-based pagination (both ResolveSpec and RestHeadSpec)
* **Computed Columns**: Define virtual columns for complex calculations
* **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)
* **🆕 Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field
- **Dynamic Data Querying**: Select specific columns and relationships to return
- **Relationship Preloading**: Load related entities with custom column selection and filters
- **Complex Filtering**: Apply multiple filters with various operators
- **Sorting**: Multi-column sort support
- **Pagination**: Built-in limit/offset and cursor-based pagination (both ResolveSpec and RestHeadSpec)
- **Computed Columns**: Define virtual columns for complex calculations
- **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)
- **🆕 Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field
### Architecture (v2.0+)
* **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
* **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers
* **🆕 Backward Compatible**: Existing code works without changes
* **🆕 Better Testing**: Mockable interfaces for easy unit testing
- **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
- **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers
- **🆕 Backward Compatible**: Existing code works without changes
- **🆕 Better Testing**: Mockable interfaces for easy unit testing
### ResolveMCP (v3.2+)
* **🆕 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
* **🆕 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
* **🆕 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
- **🆕 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
- **🆕 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
- **🆕 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
### RestHeadSpec (v2.1+)
* **🆕 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
* **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support
* **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats
* **🆕 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
* **🆕 Base64 Encoding**: Support for base64-encoded header values
- **🆕 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
- **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support
- **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats
- **🆕 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
- **🆕 Base64 Encoding**: Support for base64-encoded header values
### Routing & CORS (v3.0+)
* **🆕 Explicit Route Registration**: Routes created per registered model instead of dynamic lookups
* **🆕 OPTIONS Method Support**: Full OPTIONS method support returning model metadata
* **🆕 CORS Headers**: Comprehensive CORS support with all HeadSpec headers allowed
* **🆕 Better Route Control**: Customize routes per model with more flexibility
- **🆕 Explicit Route Registration**: Routes created per registered model instead of dynamic lookups
- **🆕 OPTIONS Method Support**: Full OPTIONS method support returning model metadata
- **🆕 CORS Headers**: Comprehensive CORS support with all HeadSpec headers allowed
- **🆕 Better Route Control**: Customize routes per model with more flexibility
## 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).
## 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).
@@ -142,14 +139,14 @@ First-class support for PostGIS geometry/geography and pgvector columns in `reso
### Column types (`pkg/spectypes`)
| Go type | SQL type | Wire / JSON |
|--------------------|--------------|--------------------------------------------------------|
| `SqlGeometry` | `geometry` | JSON in/out = **GeoJSON**; also accepts EWKT / hex-EWKB |
| `SqlGeography` | `geography` | same as `SqlGeometry` |
| `SqlVector` | `vector` | `[]float32` ⇄ `[1,2,3]` |
| `SqlHalfVector` | `halfvec` | `[]float32` ⇄ `[1,2,3]` |
| `SqlSparseVector` | `sparsevec` | `{"dim":8,"indices":[1,4],"values":[0.5,0.2]}` |
| `SqlBitVector` | `bit`/`varbit` | bool array or `"1011"` string |
| Go type | SQL type | Wire / JSON |
| ----------------- | -------------- | ------------------------------------------------------- |
| `SqlGeometry` | `geometry` | JSON in/out = **GeoJSON**; also accepts EWKT / hex-EWKB |
| `SqlGeography` | `geography` | same as `SqlGeometry` |
| `SqlVector` | `vector` | `[]float32` ⇄ `[1,2,3]` |
| `SqlHalfVector` | `halfvec` | `[]float32` ⇄ `[1,2,3]` |
| `SqlSparseVector` | `sparsevec` | `{"dim":8,"indices":[1,4],"values":[0.5,0.2]}` |
| `SqlBitVector` | `bit`/`varbit` | bool array or `"1011"` string |
- Geometry `Value()` emits `SRID=<n>;<WKT>` (PostGIS implicit text→geometry cast; no wrapper function needed).
- Declare dimensioned types with a tag: `gorm:"type:vector(1536)"` — the tag wins over the canonical name in metadata/OpenAPI.
@@ -159,32 +156,36 @@ 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.
| 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_dwithin` | `{"geom": <geometry>, "distance": <meters>}` |
| `bbox` (alias `&&`) | geometry, or `{"bbox":[minx,miny,maxx,maxy],"srid":4326}` |
| 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_dwithin` | `{"geom": <geometry>, "distance": <meters>}` |
| `bbox` (alias `&&`) | geometry, or `{"bbox":[minx,miny,maxx,maxy],"srid":4326}` |
### Vector similarity filter operators
| Operator | pgvector op | Value shape |
|----------|-------------|-------------|
| `l2_within` / `euclidean_within` | `<->` | `{"vector":[...], "distance": <n>}` |
| `cosine_within` | `<=>` | same (also `"lt"`/`"lte"`/`"gt"`/`"gte"` instead of `"distance"`) |
| `ip_within` / `inner_within` | `<#>` | same |
| Operator | pgvector op | Value shape |
| -------------------------------- | ----------- | ----------------------------------------------------------------- |
| `l2_within` / `euclidean_within` | `<->` | `{"vector":[...], "distance": <n>}` |
| `cosine_within` | `<=>` | same (also `"lt"`/`"lte"`/`"gt"`/`"gte"` instead of `"distance"`) |
| `ip_within` / `inner_within` | `<#>` | same |
### KNN search (ordering + distance column)
**resolvespec** — `options.vector_search`:
```json
{ "options": { "vector_search": {
"column": "embedding",
"vector": [0.1, 0.2, 0.3],
"metric": "cosine",
"as": "_distance",
"direction": "asc"
}}}
{
"options": {
"vector_search": {
"column": "embedding",
"vector": [0.1, 0.2, 0.3],
"metric": "cosine",
"as": "_distance",
"direction": "asc"
}
}
}
```
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
* **GORM** - Full support for PostgreSQL, SQLite, MSSQL
* **Bun** - Full support for PostgreSQL, SQLite, MSSQL
* **Native SQL** - Standard library `*sql.DB` with all supported databases
* **Custom ORMs** - Implement the `Database` interface
- **GORM** - Full support for PostgreSQL, SQLite, MSSQL
- **Bun** - Full support for PostgreSQL, SQLite, MSSQL
- **Native SQL** - Standard library `*sql.DB` with all supported databases
- **Custom ORMs** - Implement the `Database` interface
### Supported Databases
* **PostgreSQL** - Full schema support
* **SQLite** - Automatic schema.table to schema_table translation
* **Microsoft SQL Server** - Full schema support
* **MongoDB** - NoSQL document database (via MQTTSpec and custom handlers)
- **PostgreSQL** - Full schema support
- **SQLite** - Automatic schema.table to schema_table translation
- **Microsoft SQL Server** - Full schema support
- **MongoDB** - NoSQL document database (via MQTTSpec and custom handlers)
### Supported Routers
* **Gorilla Mux** (built-in support with `SetupRoutes()`)
* **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`)
* **Gin** (manual integration, see examples above)
* **Echo** (manual integration, see examples above)
* **Custom Routers** (implement request/response adapters)
- **Gorilla Mux** (built-in support with `SetupRoutes()`)
- **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`)
- **Gin** (manual integration, see examples above)
- **Echo** (manual integration, see examples above)
- **Custom Routers** (implement request/response adapters)
## Testing
@@ -373,11 +374,11 @@ ResolveSpec is designed for testability with mockable interfaces. For testing ex
### Test Server (dbtrace, real PostgreSQL)
* `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
* Ports: testserver `8123`, PostgreSQL `8124`
* `dbtrace` logs per request `tx`, `tx_queries`, `pooled`, `raw`; `pooled=0` is the target
* Integration tests default to PostgreSQL on `localhost:8124`
- `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
- Ports: testserver `8123`, PostgreSQL `8124`
- `dbtrace` logs per request `tx`, `tx_queries`, `pooled`, `raw`; `pooled=0` is the target
- Integration tests default to PostgreSQL on `localhost:8124`
## Continuous Integration
@@ -387,10 +388,10 @@ ResolveSpec uses GitHub Actions for automated testing and quality checks. The CI
The project includes automated workflows that:
* **Test**: Run all tests with race detection and code coverage
* **Lint**: Check code quality with golangci-lint
* **Build**: Verify the project builds successfully
* **Multi-version**: Test against multiple Go versions (1.23.x, 1.24.x)
- **Test**: Run all tests with race detection and code coverage
- **Lint**: Check code quality with golangci-lint
- **Build**: Verify the project builds successfully
- **Multi-version**: Test against multiple Go versions (1.23.x, 1.24.x)
### Running Tests Locally
@@ -412,9 +413,9 @@ golangci-lint run
The project includes comprehensive test coverage:
* **Unit Tests**: Individual component testing
* **Integration Tests**: End-to-end API testing
* **CRUD Tests**: Standalone tests for both ResolveSpec and RestHeadSpec APIs
- **Unit Tests**: Individual component testing
- **Integration Tests**: End-to-end API testing
- **CRUD Tests**: Standalone tests for both ResolveSpec and RestHeadSpec APIs
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.
**Key Features**:
- JSON request body with operation and options
- Recursive CRUD with nested object support
- 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.
**Key Features**:
- All query options via HTTP headers
- Same capabilities as ResolveSpec
- 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.
**Key Features**:
- Four tools per model: `read_`, `create_`, `update_`, `delete_`
- 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
@@ -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.
**Key Features**:
- Direct SQL function invocation
- Header-based parameter passing
- Automatic pagination and counting
@@ -495,20 +500,21 @@ For complete documentation, see [pkg/funcspec/](pkg/funcspec/).
All clients are under [clients/](clients/README.md); wire behaviour is identical across them.
| Client | Language | Specs | Docs |
|---|---|---|---|
| `resolvespec-js` | TypeScript | ResolveSpec, HeaderSpec, FunctionSpec, WebSocketSpec | [README](clients/resolvespec-js/README.md) |
| Client | Language | Specs | Docs |
| -------------------- | -------------- | ---------------------------------------------------- | ---------------------------------------------- |
| `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-go` | Go | ResolveSpec, FunctionSpec | [README](clients/resolvespec-go/README.md) |
| `resolvespec-rs` | Rust | ResolveSpec, FunctionSpec | [README](clients/resolvespec-rs/README.md) |
| `resolvespec-cs` | C# (.NET 8) | ResolveSpec, FunctionSpec | [README](clients/resolvespec-cs/README.md) |
| `resolvespec-dart` | Dart / Flutter | ResolveSpec, FunctionSpec | [README](clients/resolvespec-dart/README.md) |
| `resolvespec-go` | Go | ResolveSpec, FunctionSpec | [README](clients/resolvespec-go/README.md) |
| `resolvespec-rs` | Rust | ResolveSpec, FunctionSpec | [README](clients/resolvespec-rs/README.md) |
| `resolvespec-cs` | C# (.NET 8) | ResolveSpec, FunctionSpec | [README](clients/resolvespec-cs/README.md) |
| `resolvespec-dart` | Dart / Flutter | ResolveSpec, FunctionSpec | [README](clients/resolvespec-dart/README.md) |
#### ResolveSpec JS - TypeScript Client Library
TypeScript/JavaScript client library supporting all three REST and WebSocket protocols.
**Clients**:
- Body-based REST client (`read`, `create`, `update`, `deleteEntity`)
- Header-based REST client (`HeaderSpecClient`)
- 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.
**Key Features**:
- Persistent WebSocket connections
- Real-time subscriptions to entity changes
- 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.
**Key Features**:
- Embedded or external MQTT broker support
- QoS 1 (at-least-once delivery)
- 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.
**Key Features**:
- Router-agnostic with standard `http.Handler`
- Multiple filesystem backends (local, zip, embedded)
- Pluggable cache, MIME, and fallback policies
@@ -557,6 +566,7 @@ Flexible, interface-driven static file server.
- 140+ MIME types including modern formats
**Quick Example**:
```go
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.
**Key Features**:
- Multiple event sources (database, websockets, frontend, system)
- Multiple providers (in-memory, Redis Streams, NATS, PostgreSQL)
- 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.
**Key Features**:
- Multiple named database connections
- Multi-ORM access (Bun, GORM, Native SQL) sharing the same connection pool
- Automatic SQLite schema translation (`schema.table` → `schema_table`)
@@ -690,23 +702,23 @@ For documentation, see [pkg/dbtrace/README.md](pkg/dbtrace/README.md).
### Core Libraries
| Package | Purpose |
|---|---|
| [`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/reflection`](pkg/reflection/) | Model/struct reflection helpers (primary keys, columns, relations) |
| [`pkg/spectypes`](pkg/spectypes/) | SQL-aware types (nullable, JSONB, PostGIS, vector) |
| [`pkg/logger`](pkg/logger/) | Logging used by all packages |
| [`pkg/testmodels`](pkg/testmodels/) | Shared test models and data for tests and the testserver |
| Package | Purpose |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`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/reflection`](pkg/reflection/) | Model/struct reflection helpers (primary keys, columns, relations) |
| [`pkg/spectypes`](pkg/spectypes/) | SQL-aware types (nullable, JSONB, PostGIS, vector) |
| [`pkg/logger`](pkg/logger/) | Logging used by all packages |
| [`pkg/testmodels`](pkg/testmodels/) | Shared test models and data for tests and the testserver |
## Security Considerations
* Implement proper authentication and authorization
* Validate all input parameters
* Use prepared statements (handled by GORM/Bun/your ORM)
* Implement rate limiting (`middleware.RateLimiter`) and per-client request queueing (`middleware.ClientQueue`)
* Control access at schema/entity level
* **New**: Database abstraction layer provides additional security through interface boundaries
- Implement proper authentication and authorization
- Validate all input parameters
- Use prepared statements (handled by GORM/Bun/your ORM)
- Implement rate limiting (`middleware.RateLimiter`) and per-client request queueing (`middleware.ClientQueue`)
- Control access at schema/entity level
- **New**: Database abstraction layer provides additional security through interface boundaries
## Contributing
@@ -726,15 +738,15 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
**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)
* **`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
* **Delete**: single and batch delete, hooks included, in one tx
* **websocketspec / mqttspec**: one tx per message; begin/commit failures answer `transaction_error`
* **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
* **New**: `common.RunRequestTx`, `common.TxContext`, `common.TxHookName`
* **Behavior changes**: `AfterDelete` failure now rolls the delete back; funcspec begin/commit failure answers 500 `transaction_error`
- **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
- **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
- **websocketspec / mqttspec**: one tx per message; begin/commit failures answer `transaction_error`
- **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
- **New**: `common.RunRequestTx`, `common.TxContext`, `common.TxHookName`
- **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/`.
@@ -744,121 +756,133 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
**ResolveMCP - Model Context Protocol Server (🆕)**:
* **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
* **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
* **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 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
- **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
- **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
### v3.1 (February 2026)
**SQLite Schema Translation (🆕)**:
* **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
* **Transparent Handling**: Translation occurs automatically in all operations (SELECT, INSERT, UPDATE, DELETE, preloads)
* **All ORMs Supported**: Works with Bun, GORM, and Native SQL adapters
- **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
- **Transparent Handling**: Translation occurs automatically in all operations (SELECT, INSERT, UPDATE, DELETE, preloads)
- **All ORMs Supported**: Works with Bun, GORM, and Native SQL adapters
### v3.0 (December 2025)
**Explicit Route Registration (🆕)**:
* **Breaking Change**: Routes are now created explicitly for each registered model
* **Better Control**: Customize routes per model with more flexibility
* **Registration Order**: Models must be registered BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes
* **Benefits**: More flexible routing, easier to add custom routes per model, better performance
- **Breaking Change**: Routes are now created explicitly for each registered model
- **Better Control**: Customize routes per model with more flexibility
- **Registration Order**: Models must be registered BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes
- **Benefits**: More flexible routing, easier to add custom routes per model, better performance
**OPTIONS Method & CORS Support (🆕)**:
* **OPTIONS Endpoint**: Full OPTIONS method support for CORS preflight requests
* **Metadata Response**: OPTIONS returns model metadata (same as GET /metadata)
* **CORS Headers**: Comprehensive CORS headers on all responses
* **Header Support**: All HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) allowed
* **No Auth on OPTIONS**: CORS preflight requests don't require authentication
* **Configurable**: Customize CORS settings via `common.CORSConfig`
- **OPTIONS Endpoint**: Full OPTIONS method support for CORS preflight requests
- **Metadata Response**: OPTIONS returns model metadata (same as GET /metadata)
- **CORS Headers**: Comprehensive CORS headers on all responses
- **Header Support**: All HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) allowed
- **No Auth on OPTIONS**: CORS preflight requests don't require authentication
- **Configurable**: Customize CORS settings via `common.CORSConfig`
### v2.1
**Cursor Pagination for ResolveSpec (🆕 Dec 9, 2025)**:
* **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
* **Multi-Column Sort Support**: Works seamlessly with complex sorting requirements
* **Better Performance**: Improved performance for large datasets compared to offset pagination
* **SQL Safety**: Proper SQL sanitization for cursor values
- **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
- **Multi-Column Sort Support**: Works seamlessly with complex sorting requirements
- **Better Performance**: Improved performance for large datasets compared to offset pagination
- **SQL Safety**: Proper SQL sanitization for cursor values
**Recursive CRUD Handler (🆕 Nov 11, 2025)**:
* **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships
* **Foreign Key Resolution**: Automatic propagation of parent IDs to child records
* **Per-Record Operations**: Control create/update/delete operations per record via `_request` field
* **Transaction Safety**: All nested operations execute atomically within database transactions
* **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships
* **Deep Nesting Support**: Handle relationships at any depth level
* **Mixed Operations**: Combine insert, update, and delete operations in a single request
- **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships
- **Foreign Key Resolution**: Automatic propagation of parent IDs to child records
- **Per-Record Operations**: Control create/update/delete operations per record via `_request` field
- **Transaction Safety**: All nested operations execute atomically within database transactions
- **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships
- **Deep Nesting Support**: Handle relationships at any depth level
- **Mixed Operations**: Combine insert, update, and delete operations in a single request
**Primary Key Improvements (Nov 11, 2025)**:
* **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
* **Computed Column Support**: Fixed computed columns functionality across handlers
- **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
- **Computed Column Support**: Fixed computed columns functionality across handlers
**Database Adapter Enhancements (Nov 11, 2025)**:
* **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
* **Improved Type Safety**: Better handling of relationship queries with type-aware scanning
- **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
- **Improved Type Safety**: Better handling of relationship queries with type-aware scanning
**RestHeadSpec - Header-Based REST API**:
* **Header-Based Querying**: All query options via HTTP headers instead of request body
* **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations
* **Cursor Pagination**: Efficient cursor-based pagination with complex sorting
* **Advanced Filtering**: Field filters, search operators, AND/OR logic
* **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses
* **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header)
* **Base64 Support**: Base64-encoded header values for complex queries
* **Type-Aware Filtering**: Automatic type detection and conversion for filters
- **Header-Based Querying**: All query options via HTTP headers instead of request body
- **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations
- **Cursor Pagination**: Efficient cursor-based pagination with complex sorting
- **Advanced Filtering**: Field filters, search operators, AND/OR logic
- **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses
- **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header)
- **Base64 Support**: Base64-encoded header values for complex queries
- **Type-Aware Filtering**: Automatic type detection and conversion for filters
**Core Improvements**:
* Better model registry with schema.table format support
* Enhanced validation and error handling
* Improved reflection safety
* Fixed COUNT query issues with table aliasing
* Better pointer handling throughout the codebase
* **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec
- Better model registry with schema.table format support
- Enhanced validation and error handling
- Improved reflection safety
- Fixed COUNT query issues with table aliasing
- Better pointer handling throughout the codebase
- **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec
### v2.0
**Breaking Changes**:
* **None!** Full backward compatibility maintained
- **None!** Full backward compatibility maintained
**New Features**:
* **Database Abstraction**: Support for GORM, Bun, and custom ORMs
* **Router Flexibility**: Works with any HTTP router through adapters
* **BunRouter Integration**: Built-in support for uptrace/bunrouter
* **Better Architecture**: Clean separation of concerns with interfaces
* **Enhanced Testing**: Mockable interfaces for comprehensive testing
- **Database Abstraction**: Support for GORM, Bun, and custom ORMs
- **Router Flexibility**: Works with any HTTP router through adapters
- **BunRouter Integration**: Built-in support for uptrace/bunrouter
- **Better Architecture**: Clean separation of concerns with interfaces
- **Enhanced Testing**: Mockable interfaces for comprehensive testing
**Performance Improvements**:
* More efficient query building through interface design
* Reduced coupling between components
* Better memory management with interface boundaries
- More efficient query building through interface design
- Reduced coupling between components
- 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
* Inspired by REST, OData, and GraphQL's flexibility
* **Header-based approach**: Inspired by REST best practices and clean API design
* **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
* Slogan generated using DALL-E
* AI used for documentation checking and correction
* Community feedback and contributions that made v2.0 and v2.1 possible
- Inspired by REST, OData, and GraphQL's flexibility
- **Header-based approach**: Inspired by REST best practices and clean API design
- **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
- Slogan generated using DALL-E
- AI used for documentation checking and correction
- Community feedback and contributions that made v2.0 and v2.1 possible
![1.00](./generated_slogan.webp)
![1.00](./generated_slogan.webp)
+3 -2
View File
@@ -4,10 +4,11 @@ import (
"sync"
"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/promauto"
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
var (
+21 -1
View File
@@ -1244,6 +1244,16 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
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
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.
@@ -1337,6 +1347,14 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
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
hookCtx.Result = updates
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()
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)...)
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)
} else if reqID != nil {
switch id := reqID.(type) {
+58 -4
View File
@@ -1539,6 +1539,10 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Variable to store the updated record
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
hookCtx := &HookContext{
Context: ctx,
@@ -1604,11 +1608,25 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
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)
common.MergeUpdateValues(existingMap, dataMap, h.disallowNulls)
// Ensure ID is in the data map for the update
existingMap[pkName] = targetID
// 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
}
dataMap = existingMap
// Populate model instance from dataMap to preserve custom types (like SqlJSONB)
@@ -1651,6 +1669,20 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
}
_ = 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
})
@@ -1667,7 +1699,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
var errCode, errMsg string
err = h.runInTx(ctx, hookCtx, func(tx common.Database) error {
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
// re-fetch, same as it is for the initial read and the update query itself.
@@ -1711,7 +1743,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
return
}
logger.Info("Successfully updated record with ID: %v", targetID)
logger.Info("Successfully updated record with ID: %v", finalID)
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
@@ -1720,6 +1752,28 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
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{}) {
// Capture panics and return error response
defer func() {
@@ -0,0 +1,125 @@
//go:build integration
package restheadspec
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"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)
}
+8 -3
View File
@@ -140,11 +140,16 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error
// A filter that cannot be attached must fail the request; silently
// skipping it would expose every row.
selectQuery, ok := secCtx.GetQuery().(common.SelectQuery)
if !ok {
switch q := secCtx.GetQuery().(type) {
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...))
default:
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
+53
View File
@@ -214,6 +214,32 @@ func (q *recordingQuery) Where(query string, args ...interface{}) common.SelectQ
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
func TestApplyRowSecurity(t *testing.T) {
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) {
uc := &UserContext{UserID: 7, SessionID: "sess_secret", UserName: "x' OR '1'='1"}
provider := &mockSecurityProvider{rowSecurity: RowSecurity{
+2 -1
View File
@@ -11,9 +11,10 @@ import (
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/bitechdev/ResolveSpec/pkg/cache"
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
"golang.org/x/sync/singleflight"
)
// DatabaseKeyStoreOptions configures DatabaseKeyStore.
+2 -1
View File
@@ -11,10 +11,11 @@ import (
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/bitechdev/ResolveSpec/pkg/cache"
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"golang.org/x/sync/singleflight"
)
// Production-Ready Authenticators