Compare commits

...
7 Commits
Author SHA1 Message Date
Hein ae2b0a4ef4 fix(security): skip row security filter for insert queries
Insert queries have no Where clause and read no existing rows, so the
fail-closed check rejected every insert when a row security template
was defined.
2026-10-01 10:47:54 +02:00
Hein daeea241af fix(restheadspec): honour string URL ids on POST updates
POST with a non-numeric URL id (e.g. a string primary key) was treated as
having no id, so the body primary key was used to look up the existing row.
That broke primary key changes. Treat any non-empty, non-zero URL id as the
update target, and add an integration test through Handle for POST and PUT.
2026-10-01 10:23:23 +02:00
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 528 additions and 211 deletions
+220 -196
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. 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).
@@ -142,14 +139,14 @@ 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]` |
| `SqlHalfVector` | `halfvec` | `[]float32` ⇄ `[1,2,3]` | | `SqlHalfVector` | `halfvec` | `[]float32` ⇄ `[1,2,3]` |
| `SqlSparseVector` | `sparsevec` | `{"dim":8,"indices":[1,4],"values":[0.5,0.2]}` | | `SqlSparseVector` | `sparsevec` | `{"dim":8,"indices":[1,4],"values":[0.5,0.2]}` |
| `SqlBitVector` | `bit`/`varbit` | bool array or `"1011"` string | | `SqlBitVector` | `bit`/`varbit` | bool array or `"1011"` string |
- Geometry `Value()` emits `SRID=<n>;<WKT>` (PostGIS implicit text→geometry cast; no wrapper function needed). - 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. - 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. `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}` |
### 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 |
### KNN search (ordering + distance column) ### KNN search (ordering + distance column)
**resolvespec** — `options.vector_search`: **resolvespec** — `options.vector_search`:
```json ```json
{ "options": { "vector_search": { {
"column": "embedding", "options": {
"vector": [0.1, 0.2, 0.3], "vector_search": {
"metric": "cosine", "column": "embedding",
"as": "_distance", "vector": [0.1, 0.2, 0.3],
"direction": "asc" "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). 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
@@ -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. 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) |
| `resolvespec-rs` | Rust | ResolveSpec, FunctionSpec | [README](clients/resolvespec-rs/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-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-dart` | Dart / Flutter | ResolveSpec, FunctionSpec | [README](clients/resolvespec-dart/README.md) |
#### ResolveSpec JS - TypeScript Client Library #### ResolveSpec JS - TypeScript Client Library
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`)
@@ -690,23 +702,23 @@ 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) |
| [`pkg/spectypes`](pkg/spectypes/) | SQL-aware types (nullable, JSONB, PostGIS, vector) | | [`pkg/spectypes`](pkg/spectypes/) | SQL-aware types (nullable, JSONB, PostGIS, vector) |
| [`pkg/logger`](pkg/logger/) | Logging used by all packages | | [`pkg/logger`](pkg/logger/) | Logging used by all packages |
| [`pkg/testmodels`](pkg/testmodels/) | Shared test models and data for tests and the testserver | | [`pkg/testmodels`](pkg/testmodels/) | Shared test models and data for tests and the testserver |
## 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
![1.00](./generated_slogan.webp) ![1.00](./generated_slogan.webp)
+3 -2
View File
@@ -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 (
+21 -1
View File
@@ -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) {
+62 -6
View File
@@ -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)
existingMap[pkName] = targetID if pkChanged {
existingMap[pkName] = newPK
finalID = newPK
} else {
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
View File
@@ -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
+53
View File
@@ -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{
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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