Compare commits

...

22 Commits

Author SHA1 Message Date
Hein
f10bb0827e fix(sql_helpers): ensure case-insensitive matching for allowed prefixes 2026-03-25 10:57:42 +02:00
Hein
22a4ab345a feat(security): add session cookie management functions
* Introduce SessionCookieOptions for configurable session cookies
* Implement SetSessionCookie, GetSessionCookie, and ClearSessionCookie functions
* Enhance cookie handling in DatabaseAuthenticator
2026-03-24 17:11:53 +02:00
Hein
e289c2ed8f fix(handler): restore JoinAliases for proper WHERE sanitization 2026-03-24 12:00:02 +02:00
Hein
0d50bcfee6 fix(provider): enhance file opening logic with alternate path. Handling broken cases to be compatible with Bitech clients
* Implemented alternate path handling for file retrieval
* Improved error messaging for file not found scenarios
2026-03-24 09:02:17 +02:00
4df626ea71 chore(license): update project notice and clarify licensing terms 2026-03-23 20:32:09 +02:00
Hein
7dd630dec2 fix(handler): set default sort to primary key if none provided
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -26m15s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -26m11s
Build , Vet Test, and Lint / Lint Code (push) Failing after -30m52s
Build , Vet Test, and Lint / Build (push) Successful in -30m44s
Tests / Integration Tests (push) Failing after -31m5s
Tests / Unit Tests (push) Successful in -29m6s
2026-03-11 14:37:04 +02:00
Hein
613bf22cbd fix(cursor): use full schema-qualified table name in filters 2026-03-11 14:25:44 +02:00
d1ae4fe64e refactor(handler): unify filter operator handling for consistency
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -30m26s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m58s
Build , Vet Test, and Lint / Lint Code (push) Successful in -29m48s
Build , Vet Test, and Lint / Build (push) Successful in -30m4s
Tests / Integration Tests (push) Failing after -30m39s
Tests / Unit Tests (push) Successful in -30m29s
2026-03-01 13:21:38 +02:00
254102bfac refactor(auth): simplify handler type assertions for middleware
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -30m2s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m31s
Build , Vet Test, and Lint / Lint Code (push) Successful in -29m19s
Build , Vet Test, and Lint / Build (push) Successful in -29m42s
Tests / Integration Tests (push) Failing after -30m35s
Tests / Unit Tests (push) Successful in -30m17s
2026-03-01 12:08:36 +02:00
6c27419dbc refactor(auth): enhance request handling with middleware-enriched context 2026-03-01 12:06:43 +02:00
377336caf4 feat(sql): implement IN condition handling with parameterized queries 2026-03-01 09:52:32 +02:00
79720d5421 feat(security): add BeforeHandle hook for auth checks after model resolution
- Implement BeforeHandle hook to enforce authentication based on model rules.
- Integrate with existing security mechanisms to allow or deny access.
- Update documentation to reflect new hook and its usage.
2026-03-01 09:15:30 +02:00
e7ab0a20d6 Merge branch 'main' of github.com:bitechdev/ResolveSpec
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -30m15s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m41s
Build , Vet Test, and Lint / Build (push) Successful in -29m50s
Build , Vet Test, and Lint / Lint Code (push) Successful in -29m12s
Tests / Integration Tests (push) Failing after -30m43s
Tests / Unit Tests (push) Successful in -30m26s
2026-02-28 22:53:26 +02:00
e4087104a9 feat(security): add model rules enforcement for update and delete operations
- Implement BeforeUpdate and BeforeDelete hooks to enforce CanUpdate and CanDelete rules.
- Introduce new security context for websocketspec to manage security hooks.
- Enhance error handling in delete operations to provide clearer feedback.
2026-02-28 22:53:21 +02:00
Hein
17e580a9d3 fix(pgsql): optimize SQL string building for LIMIT and OFFSET
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -24m41s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -24m32s
Build , Vet Test, and Lint / Build (push) Successful in -29m28s
Build , Vet Test, and Lint / Lint Code (push) Successful in -28m35s
Tests / Integration Tests (push) Failing after -30m28s
Tests / Unit Tests (push) Successful in -29m28s
2026-02-25 09:38:37 +02:00
Hein
337a007d57 feat(openapi): add OpenAPI handling for OPTIONS requests 2026-02-25 09:36:50 +02:00
Hein
e923b0a2a3 feat(routes): add authentication middleware support for routes
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -25m48s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -25m25s
Build , Vet Test, and Lint / Lint Code (push) Successful in -25m29s
Build , Vet Test, and Lint / Build (push) Successful in -25m41s
Tests / Integration Tests (push) Failing after -26m14s
Tests / Unit Tests (push) Successful in -26m3s
2026-02-16 10:38:06 +02:00
ea4a4371ba feat(changesets): add README and config files for changeset management
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -25m56s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -25m29s
Build , Vet Test, and Lint / Build (push) Successful in -25m27s
Build , Vet Test, and Lint / Lint Code (push) Successful in -24m54s
Tests / Integration Tests (push) Failing after -26m14s
Tests / Unit Tests (push) Successful in -25m59s
2026-02-15 19:51:57 +02:00
b3694e50fe refactor(websocket): rename classes and functions for clarity 2026-02-15 19:51:05 +02:00
b76dae5991 refactor(headerspec): improve code formatting and consistency 2026-02-15 19:49:29 +02:00
dc85008d7f feat(api): add ResolveSpec and WebSocket client implementations
- Introduced ResolveSpecClient for REST API interactions.
- Added WebSocketClient for real-time communication.
- Created types and utility functions for both clients.
- Removed deprecated types and example files.
- Configured TypeScript and Vite for building the library.
2026-02-15 15:17:39 +02:00
Hein
fd77385dd6 feat(handler): enhance FetchRowNumber support in handlers
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -26m2s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -25m39s
Build , Vet Test, and Lint / Lint Code (push) Successful in -25m42s
Build , Vet Test, and Lint / Build (push) Successful in -25m55s
Tests / Integration Tests (push) Failing after -26m29s
Tests / Unit Tests (push) Successful in -26m17s
* Implement FetchRowNumber handling in multiple handlers
* Improve error logging for missing rows with filters
* Set row numbers correctly based on FetchRowNumber
2026-02-10 17:42:27 +02:00
70 changed files with 7723 additions and 1543 deletions

4
.gitignore vendored
View File

@@ -26,4 +26,6 @@ go.work.sum
bin/ bin/
test.db test.db
/testserver /testserver
tests/data/ tests/data/
node_modules/
resolvespec-js/dist/

27
LICENSE
View File

@@ -1,3 +1,18 @@
Project Notice
This project was independently developed.
The contents of this repository were prepared and published outside any time
allocated to Bitech Systems CC and do not contain, incorporate, disclose,
or rely upon any proprietary or confidential information, trade secrets,
protected designs, or other intellectual property of Bitech Systems CC.
No portion of this repository reproduces any Bitech Systems CC-specific
implementation, design asset, confidential workflow, or non-public technical material.
This notice is provided for clarification only and does not modify the terms of
the Apache License, Version 2.0.
Apache License Apache License
Version 2.0, January 2004 Version 2.0, January 2004
http://www.apache.org/licenses/ http://www.apache.org/licenses/
@@ -32,15 +47,15 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
@@ -56,7 +71,7 @@ END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work. APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2025 wdevs Copyright 2025 wdevs

View File

@@ -357,6 +357,17 @@ Execute SQL functions and queries through a simple HTTP API with header-based pa
For complete documentation, see [pkg/funcspec/](pkg/funcspec/). For complete documentation, see [pkg/funcspec/](pkg/funcspec/).
#### 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
For complete documentation, see [resolvespec-js/README.md](resolvespec-js/README.md).
### Real-Time Communication ### Real-Time Communication
#### WebSocketSpec - WebSocket API #### WebSocketSpec - WebSocket API

View File

@@ -394,12 +394,12 @@ func (p *PgSQLSelectQuery) buildSQL() string {
// LIMIT clause // LIMIT clause
if p.limit > 0 { if p.limit > 0 {
sb.WriteString(fmt.Sprintf(" LIMIT %d", p.limit)) fmt.Fprintf(&sb, " LIMIT %d", p.limit)
} }
// OFFSET clause // OFFSET clause
if p.offset > 0 { if p.offset > 0 {
sb.WriteString(fmt.Sprintf(" OFFSET %d", p.offset)) fmt.Fprintf(&sb, " OFFSET %d", p.offset)
} }
return sb.String() return sb.String()

View File

@@ -2,6 +2,7 @@ package common
import ( import (
"fmt" "fmt"
"reflect"
"regexp" "regexp"
"strings" "strings"
@@ -167,16 +168,17 @@ func SanitizeWhereClause(where string, tableName string, options ...*RequestOpti
} }
// Build a set of allowed table prefixes (main table + preloaded relations) // Build a set of allowed table prefixes (main table + preloaded relations)
// Keys are stored lowercase for case-insensitive matching
allowedPrefixes := make(map[string]bool) allowedPrefixes := make(map[string]bool)
if tableName != "" { if tableName != "" {
allowedPrefixes[tableName] = true allowedPrefixes[strings.ToLower(tableName)] = true
} }
// Add preload relation names as allowed prefixes // Add preload relation names as allowed prefixes
if len(options) > 0 && options[0] != nil { if len(options) > 0 && options[0] != nil {
for pi := range options[0].Preload { for pi := range options[0].Preload {
if options[0].Preload[pi].Relation != "" { if options[0].Preload[pi].Relation != "" {
allowedPrefixes[options[0].Preload[pi].Relation] = true allowedPrefixes[strings.ToLower(options[0].Preload[pi].Relation)] = true
logger.Debug("Added preload relation '%s' as allowed table prefix", options[0].Preload[pi].Relation) logger.Debug("Added preload relation '%s' as allowed table prefix", options[0].Preload[pi].Relation)
} }
} }
@@ -184,7 +186,7 @@ func SanitizeWhereClause(where string, tableName string, options ...*RequestOpti
// Add join aliases as allowed prefixes // Add join aliases as allowed prefixes
for _, alias := range options[0].JoinAliases { for _, alias := range options[0].JoinAliases {
if alias != "" { if alias != "" {
allowedPrefixes[alias] = true allowedPrefixes[strings.ToLower(alias)] = true
logger.Debug("Added join alias '%s' as allowed table prefix", alias) logger.Debug("Added join alias '%s' as allowed table prefix", alias)
} }
} }
@@ -216,8 +218,8 @@ func SanitizeWhereClause(where string, tableName string, options ...*RequestOpti
currentPrefix, columnName := extractTableAndColumn(condToCheck) currentPrefix, columnName := extractTableAndColumn(condToCheck)
if currentPrefix != "" && columnName != "" { if currentPrefix != "" && columnName != "" {
// Check if the prefix is allowed (main table or preload relation) // Check if the prefix is allowed (main table or preload relation) - case-insensitive
if !allowedPrefixes[currentPrefix] { if !allowedPrefixes[strings.ToLower(currentPrefix)] {
// Prefix is not in the allowed list - only fix if it's a valid column in the main table // Prefix is not in the allowed list - only fix if it's a valid column in the main table
if validColumns == nil || isValidColumn(columnName, validColumns) { if validColumns == nil || isValidColumn(columnName, validColumns) {
// Replace the incorrect prefix with the correct main table name // Replace the incorrect prefix with the correct main table name
@@ -925,3 +927,36 @@ func extractLeftSideOfComparison(cond string) string {
return "" return ""
} }
// FilterValueToSlice converts a filter value to []interface{} for use with IN operators.
// JSON-decoded arrays arrive as []interface{}, but typed slices (e.g. []string) also work.
// Returns a single-element slice if the value is not a slice type.
func FilterValueToSlice(v interface{}) []interface{} {
if v == nil {
return nil
}
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Slice {
result := make([]interface{}, rv.Len())
for i := 0; i < rv.Len(); i++ {
result[i] = rv.Index(i).Interface()
}
return result
}
return []interface{}{v}
}
// BuildInCondition builds a parameterized IN condition from a filter value.
// Returns the condition string (e.g. "col IN (?,?)") and the individual values as args.
// Returns ("", nil) if the value is empty or not a slice.
func BuildInCondition(column string, v interface{}) (query string, args []interface{}) {
values := FilterValueToSlice(v)
if len(values) == 0 {
return "", nil
}
placeholders := make([]string, len(values))
for i := range values {
placeholders[i] = "?"
}
return fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ",")), values
}

View File

@@ -2,14 +2,38 @@ package funcspec
import ( import (
"context" "context"
"fmt"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/security" "github.com/bitechdev/ResolveSpec/pkg/security"
) )
// RegisterSecurityHooks registers security hooks for funcspec handlers // RegisterSecurityHooks registers security hooks for funcspec handlers
// Note: funcspec operates on SQL queries directly, so row-level security is not directly applicable // Note: funcspec operates on SQL queries directly, so row-level security is not directly applicable
// We provide audit logging for data access tracking // We provide auth enforcement and audit logging for data access tracking
func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) {
// Hook 0: BeforeQueryList - Auth check before list query execution
handler.Hooks().Register(BeforeQueryList, func(hookCtx *HookContext) error {
if hookCtx.UserContext == nil || hookCtx.UserContext.UserID == 0 {
hookCtx.Abort = true
hookCtx.AbortMessage = "authentication required"
hookCtx.AbortCode = http.StatusUnauthorized
return fmt.Errorf("authentication required")
}
return nil
})
// Hook 0: BeforeQuery - Auth check before single query execution
handler.Hooks().Register(BeforeQuery, func(hookCtx *HookContext) error {
if hookCtx.UserContext == nil || hookCtx.UserContext.UserID == 0 {
hookCtx.Abort = true
hookCtx.AbortMessage = "authentication required"
hookCtx.AbortCode = http.StatusUnauthorized
return fmt.Errorf("authentication required")
}
return nil
})
// Hook 1: BeforeQueryList - Audit logging before query list execution // Hook 1: BeforeQueryList - Audit logging before query list execution
handler.Hooks().Register(BeforeQueryList, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeQueryList, func(hookCtx *HookContext) error {
secCtx := newFuncSpecSecurityContext(hookCtx) secCtx := newFuncSpecSecurityContext(hookCtx)

View File

@@ -8,6 +8,10 @@ import (
// ModelRules defines the permissions and security settings for a model // ModelRules defines the permissions and security settings for a model
type ModelRules struct { type ModelRules struct {
CanPublicRead bool // Whether the model can be read (GET operations)
CanPublicUpdate bool // Whether the model can be updated (PUT/PATCH operations)
CanPublicCreate bool // Whether the model can be created (POST operations)
CanPublicDelete bool // Whether the model can be deleted (DELETE operations)
CanRead bool // Whether the model can be read (GET operations) CanRead bool // Whether the model can be read (GET operations)
CanUpdate bool // Whether the model can be updated (PUT/PATCH operations) CanUpdate bool // Whether the model can be updated (PUT/PATCH operations)
CanCreate bool // Whether the model can be created (POST operations) CanCreate bool // Whether the model can be created (POST operations)
@@ -22,6 +26,10 @@ func DefaultModelRules() ModelRules {
CanUpdate: true, CanUpdate: true,
CanCreate: true, CanCreate: true,
CanDelete: true, CanDelete: true,
CanPublicRead: false,
CanPublicUpdate: false,
CanPublicCreate: false,
CanPublicDelete: false,
SecurityDisabled: false, SecurityDisabled: false,
} }
} }

View File

@@ -9,7 +9,7 @@ MQTTSpec is an MQTT-based database query framework that enables real-time databa
- **Full CRUD Operations**: Create, Read, Update, Delete with hooks - **Full CRUD Operations**: Create, Read, Update, Delete with hooks
- **Real-time Subscriptions**: Subscribe to entity changes with filtering - **Real-time Subscriptions**: Subscribe to entity changes with filtering
- **Database Agnostic**: GORM and Bun ORM support - **Database Agnostic**: GORM and Bun ORM support
- **Lifecycle Hooks**: 12 hooks for authentication, authorization, validation, and auditing - **Lifecycle Hooks**: 13 hooks for authentication, authorization, validation, and auditing
- **Multi-tenancy Support**: Built-in tenant isolation via hooks - **Multi-tenancy Support**: Built-in tenant isolation via hooks
- **Thread-safe**: Proper concurrency handling throughout - **Thread-safe**: Proper concurrency handling throughout
@@ -326,10 +326,11 @@ When any client creates/updates/deletes a user matching the subscription filters
## Lifecycle Hooks ## Lifecycle Hooks
MQTTSpec provides 12 lifecycle hooks for implementing cross-cutting concerns: MQTTSpec provides 13 lifecycle hooks for implementing cross-cutting concerns:
### Hook Types ### Hook Types
- `BeforeHandle` — fires after model resolution, before operation dispatch (auth checks)
- `BeforeConnect` / `AfterConnect` - Connection lifecycle - `BeforeConnect` / `AfterConnect` - Connection lifecycle
- `BeforeDisconnect` / `AfterDisconnect` - Disconnection lifecycle - `BeforeDisconnect` / `AfterDisconnect` - Disconnection lifecycle
- `BeforeRead` / `AfterRead` - Read operations - `BeforeRead` / `AfterRead` - Read operations
@@ -339,6 +340,20 @@ MQTTSpec provides 12 lifecycle hooks for implementing cross-cutting concerns:
- `BeforeSubscribe` / `AfterSubscribe` - Subscription creation - `BeforeSubscribe` / `AfterSubscribe` - Subscription creation
- `BeforeUnsubscribe` / `AfterUnsubscribe` - Subscription removal - `BeforeUnsubscribe` / `AfterUnsubscribe` - Subscription removal
### Security Hooks (Recommended)
Use `RegisterSecurityHooks` for integrated auth with model-rule support:
```go
import "github.com/bitechdev/ResolveSpec/pkg/security"
provider := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
securityList := security.NewSecurityList(provider)
mqttspec.RegisterSecurityHooks(handler, securityList)
// Registers BeforeHandle (model auth), BeforeRead (load rules),
// AfterRead (column security + audit), BeforeUpdate, BeforeDelete
```
### Authentication Example (JWT) ### Authentication Example (JWT)
```go ```go
@@ -657,7 +672,7 @@ handler, err := mqttspec.NewHandlerWithGORM(db,
| **Network Efficiency** | Better for unreliable networks | Better for low-latency | | **Network Efficiency** | Better for unreliable networks | Better for low-latency |
| **Best For** | IoT, mobile apps, distributed systems | Web applications, real-time dashboards | | **Best For** | IoT, mobile apps, distributed systems | Web applications, real-time dashboards |
| **Message Protocol** | Same JSON structure | Same JSON structure | | **Message Protocol** | Same JSON structure | Same JSON structure |
| **Hooks** | Same 12 hooks | Same 12 hooks | | **Hooks** | Same 13 hooks | Same 13 hooks |
| **CRUD Operations** | Identical | Identical | | **CRUD Operations** | Identical | Identical |
| **Subscriptions** | Identical (via MQTT topics) | Identical (via app-level) | | **Subscriptions** | Identical (via MQTT topics) | Identical (via app-level) |

View File

@@ -284,6 +284,15 @@ func (h *Handler) handleRequest(client *Client, msg *Message) {
}, },
} }
// Execute BeforeHandle hook - auth check fires here, after model resolution
hookCtx.Operation = string(msg.Operation)
if err := h.hooks.Execute(BeforeHandle, hookCtx); err != nil {
if hookCtx.Abort {
h.sendError(client.ID, msg.ID, "unauthorized", hookCtx.AbortMessage)
}
return
}
// Route to operation handler // Route to operation handler
switch msg.Operation { switch msg.Operation {
case OperationRead: case OperationRead:

View File

@@ -20,8 +20,11 @@ type (
HookRegistry = websocketspec.HookRegistry HookRegistry = websocketspec.HookRegistry
) )
// Hook type constants - all 12 lifecycle hooks // Hook type constants - all lifecycle hooks
const ( const (
// BeforeHandle fires after model resolution, before operation dispatch
BeforeHandle = websocketspec.BeforeHandle
// CRUD operation hooks // CRUD operation hooks
BeforeRead = websocketspec.BeforeRead BeforeRead = websocketspec.BeforeRead
AfterRead = websocketspec.AfterRead AfterRead = websocketspec.AfterRead

View File

@@ -0,0 +1,108 @@
package mqttspec
import (
"context"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
// RegisterSecurityHooks registers all security-related hooks with the MQTT handler
func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) {
// Hook 0: BeforeHandle - enforce auth after model resolution
handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error {
if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil {
hookCtx.Abort = true
hookCtx.AbortMessage = err.Error()
hookCtx.AbortCode = http.StatusUnauthorized
return err
}
return nil
})
// Hook 1: BeforeRead - Load security rules
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.LoadSecurityRules(secCtx, securityList)
})
// Hook 2: AfterRead - Apply column-level security (masking)
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.ApplyColumnSecurity(secCtx, securityList)
})
// Hook 3 (Optional): Audit logging
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.LogDataAccess(secCtx)
})
// Hook 4: BeforeUpdate - enforce CanUpdate rule from context/registry
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelUpdateAllowed(secCtx)
})
// Hook 5: BeforeDelete - enforce CanDelete rule from context/registry
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelDeleteAllowed(secCtx)
})
logger.Info("Security hooks registered for mqttspec handler")
}
// securityContext adapts mqttspec.HookContext to security.SecurityContext interface
type securityContext struct {
ctx *HookContext
}
func newSecurityContext(ctx *HookContext) security.SecurityContext {
return &securityContext{ctx: ctx}
}
func (s *securityContext) GetContext() context.Context {
return s.ctx.Context
}
func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context)
}
func (s *securityContext) GetSchema() string {
return s.ctx.Schema
}
func (s *securityContext) GetEntity() string {
return s.ctx.Entity
}
func (s *securityContext) GetModel() interface{} {
return s.ctx.Model
}
// GetQuery retrieves a stored query from hook metadata
func (s *securityContext) GetQuery() interface{} {
if s.ctx.Metadata == nil {
return nil
}
return s.ctx.Metadata["query"]
}
// SetQuery stores the query in hook metadata
func (s *securityContext) SetQuery(query interface{}) {
if s.ctx.Metadata == nil {
s.ctx.Metadata = make(map[string]interface{})
}
s.ctx.Metadata["query"] = query
}
func (s *securityContext) GetResult() interface{} {
return s.ctx.Result
}
func (s *securityContext) SetResult(result interface{}) {
s.ctx.Result = result
}

View File

@@ -644,6 +644,7 @@ handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookCon
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Tags []Tag `json:"tags,omitempty" gorm:"many2many:post_tags"` Tags []Tag `json:"tags,omitempty" gorm:"many2many:post_tags"`
} }
// Schema.Table format // Schema.Table format
handler.registry.RegisterModel("core.users", &User{}) handler.registry.RegisterModel("core.users", &User{})
handler.registry.RegisterModel("core.posts", &Post{}) handler.registry.RegisterModel("core.posts", &Post{})
@@ -654,11 +655,13 @@ handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookCon
```go ```go
package main package main
import (
"log" "log"
"net/http" "net/http"
"github.com/bitechdev/ResolveSpec/pkg/resolvespec" "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -32,7 +32,8 @@ func GetCursorFilter(
modelColumns []string, modelColumns []string,
options common.RequestOptions, options common.RequestOptions,
) (string, error) { ) (string, error) {
// Remove schema prefix if present // Separate schema prefix from bare table name
fullTableName := tableName
if strings.Contains(tableName, ".") { if strings.Contains(tableName, ".") {
tableName = strings.SplitN(tableName, ".", 2)[1] tableName = strings.SplitN(tableName, ".", 2)[1]
} }
@@ -115,7 +116,7 @@ func GetCursorFilter(
WHERE cursor_select.%s = %s WHERE cursor_select.%s = %s
AND (%s) AND (%s)
)`, )`,
tableName, fullTableName,
pkName, pkName,
cursorID, cursorID,
orSQL, orSQL,

View File

@@ -175,9 +175,9 @@ func TestGetCursorFilter_WithSchemaPrefix(t *testing.T) {
t.Fatalf("GetCursorFilter failed: %v", err) t.Fatalf("GetCursorFilter failed: %v", err)
} }
// Should handle schema prefix properly // Should include full schema-qualified name in FROM clause
if !strings.Contains(filter, "users") { if !strings.Contains(filter, "public.users") {
t.Errorf("Filter should reference table name users, got: %s", filter) t.Errorf("Filter FROM clause should use schema-qualified name public.users, got: %s", filter)
} }
t.Logf("Generated cursor filter with schema: %s", filter) t.Logf("Generated cursor filter with schema: %s", filter)

View File

@@ -44,8 +44,8 @@ func TestBuildFilterCondition(t *testing.T) {
Operator: "in", Operator: "in",
Value: []string{"active", "pending"}, Value: []string{"active", "pending"},
}, },
expectedCondition: "status IN (?)", expectedCondition: "status IN (?,?)",
expectedArgsCount: 1, expectedArgsCount: 2,
}, },
{ {
name: "LIKE operator", name: "LIKE operator",

View File

@@ -138,6 +138,26 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
validator := common.NewColumnValidator(model) validator := common.NewColumnValidator(model)
req.Options = validator.FilterRequestOptions(req.Options) req.Options = validator.FilterRequestOptions(req.Options)
// Execute BeforeHandle hook - auth check fires here, after model resolution
beforeCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Writer: w,
Request: r,
Operation: req.Operation,
}
if err := h.hooks.Execute(BeforeHandle, beforeCtx); err != nil {
code := http.StatusUnauthorized
if beforeCtx.AbortCode != 0 {
code = beforeCtx.AbortCode
}
h.sendError(w, code, "unauthorized", beforeCtx.AbortMessage, err)
return
}
switch req.Operation { switch req.Operation {
case "read": case "read":
h.handleRead(ctx, w, id, req.Options) h.handleRead(ctx, w, id, req.Options)
@@ -309,6 +329,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Extract model columns for validation // Extract model columns for validation
modelColumns := reflection.GetModelColumns(model) modelColumns := reflection.GetModelColumns(model)
// Default sort to primary key when none provided
if len(options.Sort) == 0 {
options.Sort = []common.SortOption{{Column: pkName, Direction: "ASC"}}
}
// Get cursor filter SQL // Get cursor filter SQL
cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options) cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options)
if err != nil { if err != nil {
@@ -433,7 +458,18 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Execute query to get row number // Execute query to get row number
var result RowNumResult var result RowNumResult
if err := rowNumQuery.Scan(ctx, &result); err != nil { if err := rowNumQuery.Scan(ctx, &result); err != nil {
if err != sql.ErrNoRows { if err == sql.ErrNoRows {
// Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
if len(options.CustomOperators) > 0 {
customOps := make([]string, 0, len(options.CustomOperators))
for _, op := range options.CustomOperators {
customOps = append(customOps, op.SQL)
}
filterInfo += fmt.Sprintf(", custom operators: [%s]", strings.Join(customOps, "; "))
}
logger.Warn("No row found for primary key %s=%s with %s", pkName, *options.FetchRowNumber, filterInfo)
} else {
logger.Warn("Error fetching row number: %v", err) logger.Warn("Error fetching row number: %v", err)
} }
} else { } else {
@@ -499,7 +535,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// When FetchRowNumber is used, we only return 1 record // When FetchRowNumber is used, we only return 1 record
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" { if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
count = 1 count = 1
// Don't use limit/offset when fetching specific record // Set the fetched row number on the record
if rowNumber != nil {
logger.Debug("FetchRowNumber: Setting row number %d on record", *rowNumber)
h.setRowNumbersOnRecords(result, int(*rowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
}
} else { } else {
if options.Limit != nil { if options.Limit != nil {
limit = *options.Limit limit = *options.Limit
@@ -1221,6 +1261,24 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
logger.Info("Deleting records from %s.%s", schema, entity) logger.Info("Deleting records from %s.%s", schema, entity)
// Execute BeforeDelete hooks (covers model-rule checks before any deletion)
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
ID: id,
Data: data,
Writer: w,
Tx: h.db,
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
h.sendError(w, http.StatusForbidden, "delete_forbidden", "Delete operation not allowed", err)
return
}
// Handle batch delete from request data // Handle batch delete from request data
if data != nil { if data != nil {
switch v := data.(type) { switch v := data.(type) {
@@ -1468,22 +1526,22 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
var args []interface{} var args []interface{}
switch filter.Operator { switch filter.Operator {
case "eq": case "eq", "=":
condition = fmt.Sprintf("%s = ?", filter.Column) condition = fmt.Sprintf("%s = ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "neq": case "neq", "!=", "<>":
condition = fmt.Sprintf("%s != ?", filter.Column) condition = fmt.Sprintf("%s != ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "gt": case "gt", ">":
condition = fmt.Sprintf("%s > ?", filter.Column) condition = fmt.Sprintf("%s > ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "gte": case "gte", ">=":
condition = fmt.Sprintf("%s >= ?", filter.Column) condition = fmt.Sprintf("%s >= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "lt": case "lt", "<":
condition = fmt.Sprintf("%s < ?", filter.Column) condition = fmt.Sprintf("%s < ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "lte": case "lte", "<=":
condition = fmt.Sprintf("%s <= ?", filter.Column) condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "like": case "like":
@@ -1493,8 +1551,10 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
condition = fmt.Sprintf("%s ILIKE ?", filter.Column) condition = fmt.Sprintf("%s ILIKE ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "in": case "in":
condition = fmt.Sprintf("%s IN (?)", filter.Column) condition, args = common.BuildInCondition(filter.Column, filter.Value)
args = []interface{}{filter.Value} if condition == "" {
return "", nil
}
default: default:
return "", nil return "", nil
} }
@@ -1510,22 +1570,22 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
var args []interface{} var args []interface{}
switch filter.Operator { switch filter.Operator {
case "eq": case "eq", "=":
condition = fmt.Sprintf("%s = ?", filter.Column) condition = fmt.Sprintf("%s = ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "neq": case "neq", "!=", "<>":
condition = fmt.Sprintf("%s != ?", filter.Column) condition = fmt.Sprintf("%s != ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "gt": case "gt", ">":
condition = fmt.Sprintf("%s > ?", filter.Column) condition = fmt.Sprintf("%s > ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "gte": case "gte", ">=":
condition = fmt.Sprintf("%s >= ?", filter.Column) condition = fmt.Sprintf("%s >= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "lt": case "lt", "<":
condition = fmt.Sprintf("%s < ?", filter.Column) condition = fmt.Sprintf("%s < ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "lte": case "lte", "<=":
condition = fmt.Sprintf("%s <= ?", filter.Column) condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "like": case "like":
@@ -1535,8 +1595,10 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
condition = fmt.Sprintf("%s ILIKE ?", filter.Column) condition = fmt.Sprintf("%s ILIKE ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "in": case "in":
condition = fmt.Sprintf("%s IN (?)", filter.Column) condition, args = common.BuildInCondition(filter.Column, filter.Value)
args = []interface{}{filter.Value} if condition == "" {
return query
}
default: default:
return query return query
} }

View File

@@ -12,6 +12,10 @@ import (
type HookType string type HookType string
const ( const (
// BeforeHandle fires after model resolution, before operation dispatch.
// Use this for auth checks that need model rules and user context simultaneously.
BeforeHandle HookType = "before_handle"
// Read operation hooks // Read operation hooks
BeforeRead HookType = "before_read" BeforeRead HookType = "before_read"
AfterRead HookType = "after_read" AfterRead HookType = "after_read"
@@ -43,6 +47,9 @@ type HookContext struct {
Writer common.ResponseWriter Writer common.ResponseWriter
Request common.Request Request common.Request
// Operation being dispatched (e.g. "read", "create", "update", "delete")
Operation string
// Operation-specific fields // Operation-specific fields
ID string ID string
Data interface{} // For create/update operations Data interface{} // For create/update operations

View File

@@ -70,17 +70,17 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
entityWithIDPath := buildRoutePath(schema, entity) + "/{id}" entityWithIDPath := buildRoutePath(schema, entity) + "/{id}"
// Create handler functions for this specific entity // Create handler functions for this specific entity
postEntityHandler := createMuxHandler(handler, schema, entity, "") var postEntityHandler http.Handler = createMuxHandler(handler, schema, entity, "")
postEntityWithIDHandler := createMuxHandler(handler, schema, entity, "id") var postEntityWithIDHandler http.Handler = createMuxHandler(handler, schema, entity, "id")
getEntityHandler := createMuxGetHandler(handler, schema, entity, "") var getEntityHandler http.Handler = createMuxGetHandler(handler, schema, entity, "")
optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"}) optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"})
optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"POST", "OPTIONS"}) optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"POST", "OPTIONS"})
// Apply authentication middleware if provided // Apply authentication middleware if provided
if authMiddleware != nil { if authMiddleware != nil {
postEntityHandler = authMiddleware(postEntityHandler).(http.HandlerFunc) postEntityHandler = authMiddleware(postEntityHandler)
postEntityWithIDHandler = authMiddleware(postEntityWithIDHandler).(http.HandlerFunc) postEntityWithIDHandler = authMiddleware(postEntityWithIDHandler)
getEntityHandler = authMiddleware(getEntityHandler).(http.HandlerFunc) getEntityHandler = authMiddleware(getEntityHandler)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth // Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
} }
@@ -216,9 +216,34 @@ type BunRouterHandler interface {
Handle(method, path string, handler bunrouter.HandlerFunc) Handle(method, path string, handler bunrouter.HandlerFunc)
} }
// wrapBunRouterHandler wraps a bunrouter handler with auth middleware if provided
func wrapBunRouterHandler(handler bunrouter.HandlerFunc, authMiddleware MiddlewareFunc) bunrouter.HandlerFunc {
if authMiddleware == nil {
return handler
}
return func(w http.ResponseWriter, req bunrouter.Request) error {
// Create an http.Handler that calls the bunrouter handler
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Replace the embedded *http.Request with the middleware-enriched one
// so that auth context (user ID, etc.) is visible to the handler.
enrichedReq := req
enrichedReq.Request = r
_ = handler(w, enrichedReq)
})
// Wrap with auth middleware and execute
wrappedHandler := authMiddleware(httpHandler)
wrappedHandler.ServeHTTP(w, req.Request)
return nil
}
}
// SetupBunRouterRoutes sets up bunrouter routes for the ResolveSpec API // SetupBunRouterRoutes sets up bunrouter routes for the ResolveSpec API
// Accepts bunrouter.Router or bunrouter.Group // Accepts bunrouter.Router or bunrouter.Group
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) { // authMiddleware is optional - if provided, routes will be protected with the middleware
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler, authMiddleware MiddlewareFunc) {
// CORS config // CORS config
corsConfig := common.DefaultCORSConfig() corsConfig := common.DefaultCORSConfig()
@@ -256,7 +281,7 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
currentEntity := entity currentEntity := entity
// POST route without ID // POST route without ID
r.Handle("POST", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error { postEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request) reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -267,10 +292,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("POST", entityPath, wrapBunRouterHandler(postEntityHandler, authMiddleware))
// POST route with ID // POST route with ID
r.Handle("POST", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { postEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request) reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -282,10 +308,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("POST", entityWithIDPath, wrapBunRouterHandler(postEntityWithIDHandler, authMiddleware))
// GET route without ID // GET route without ID
r.Handle("GET", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error { getEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request) reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -296,10 +323,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.HandleGet(respAdapter, reqAdapter, params) handler.HandleGet(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("GET", entityPath, wrapBunRouterHandler(getEntityHandler, authMiddleware))
// GET route with ID // GET route with ID
r.Handle("GET", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { getEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request) reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -311,9 +339,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.HandleGet(respAdapter, reqAdapter, params) handler.HandleGet(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("GET", entityWithIDPath, wrapBunRouterHandler(getEntityWithIDHandler, authMiddleware))
// OPTIONS route without ID (returns metadata) // OPTIONS route without ID (returns metadata)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error { r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request) reqAdapter := router.NewHTTPRequest(req.Request)
@@ -330,6 +360,7 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
}) })
// OPTIONS route with ID (returns metadata) // OPTIONS route with ID (returns metadata)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request) reqAdapter := router.NewHTTPRequest(req.Request)
@@ -355,8 +386,8 @@ func ExampleWithBunRouter(bunDB *bun.DB) {
// Create bunrouter // Create bunrouter
bunRouter := bunrouter.New() bunRouter := bunrouter.New()
// Setup ResolveSpec routes with bunrouter // Setup ResolveSpec routes with bunrouter without authentication
SetupBunRouterRoutes(bunRouter, handler) SetupBunRouterRoutes(bunRouter, handler, nil)
// Start server // Start server
// http.ListenAndServe(":8080", bunRouter) // http.ListenAndServe(":8080", bunRouter)
@@ -377,8 +408,8 @@ func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
// Create bunrouter // Create bunrouter
bunRouter := bunrouter.New() bunRouter := bunrouter.New()
// Setup ResolveSpec routes // Setup ResolveSpec routes without authentication
SetupBunRouterRoutes(bunRouter, handler) SetupBunRouterRoutes(bunRouter, handler, nil)
// This gives you the full uptrace stack: bunrouter + Bun ORM // This gives you the full uptrace stack: bunrouter + Bun ORM
// http.ListenAndServe(":8080", bunRouter) // http.ListenAndServe(":8080", bunRouter)
@@ -396,8 +427,87 @@ func ExampleBunRouterWithGroup(bunDB *bun.DB) {
apiGroup := bunRouter.NewGroup("/api") apiGroup := bunRouter.NewGroup("/api")
// Setup ResolveSpec routes on the group - routes will be under /api // Setup ResolveSpec routes on the group - routes will be under /api
SetupBunRouterRoutes(apiGroup, handler) SetupBunRouterRoutes(apiGroup, handler, nil)
// Start server // Start server
// http.ListenAndServe(":8080", bunRouter) // http.ListenAndServe(":8080", bunRouter)
} }
// ExampleWithGORMAndAuth shows how to use ResolveSpec with GORM and authentication
func ExampleWithGORMAndAuth(db *gorm.DB) {
// Create handler using GORM
_ = NewHandlerWithGORM(db)
// Create auth middleware
// import "github.com/bitechdev/ResolveSpec/pkg/security"
// secList := security.NewSecurityList(myProvider)
// authMiddleware := func(h http.Handler) http.Handler {
// return security.NewAuthHandler(secList, h)
// }
// Setup router with authentication
_ = mux.NewRouter()
// SetupMuxRoutes(muxRouter, handler, authMiddleware)
// Register models
// handler.RegisterModel("public", "users", &User{})
// Start server
// http.ListenAndServe(":8080", muxRouter)
}
// ExampleWithBunAndAuth shows how to use ResolveSpec with Bun and authentication
func ExampleWithBunAndAuth(bunDB *bun.DB) {
// Create Bun adapter
dbAdapter := database.NewBunAdapter(bunDB)
// Create model registry
registry := modelregistry.NewModelRegistry()
// registry.RegisterModel("public.users", &User{})
// Create handler
_ = NewHandler(dbAdapter, registry)
// Create auth middleware
// import "github.com/bitechdev/ResolveSpec/pkg/security"
// secList := security.NewSecurityList(myProvider)
// authMiddleware := func(h http.Handler) http.Handler {
// return security.NewAuthHandler(secList, h)
// }
// Setup routes with authentication
_ = mux.NewRouter()
// SetupMuxRoutes(muxRouter, handler, authMiddleware)
// Start server
// http.ListenAndServe(":8080", muxRouter)
}
// ExampleBunRouterWithBunDBAndAuth shows the full uptrace stack with authentication
func ExampleBunRouterWithBunDBAndAuth(bunDB *bun.DB) {
// Create Bun database adapter
dbAdapter := database.NewBunAdapter(bunDB)
// Create model registry
registry := modelregistry.NewModelRegistry()
// registry.RegisterModel("public.users", &User{})
// Create handler with Bun
_ = NewHandler(dbAdapter, registry)
// Create auth middleware
// import "github.com/bitechdev/ResolveSpec/pkg/security"
// secList := security.NewSecurityList(myProvider)
// authMiddleware := func(h http.Handler) http.Handler {
// return security.NewAuthHandler(secList, h)
// }
// Create bunrouter
_ = bunrouter.New()
// Setup ResolveSpec routes with authentication
// SetupBunRouterRoutes(bunRouter, handler, authMiddleware)
// This gives you the full uptrace stack: bunrouter + Bun ORM with authentication
// http.ListenAndServe(":8080", bunRouter)
}

View File

@@ -2,6 +2,7 @@ package resolvespec
import ( import (
"context" "context"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/common" "github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/logger"
@@ -10,6 +11,17 @@ import (
// RegisterSecurityHooks registers all security-related hooks with the handler // RegisterSecurityHooks registers all security-related hooks with the handler
func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) {
// Hook 0: BeforeHandle - enforce auth after model resolution
handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error {
if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil {
hookCtx.Abort = true
hookCtx.AbortMessage = err.Error()
hookCtx.AbortCode = http.StatusUnauthorized
return err
}
return nil
})
// Hook 1: BeforeRead - Load security rules // Hook 1: BeforeRead - Load security rules
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
@@ -34,6 +46,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
return security.LogDataAccess(secCtx) return security.LogDataAccess(secCtx)
}) })
// Hook 5: BeforeUpdate - enforce CanUpdate rule from context/registry
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelUpdateAllowed(secCtx)
})
// Hook 6: BeforeDelete - enforce CanDelete rule from context/registry
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelDeleteAllowed(secCtx)
})
logger.Info("Security hooks registered for resolvespec handler") logger.Info("Security hooks registered for resolvespec handler")
} }

View File

@@ -147,6 +147,7 @@ handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookCon
``` ```
**Available Hook Types**: **Available Hook Types**:
* `BeforeHandle` — fires after model resolution, before operation dispatch (auth checks)
* `BeforeRead`, `AfterRead` * `BeforeRead`, `AfterRead`
* `BeforeCreate`, `AfterCreate` * `BeforeCreate`, `AfterCreate`
* `BeforeUpdate`, `AfterUpdate` * `BeforeUpdate`, `AfterUpdate`
@@ -157,11 +158,13 @@ handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookCon
* `Handler`: Access to handler, database, and registry * `Handler`: Access to handler, database, and registry
* `Schema`, `Entity`, `TableName`: Request info * `Schema`, `Entity`, `TableName`: Request info
* `Model`: The registered model type * `Model`: The registered model type
* `Operation`: Current operation string (`"read"`, `"create"`, `"update"`, `"delete"`)
* `Options`: Parsed request options (filters, sorting, etc.) * `Options`: Parsed request options (filters, sorting, etc.)
* `ID`: Record ID (for single-record operations) * `ID`: Record ID (for single-record operations)
* `Data`: Request data (for create/update) * `Data`: Request data (for create/update)
* `Result`: Operation result (for after hooks) * `Result`: Operation result (for after hooks)
* `Writer`: Response writer (allows hooks to modify response) * `Writer`: Response writer (allows hooks to modify response)
* `Abort`, `AbortMessage`, `AbortCode`: Set in hook to abort with an error response
## Cursor Pagination ## Cursor Pagination

View File

@@ -32,6 +32,8 @@ func (opts *ExtendedRequestOptions) GetCursorFilter(
modelColumns []string, // optional: for validation modelColumns []string, // optional: for validation
expandJoins map[string]string, // optional: alias → JOIN SQL expandJoins map[string]string, // optional: alias → JOIN SQL
) (string, error) { ) (string, error) {
// Separate schema prefix from bare table name
fullTableName := tableName
if strings.Contains(tableName, ".") { if strings.Contains(tableName, ".") {
tableName = strings.SplitN(tableName, ".", 2)[1] tableName = strings.SplitN(tableName, ".", 2)[1]
} }
@@ -127,7 +129,7 @@ func (opts *ExtendedRequestOptions) GetCursorFilter(
WHERE cursor_select.%s = %s WHERE cursor_select.%s = %s
AND (%s) AND (%s)
)`, )`,
tableName, fullTableName,
joinSQL, joinSQL,
pkName, pkName,
cursorID, cursorID,

View File

@@ -187,9 +187,9 @@ func TestGetCursorFilter_WithSchemaPrefix(t *testing.T) {
t.Fatalf("GetCursorFilter failed: %v", err) t.Fatalf("GetCursorFilter failed: %v", err)
} }
// Should handle schema prefix properly // Should include full schema-qualified name in FROM clause
if !strings.Contains(filter, "users") { if !strings.Contains(filter, "public.users") {
t.Errorf("Filter should reference table name users, got: %s", filter) t.Errorf("Filter FROM clause should use schema-qualified name public.users, got: %s", filter)
} }
t.Logf("Generated cursor filter with schema: %s", filter) t.Logf("Generated cursor filter with schema: %s", filter)

View File

@@ -133,6 +133,41 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
// Add request-scoped data to context (including options) // Add request-scoped data to context (including options)
ctx = WithRequestData(ctx, schema, entity, tableName, model, modelPtr, options) ctx = WithRequestData(ctx, schema, entity, tableName, model, modelPtr, options)
// Derive operation for auth check
var operation string
switch method {
case "GET":
operation = "read"
case "POST":
operation = "create"
case "PUT", "PATCH":
operation = "update"
case "DELETE":
operation = "delete"
default:
operation = "read"
}
// Execute BeforeHandle hook - auth check fires here, after model resolution
beforeCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Writer: w,
Request: r,
Operation: operation,
}
if err := h.hooks.Execute(BeforeHandle, beforeCtx); err != nil {
code := http.StatusUnauthorized
if beforeCtx.AbortCode != 0 {
code = beforeCtx.AbortCode
}
h.sendError(w, code, "unauthorized", beforeCtx.AbortMessage, err)
return
}
switch method { switch method {
case "GET": case "GET":
if id != "" { if id != "" {
@@ -549,8 +584,30 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
} }
// If ID is provided, filter by ID // Handle FetchRowNumber before applying ID filter
if id != "" { // This must happen before the query to get the row position, then filter by PK
var fetchedRowNumber *int64
var fetchRowNumberPKValue string
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
pkName := reflection.GetPrimaryKeyName(model)
fetchRowNumberPKValue = *options.FetchRowNumber
logger.Debug("FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, fetchRowNumberPKValue, options, model)
if err != nil {
logger.Error("Failed to fetch row number: %v", err)
h.sendError(w, http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number", err)
return
}
fetchedRowNumber = &rowNum
logger.Debug("FetchRowNumber: Row number %d for PK %s = %s", rowNum, pkName, fetchRowNumberPKValue)
// Now filter the main query to this specific primary key
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), fetchRowNumberPKValue)
} else if id != "" {
// If ID is provided (and not FetchRowNumber), filter by ID
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
logger.Debug("Filtering by ID=%s: %s", pkName, id) logger.Debug("Filtering by ID=%s: %s", pkName, id)
@@ -674,6 +731,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// For now, pass empty map as joins are handled via Preload // For now, pass empty map as joins are handled via Preload
} }
// Default sort to primary key when none provided
if len(options.Sort) == 0 {
options.Sort = []common.SortOption{{Column: pkName, Direction: "ASC"}}
}
// Get cursor filter SQL // Get cursor filter SQL
cursorFilter, err := options.GetCursorFilter(tableName, pkName, modelColumns, expandJoins) cursorFilter, err := options.GetCursorFilter(tableName, pkName, modelColumns, expandJoins)
if err != nil { if err != nil {
@@ -730,7 +792,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
// Set row numbers on each record if the model has a RowNumber field // Set row numbers on each record if the model has a RowNumber field
h.setRowNumbersOnRecords(modelPtr, offset) // If FetchRowNumber was used, set the fetched row number instead of offset-based
if fetchedRowNumber != nil {
// FetchRowNumber: set the actual row position on the record
logger.Debug("FetchRowNumber: Setting row number %d on record", *fetchedRowNumber)
h.setRowNumbersOnRecords(modelPtr, int(*fetchedRowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
} else {
h.setRowNumbersOnRecords(modelPtr, offset)
}
metadata := &common.Metadata{ metadata := &common.Metadata{
Total: int64(total), Total: int64(total),
@@ -740,21 +809,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
Offset: offset, Offset: offset,
} }
// Fetch row number for a specific record if requested // If FetchRowNumber was used, also set it in metadata
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" { if fetchedRowNumber != nil {
pkName := reflection.GetPrimaryKeyName(model) metadata.RowNumber = fetchedRowNumber
pkValue := *options.FetchRowNumber logger.Debug("FetchRowNumber: Row number %d set in metadata", *fetchedRowNumber)
logger.Debug("Fetching row number for specific PK %s = %s", pkName, pkValue)
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, pkValue, options, model)
if err != nil {
logger.Warn("Failed to fetch row number: %v", err)
// Don't fail the entire request, just log the warning
} else {
metadata.RowNumber = &rowNum
logger.Debug("Row number for PK %s: %d", pkValue, rowNum)
}
} }
// Execute AfterRead hooks // Execute AfterRead hooks
@@ -1480,8 +1538,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %s: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %s: %v", itemID, err)
continue return fmt.Errorf("delete not allowed for ID %s: %w", itemID, err)
} }
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID) query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
@@ -1554,8 +1612,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %v: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
continue return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err)
} }
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID) query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
@@ -1612,8 +1670,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %v: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
continue return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err)
} }
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID) query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
@@ -2093,7 +2151,11 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
// Column is already cast to TEXT if needed // Column is already cast to TEXT if needed
return applyWhere(fmt.Sprintf("%s ILIKE ?", qualifiedColumn), filter.Value) return applyWhere(fmt.Sprintf("%s ILIKE ?", qualifiedColumn), filter.Value)
case "in": case "in":
return applyWhere(fmt.Sprintf("%s IN (?)", qualifiedColumn), filter.Value) cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
if cond == "" {
return query
}
return applyWhere(cond, inArgs...)
case "between": case "between":
// Handle between operator - exclusive (> val1 AND < val2) // Handle between operator - exclusive (> val1 AND < val2)
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 { if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
@@ -2169,24 +2231,25 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common
// buildFilterCondition builds a single filter condition and returns the condition string and args // buildFilterCondition builds a single filter condition and returns the condition string and args
func (h *Handler) buildFilterCondition(qualifiedColumn string, filter *common.FilterOption, tableName string) (filterStr string, filterInterface []interface{}) { func (h *Handler) buildFilterCondition(qualifiedColumn string, filter *common.FilterOption, tableName string) (filterStr string, filterInterface []interface{}) {
switch strings.ToLower(filter.Operator) { switch strings.ToLower(filter.Operator) {
case "eq", "equals": case "eq", "equals", "=":
return fmt.Sprintf("%s = ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s = ?", qualifiedColumn), []interface{}{filter.Value}
case "neq", "not_equals", "ne": case "neq", "not_equals", "ne", "!=", "<>":
return fmt.Sprintf("%s != ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s != ?", qualifiedColumn), []interface{}{filter.Value}
case "gt", "greater_than": case "gt", "greater_than", ">":
return fmt.Sprintf("%s > ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s > ?", qualifiedColumn), []interface{}{filter.Value}
case "gte", "greater_than_equals", "ge": case "gte", "greater_than_equals", "ge", ">=":
return fmt.Sprintf("%s >= ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s >= ?", qualifiedColumn), []interface{}{filter.Value}
case "lt", "less_than": case "lt", "less_than", "<":
return fmt.Sprintf("%s < ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s < ?", qualifiedColumn), []interface{}{filter.Value}
case "lte", "less_than_equals", "le": case "lte", "less_than_equals", "le", "<=":
return fmt.Sprintf("%s <= ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s <= ?", qualifiedColumn), []interface{}{filter.Value}
case "like": case "like":
return fmt.Sprintf("%s LIKE ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s LIKE ?", qualifiedColumn), []interface{}{filter.Value}
case "ilike": case "ilike":
return fmt.Sprintf("%s ILIKE ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s ILIKE ?", qualifiedColumn), []interface{}{filter.Value}
case "in": case "in":
return fmt.Sprintf("%s IN (?)", qualifiedColumn), []interface{}{filter.Value} cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
return cond, inArgs
case "between": case "between":
// Handle between operator - exclusive (> val1 AND < val2) // Handle between operator - exclusive (> val1 AND < val2)
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 { if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
@@ -2651,13 +2714,19 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
var result []struct { var result []struct {
RN int64 `bun:"rn"` RN int64 `bun:"rn"`
} }
logger.Debug("[FetchRowNumber] BEFORE Query call - about to execute raw query")
err := h.db.Query(ctx, &result, queryStr, pkValue) err := h.db.Query(ctx, &result, queryStr, pkValue)
logger.Debug("[FetchRowNumber] AFTER Query call - query completed with %d results, err: %v", len(result), err)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err) return 0, fmt.Errorf("failed to fetch row number: %w", err)
} }
if len(result) == 0 { if len(result) == 0 {
return 0, fmt.Errorf("no row found for primary key %s", pkValue) whereInfo := "none"
if whereSQL != "" {
whereInfo = whereSQL
}
return 0, fmt.Errorf("no row found for primary key %s=%s with active filters: %s", pkName, pkValue, whereInfo)
} }
return result[0].RN, nil return result[0].RN, nil
@@ -2815,6 +2884,8 @@ func (h *Handler) filterExtendedOptions(validator *common.ColumnValidator, optio
// Filter base RequestOptions // Filter base RequestOptions
filtered.RequestOptions = validator.FilterRequestOptions(options.RequestOptions) filtered.RequestOptions = validator.FilterRequestOptions(options.RequestOptions)
// Restore JoinAliases cleared by FilterRequestOptions — still needed for SanitizeWhereClause
filtered.RequestOptions.JoinAliases = options.JoinAliases
// Filter SearchColumns // Filter SearchColumns
filtered.SearchColumns = validator.FilterValidColumns(options.SearchColumns) filtered.SearchColumns = validator.FilterValidColumns(options.SearchColumns)

View File

@@ -1061,15 +1061,42 @@ func (h *Handler) addXFilesPreload(xfile *XFiles, options *ExtendedRequestOption
} }
} }
// Transfer SqlJoins from XFiles to PreloadOption first, so aliases are available for WHERE sanitization
if len(xfile.SqlJoins) > 0 {
preloadOpt.SqlJoins = make([]string, 0, len(xfile.SqlJoins))
preloadOpt.JoinAliases = make([]string, 0, len(xfile.SqlJoins))
for _, joinClause := range xfile.SqlJoins {
// Sanitize the join clause
sanitizedJoin := common.SanitizeWhereClause(joinClause, "", nil)
if sanitizedJoin == "" {
logger.Warn("X-Files: SqlJoin failed sanitization for %s: %s", relationPath, joinClause)
continue
}
preloadOpt.SqlJoins = append(preloadOpt.SqlJoins, sanitizedJoin)
// Extract join alias for validation
alias := extractJoinAlias(sanitizedJoin)
if alias != "" {
preloadOpt.JoinAliases = append(preloadOpt.JoinAliases, alias)
logger.Debug("X-Files: Extracted join alias for %s: %s", relationPath, alias)
}
}
logger.Debug("X-Files: Added %d SQL joins to preload %s", len(preloadOpt.SqlJoins), relationPath)
}
// Add WHERE clause if SQL conditions specified // Add WHERE clause if SQL conditions specified
// SqlJoins must be processed first so join aliases are known and not incorrectly replaced
whereConditions := make([]string, 0) whereConditions := make([]string, 0)
if len(xfile.SqlAnd) > 0 { if len(xfile.SqlAnd) > 0 {
// Process each SQL condition var sqlAndOpts *common.RequestOptions
// Note: We don't add table prefixes here because they're only needed for JOINs if len(preloadOpt.JoinAliases) > 0 {
// The handler will add prefixes later if SqlJoins are present sqlAndOpts = &common.RequestOptions{JoinAliases: preloadOpt.JoinAliases}
}
for _, sqlCond := range xfile.SqlAnd { for _, sqlCond := range xfile.SqlAnd {
// Sanitize the condition without adding prefixes sanitizedCond := common.SanitizeWhereClause(sqlCond, xfile.TableName, sqlAndOpts)
sanitizedCond := common.SanitizeWhereClause(sqlCond, xfile.TableName)
if sanitizedCond != "" { if sanitizedCond != "" {
whereConditions = append(whereConditions, sanitizedCond) whereConditions = append(whereConditions, sanitizedCond)
} }
@@ -1114,32 +1141,6 @@ func (h *Handler) addXFilesPreload(xfile *XFiles, options *ExtendedRequestOption
logger.Debug("X-Files: Set foreign key for %s: %s", relationPath, xfile.ForeignKey) logger.Debug("X-Files: Set foreign key for %s: %s", relationPath, xfile.ForeignKey)
} }
// Transfer SqlJoins from XFiles to PreloadOption
if len(xfile.SqlJoins) > 0 {
preloadOpt.SqlJoins = make([]string, 0, len(xfile.SqlJoins))
preloadOpt.JoinAliases = make([]string, 0, len(xfile.SqlJoins))
for _, joinClause := range xfile.SqlJoins {
// Sanitize the join clause
sanitizedJoin := common.SanitizeWhereClause(joinClause, "", nil)
if sanitizedJoin == "" {
logger.Warn("X-Files: SqlJoin failed sanitization for %s: %s", relationPath, joinClause)
continue
}
preloadOpt.SqlJoins = append(preloadOpt.SqlJoins, sanitizedJoin)
// Extract join alias for validation
alias := extractJoinAlias(sanitizedJoin)
if alias != "" {
preloadOpt.JoinAliases = append(preloadOpt.JoinAliases, alias)
logger.Debug("X-Files: Extracted join alias for %s: %s", relationPath, alias)
}
}
logger.Debug("X-Files: Added %d SQL joins to preload %s", len(preloadOpt.SqlJoins), relationPath)
}
// Check if this table has a recursive child - if so, mark THIS preload as recursive // Check if this table has a recursive child - if so, mark THIS preload as recursive
// and store the recursive child's RelatedKey for recursion generation // and store the recursive child's RelatedKey for recursion generation
hasRecursiveChild := false hasRecursiveChild := false

View File

@@ -12,6 +12,10 @@ import (
type HookType string type HookType string
const ( const (
// BeforeHandle fires after model resolution, before operation dispatch.
// Use this for auth checks that need model rules and user context simultaneously.
BeforeHandle HookType = "before_handle"
// Read operation hooks // Read operation hooks
BeforeRead HookType = "before_read" BeforeRead HookType = "before_read"
AfterRead HookType = "after_read" AfterRead HookType = "after_read"
@@ -42,6 +46,9 @@ type HookContext struct {
Model interface{} Model interface{}
Options ExtendedRequestOptions Options ExtendedRequestOptions
// Operation being dispatched (e.g. "read", "create", "update", "delete")
Operation string
// Operation-specific fields // Operation-specific fields
ID string ID string
Data interface{} // For create/update operations Data interface{} // For create/update operations
@@ -56,6 +63,14 @@ type HookContext struct {
// Response writer - allows hooks to modify response // Response writer - allows hooks to modify response
Writer common.ResponseWriter Writer common.ResponseWriter
// Request - the original HTTP request
Request common.Request
// Allow hooks to abort the operation
Abort bool // If set to true, the operation will be aborted
AbortMessage string // Message to return if aborted
AbortCode int // HTTP status code if aborted
// Tx provides access to the database/transaction for executing additional SQL // Tx provides access to the database/transaction for executing additional SQL
// This allows hooks to run custom queries in addition to the main Query chain // This allows hooks to run custom queries in addition to the main Query chain
Tx common.Database Tx common.Database
@@ -110,6 +125,12 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
logger.Error("Hook %d for %s failed: %v", i+1, hookType, err) logger.Error("Hook %d for %s failed: %v", i+1, hookType, err)
return fmt.Errorf("hook execution failed: %w", err) return fmt.Errorf("hook execution failed: %w", err)
} }
// Check if hook requested abort
if ctx.Abort {
logger.Warn("Hook %d for %s requested abort: %s", i+1, hookType, ctx.AbortMessage)
return fmt.Errorf("operation aborted by hook: %s", ctx.AbortMessage)
}
} }
// logger.Debug("All hooks for %s executed successfully", hookType) // logger.Debug("All hooks for %s executed successfully", hookType)

View File

@@ -125,17 +125,17 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
metadataPath := buildRoutePath(schema, entity) + "/metadata" metadataPath := buildRoutePath(schema, entity) + "/metadata"
// Create handler functions for this specific entity // Create handler functions for this specific entity
entityHandler := createMuxHandler(handler, schema, entity, "") var entityHandler http.Handler = createMuxHandler(handler, schema, entity, "")
entityWithIDHandler := createMuxHandler(handler, schema, entity, "id") var entityWithIDHandler http.Handler = createMuxHandler(handler, schema, entity, "id")
metadataHandler := createMuxGetHandler(handler, schema, entity, "") var metadataHandler http.Handler = createMuxGetHandler(handler, schema, entity, "")
optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"}) optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"})
optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "PUT", "PATCH", "DELETE", "POST", "OPTIONS"}) optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "PUT", "PATCH", "DELETE", "POST", "OPTIONS"})
// Apply authentication middleware if provided // Apply authentication middleware if provided
if authMiddleware != nil { if authMiddleware != nil {
entityHandler = authMiddleware(entityHandler).(http.HandlerFunc) entityHandler = authMiddleware(entityHandler)
entityWithIDHandler = authMiddleware(entityWithIDHandler).(http.HandlerFunc) entityWithIDHandler = authMiddleware(entityWithIDHandler)
metadataHandler = authMiddleware(metadataHandler).(http.HandlerFunc) metadataHandler = authMiddleware(metadataHandler)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth // Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
} }
@@ -280,9 +280,34 @@ type BunRouterHandler interface {
Handle(method, path string, handler bunrouter.HandlerFunc) Handle(method, path string, handler bunrouter.HandlerFunc)
} }
// wrapBunRouterHandler wraps a bunrouter handler with auth middleware if provided
func wrapBunRouterHandler(handler bunrouter.HandlerFunc, authMiddleware MiddlewareFunc) bunrouter.HandlerFunc {
if authMiddleware == nil {
return handler
}
return func(w http.ResponseWriter, req bunrouter.Request) error {
// Create an http.Handler that calls the bunrouter handler
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Replace the embedded *http.Request with the middleware-enriched one
// so that auth context (user ID, etc.) is visible to the handler.
enrichedReq := req
enrichedReq.Request = r
_ = handler(w, enrichedReq)
})
// Wrap with auth middleware and execute
wrappedHandler := authMiddleware(httpHandler)
wrappedHandler.ServeHTTP(w, req.Request)
return nil
}
}
// SetupBunRouterRoutes sets up bunrouter routes for the RestHeadSpec API // SetupBunRouterRoutes sets up bunrouter routes for the RestHeadSpec API
// Accepts bunrouter.Router or bunrouter.Group // Accepts bunrouter.Router or bunrouter.Group
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) { // authMiddleware is optional - if provided, routes will be protected with the middleware
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler, authMiddleware MiddlewareFunc) {
// CORS config // CORS config
corsConfig := common.DefaultCORSConfig() corsConfig := common.DefaultCORSConfig()
@@ -292,6 +317,14 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
handler.HandleOpenAPI(respAdapter, reqAdapter)
return nil
})
r.Handle("OPTIONS", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
return nil return nil
}) })
@@ -313,7 +346,7 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
currentEntity := entity currentEntity := entity
// GET and POST for /{schema}/{entity} // GET and POST for /{schema}/{entity}
r.Handle("GET", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error { getEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -324,9 +357,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("GET", entityPath, wrapBunRouterHandler(getEntityHandler, authMiddleware))
r.Handle("POST", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error { postEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -337,10 +371,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("POST", entityPath, wrapBunRouterHandler(postEntityHandler, authMiddleware))
// GET, POST, PUT, PATCH, DELETE for /{schema}/{entity}/:id // GET, POST, PUT, PATCH, DELETE for /{schema}/{entity}/:id
r.Handle("GET", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { getEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -352,9 +387,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("GET", entityWithIDPath, wrapBunRouterHandler(getEntityWithIDHandler, authMiddleware))
r.Handle("POST", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { postEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -366,9 +402,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("POST", entityWithIDPath, wrapBunRouterHandler(postEntityWithIDHandler, authMiddleware))
r.Handle("PUT", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { putEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -380,9 +417,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("PUT", entityWithIDPath, wrapBunRouterHandler(putEntityWithIDHandler, authMiddleware))
r.Handle("PATCH", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { patchEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -394,9 +432,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("PATCH", entityWithIDPath, wrapBunRouterHandler(patchEntityWithIDHandler, authMiddleware))
r.Handle("DELETE", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { deleteEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -408,10 +447,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params) handler.Handle(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("DELETE", entityWithIDPath, wrapBunRouterHandler(deleteEntityWithIDHandler, authMiddleware))
// Metadata endpoint // Metadata endpoint
r.Handle("GET", metadataPath, func(w http.ResponseWriter, req bunrouter.Request) error { metadataHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig) common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -422,9 +462,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.HandleGet(respAdapter, reqAdapter, params) handler.HandleGet(respAdapter, reqAdapter, params)
return nil return nil
}) }
r.Handle("GET", metadataPath, wrapBunRouterHandler(metadataHandler, authMiddleware))
// OPTIONS route without ID (returns metadata) // OPTIONS route without ID (returns metadata)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error { r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
@@ -441,6 +483,7 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
}) })
// OPTIONS route with ID (returns metadata) // OPTIONS route with ID (returns metadata)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error { r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w) respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req) reqAdapter := router.NewBunRouterRequest(req)
@@ -466,8 +509,8 @@ func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
// Create bunrouter // Create bunrouter
bunRouter := bunrouter.New() bunRouter := bunrouter.New()
// Setup routes // Setup routes without authentication
SetupBunRouterRoutes(bunRouter, handler) SetupBunRouterRoutes(bunRouter, handler, nil)
// Start server // Start server
if err := http.ListenAndServe(":8080", bunRouter); err != nil { if err := http.ListenAndServe(":8080", bunRouter); err != nil {
@@ -487,7 +530,7 @@ func ExampleBunRouterWithGroup(bunDB *bun.DB) {
apiGroup := bunRouter.NewGroup("/api") apiGroup := bunRouter.NewGroup("/api")
// Setup RestHeadSpec routes on the group - routes will be under /api // Setup RestHeadSpec routes on the group - routes will be under /api
SetupBunRouterRoutes(apiGroup, handler) SetupBunRouterRoutes(apiGroup, handler, nil)
// Start server // Start server
if err := http.ListenAndServe(":8080", bunRouter); err != nil { if err := http.ListenAndServe(":8080", bunRouter); err != nil {

View File

@@ -2,6 +2,7 @@ package restheadspec
import ( import (
"context" "context"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/security" "github.com/bitechdev/ResolveSpec/pkg/security"
@@ -9,6 +10,17 @@ import (
// RegisterSecurityHooks registers all security-related hooks with the handler // RegisterSecurityHooks registers all security-related hooks with the handler
func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) {
// Hook 0: BeforeHandle - enforce auth after model resolution
handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error {
if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil {
hookCtx.Abort = true
hookCtx.AbortMessage = err.Error()
hookCtx.AbortCode = http.StatusUnauthorized
return err
}
return nil
})
// Hook 1: BeforeRead - Load security rules // Hook 1: BeforeRead - Load security rules
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
@@ -33,6 +45,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
return security.LogDataAccess(secCtx) return security.LogDataAccess(secCtx)
}) })
// Hook 5: BeforeUpdate - enforce CanUpdate rule from context/registry
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelUpdateAllowed(secCtx)
})
// Hook 6: BeforeDelete - enforce CanDelete rule from context/registry
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelDeleteAllowed(secCtx)
})
logger.Info("Security hooks registered for restheadspec handler") logger.Info("Security hooks registered for restheadspec handler")
} }

View File

@@ -405,11 +405,16 @@ assert.Equal(t, "user_id = {UserID}", row.Template)
``` ```
HTTP Request HTTP Request
NewAuthMiddleware → calls provider.Authenticate() NewOptionalAuthMiddleware → calls provider.Authenticate()
↓ (adds UserContext to context) ↓ (adds UserContext or guest context; never 401)
SetSecurityMiddleware → adds SecurityList to context SetSecurityMiddleware → adds SecurityList to context
Handler.Handle() Handler.Handle() → resolves model
BeforeHandle Hook → CheckModelAuthAllowed(secCtx, operation)
├─ SecurityDisabled → allow
├─ CanPublicRead/Create/Update/Delete → allow unauthenticated
└─ UserID == 0 → abort 401
BeforeRead Hook → calls provider.GetColumnSecurity() + GetRowSecurity() BeforeRead Hook → calls provider.GetColumnSecurity() + GetRowSecurity()
@@ -693,15 +698,30 @@ http.Handle("/api/protected", authHandler)
optionalHandler := security.NewOptionalAuthHandler(securityList, myHandler) optionalHandler := security.NewOptionalAuthHandler(securityList, myHandler)
http.Handle("/home", optionalHandler) http.Handle("/home", optionalHandler)
// Example handler // NewOptionalAuthMiddleware - For spec routes; auth enforcement deferred to BeforeHandle
func myHandler(w http.ResponseWriter, r *http.Request) { apiRouter.Use(security.NewOptionalAuthMiddleware(securityList))
userCtx, _ := security.GetUserContext(r.Context()) apiRouter.Use(security.SetSecurityMiddleware(securityList))
if userCtx.UserID == 0 { restheadspec.RegisterSecurityHooks(handler, securityList) // includes BeforeHandle
// Guest user ```
} else {
// Authenticated user ---
}
} ## Model-Level Access Control
```go
// Register model with rules (pkg/modelregistry)
modelregistry.RegisterModelWithRules("public.products", &Product{}, modelregistry.ModelRules{
SecurityDisabled: false, // skip all auth when true
CanPublicRead: true, // unauthenticated reads allowed
CanPublicCreate: false, // requires auth
CanPublicUpdate: false, // requires auth
CanPublicDelete: false, // requires auth
CanUpdate: true, // authenticated can update
CanDelete: false, // authenticated cannot delete (enforced in BeforeDelete)
})
// CheckModelAuthAllowed used automatically in BeforeHandle hook
// No code needed — call RegisterSecurityHooks and it's applied
``` ```
--- ---

View File

@@ -751,14 +751,25 @@ resolvespec.RegisterSecurityHooks(resolveHandler, securityList)
``` ```
HTTP Request HTTP Request
NewAuthMiddleware (security package) NewOptionalAuthMiddleware (security package) ← recommended for spec routes
├─ Calls provider.Authenticate(request) ├─ Calls provider.Authenticate(request)
Adds UserContext to context On success: adds authenticated UserContext to context
└─ On failure: adds guest UserContext (UserID=0) to context
SetSecurityMiddleware (security package) SetSecurityMiddleware (security package)
└─ Adds SecurityList to context └─ Adds SecurityList to context
Spec Handler (restheadspec/funcspec/resolvespec) Spec Handler (restheadspec/funcspec/resolvespec/websocketspec/mqttspec)
└─ Resolves schema + entity + model from request
BeforeHandle Hook (registered by spec via RegisterSecurityHooks)
├─ Adapts spec's HookContext → SecurityContext
├─ Calls security.CheckModelAuthAllowed(secCtx, operation)
│ ├─ Loads model rules from context or registry
│ ├─ SecurityDisabled → allow
│ ├─ CanPublicRead/Create/Update/Delete → allow unauthenticated
│ └─ UserID == 0 → 401 unauthorized
└─ On error: aborts with 401
BeforeRead Hook (registered by spec) BeforeRead Hook (registered by spec)
├─ Adapts spec's HookContext → SecurityContext ├─ Adapts spec's HookContext → SecurityContext
@@ -784,7 +795,8 @@ HTTP Response (secured data)
``` ```
**Key Points:** **Key Points:**
- Security package is spec-agnostic and provides core logic - `NewOptionalAuthMiddleware` never rejects — it sets guest context on auth failure; `BeforeHandle` enforces auth after model resolution
- `BeforeHandle` fires after model resolution, giving access to model rules and user context simultaneously
- Each spec registers its own hooks that adapt to SecurityContext - Each spec registers its own hooks that adapt to SecurityContext
- Security rules are loaded once and cached for the request - Security rules are loaded once and cached for the request
- Row security is applied to the query (database level) - Row security is applied to the query (database level)
@@ -1002,15 +1014,49 @@ func (p *MyProvider) GetRowSecurity(ctx context.Context, userID int, schema, tab
} }
``` ```
## Model-Level Access Control
Use `ModelRules` (from `pkg/modelregistry`) to control per-entity auth behavior:
```go
modelregistry.RegisterModelWithRules("public.products", &Product{}, modelregistry.ModelRules{
SecurityDisabled: false, // true = skip all auth checks
CanPublicRead: true, // unauthenticated GET allowed
CanPublicCreate: false, // requires auth
CanPublicUpdate: false, // requires auth
CanPublicDelete: false, // requires auth
CanUpdate: true, // authenticated users can update
CanDelete: false, // authenticated users cannot delete
})
```
`CheckModelAuthAllowed(secCtx, operation)` applies these rules in `BeforeHandle`:
1. `SecurityDisabled` → allow all
2. `CanPublicRead/Create/Update/Delete` → allow unauthenticated for that operation
3. Guest (UserID == 0) → return 401
4. Authenticated → allow (operation-specific `CanUpdate`/`CanDelete` checked in `BeforeUpdate`/`BeforeDelete`)
---
## Middleware and Handler API ## Middleware and Handler API
### NewAuthMiddleware ### NewAuthMiddleware
Standard middleware that authenticates all requests: Standard middleware that authenticates all requests and returns 401 on failure:
```go ```go
router.Use(security.NewAuthMiddleware(securityList)) router.Use(security.NewAuthMiddleware(securityList))
``` ```
### NewOptionalAuthMiddleware
Middleware for spec routes — always continues; sets guest context on auth failure:
```go
// Use with RegisterSecurityHooks — auth enforcement is deferred to BeforeHandle
apiRouter.Use(security.NewOptionalAuthMiddleware(securityList))
apiRouter.Use(security.SetSecurityMiddleware(securityList))
restheadspec.RegisterSecurityHooks(handler, securityList) // registers BeforeHandle
```
Routes can skip authentication using the `SkipAuth` helper: Routes can skip authentication using the `SkipAuth` helper:
```go ```go

View File

@@ -6,6 +6,7 @@ import (
"reflect" "reflect"
"github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
) )
// SecurityContext is a generic interface that any spec can implement to integrate with security features // SecurityContext is a generic interface that any spec can implement to integrate with security features
@@ -226,6 +227,122 @@ func ApplyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) err
return applyColumnSecurity(secCtx, securityList) return applyColumnSecurity(secCtx, securityList)
} }
// checkModelUpdateAllowed returns an error if CanUpdate is false for the model.
// Rules are read from context (set by NewModelAuthMiddleware) with a fallback to the model registry.
func checkModelUpdateAllowed(secCtx SecurityContext) error {
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
if !ok {
schema := secCtx.GetSchema()
entity := secCtx.GetEntity()
var err error
if schema != "" {
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
}
if err != nil || schema == "" {
rules, err = modelregistry.GetModelRulesByName(entity)
}
if err != nil {
return nil // model not registered, allow by default
}
}
if !rules.CanUpdate {
return fmt.Errorf("update not allowed for %s", secCtx.GetEntity())
}
return nil
}
// checkModelDeleteAllowed returns an error if CanDelete is false for the model.
// Rules are read from context (set by NewModelAuthMiddleware) with a fallback to the model registry.
func checkModelDeleteAllowed(secCtx SecurityContext) error {
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
if !ok {
schema := secCtx.GetSchema()
entity := secCtx.GetEntity()
var err error
if schema != "" {
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
}
if err != nil || schema == "" {
rules, err = modelregistry.GetModelRulesByName(entity)
}
if err != nil {
return nil // model not registered, allow by default
}
}
if !rules.CanDelete {
return fmt.Errorf("delete not allowed for %s", secCtx.GetEntity())
}
return nil
}
// CheckModelAuthAllowed checks whether the requested operation is permitted based on
// model rules and the current user's authentication state. It is intended for use in
// a BeforeHandle hook, fired after model resolution.
//
// Logic:
// 1. Load model rules from context (set by NewModelAuthMiddleware) or fall back to registry.
// 2. SecurityDisabled → allow.
// 3. operation == "read" && CanPublicRead → allow.
// 4. operation == "create" && CanPublicCreate → allow.
// 5. operation == "update" && CanPublicUpdate → allow.
// 6. operation == "delete" && CanPublicDelete → allow.
// 7. Guest (UserID == 0) → return "authentication required".
// 8. Authenticated user → allow (operation-specific checks remain in BeforeUpdate/BeforeDelete).
func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error {
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
if !ok {
schema := secCtx.GetSchema()
entity := secCtx.GetEntity()
var err error
if schema != "" {
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
}
if err != nil || schema == "" {
rules, err = modelregistry.GetModelRulesByName(entity)
}
if err != nil {
// Model not registered - fall through to auth check
userID, _ := secCtx.GetUserID()
if userID == 0 {
return fmt.Errorf("authentication required")
}
return nil
}
}
if rules.SecurityDisabled {
return nil
}
if operation == "read" && rules.CanPublicRead {
return nil
}
if operation == "create" && rules.CanPublicCreate {
return nil
}
if operation == "update" && rules.CanPublicUpdate {
return nil
}
if operation == "delete" && rules.CanPublicDelete {
return nil
}
userID, _ := secCtx.GetUserID()
if userID == 0 {
return fmt.Errorf("authentication required")
}
return nil
}
// CheckModelUpdateAllowed is the public wrapper for checkModelUpdateAllowed.
func CheckModelUpdateAllowed(secCtx SecurityContext) error {
return checkModelUpdateAllowed(secCtx)
}
// CheckModelDeleteAllowed is the public wrapper for checkModelDeleteAllowed.
func CheckModelDeleteAllowed(secCtx SecurityContext) error {
return checkModelDeleteAllowed(secCtx)
}
// Helper functions // Helper functions
func contains(s, substr string) bool { func contains(s, substr string) bool {

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
) )
// contextKey is a custom type for context keys to avoid collisions // contextKey is a custom type for context keys to avoid collisions
@@ -23,6 +25,7 @@ const (
UserMetaKey contextKey = "user_meta" UserMetaKey contextKey = "user_meta"
SkipAuthKey contextKey = "skip_auth" SkipAuthKey contextKey = "skip_auth"
OptionalAuthKey contextKey = "optional_auth" OptionalAuthKey contextKey = "optional_auth"
ModelRulesKey contextKey = "model_rules"
) )
// SkipAuth returns a context with skip auth flag set to true // SkipAuth returns a context with skip auth flag set to true
@@ -136,6 +139,31 @@ func NewOptionalAuthHandler(securityList *SecurityList, next http.Handler) http.
}) })
} }
// NewOptionalAuthMiddleware creates authentication middleware that always continues.
// On auth failure, a guest user context is set instead of returning 401.
// Intended for spec routes where auth enforcement is deferred to a BeforeHandle hook
// after model resolution.
func NewOptionalAuthMiddleware(securityList *SecurityList) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
provider := securityList.Provider()
if provider == nil {
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
return
}
userCtx, err := provider.Authenticate(r)
if err != nil {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
next.ServeHTTP(w, setUserContext(r, userCtx))
})
}
}
// NewAuthMiddleware creates an authentication middleware with the given security list // NewAuthMiddleware creates an authentication middleware with the given security list
// This middleware extracts user authentication from the request and adds it to context // This middleware extracts user authentication from the request and adds it to context
// Routes can skip authentication by setting SkipAuthKey context value (use SkipAuth helper) // Routes can skip authentication by setting SkipAuthKey context value (use SkipAuth helper)
@@ -182,6 +210,68 @@ func NewAuthMiddleware(securityList *SecurityList) func(http.Handler) http.Handl
} }
} }
// NewModelAuthMiddleware creates authentication middleware that respects ModelRules for the given model name.
// It first checks if ModelRules are set for the model:
// - If SecurityDisabled is true, authentication is skipped and a guest context is set.
// - Otherwise, all checks from NewAuthMiddleware apply (SkipAuthKey, provider check, OptionalAuthKey, Authenticate).
//
// If the model is not found in any registry, the middleware falls back to standard NewAuthMiddleware behaviour.
func NewModelAuthMiddleware(securityList *SecurityList, modelName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check ModelRules first
if rules, err := modelregistry.GetModelRulesByName(modelName); err == nil {
// Store rules in context for downstream use (e.g., security hooks)
r = r.WithContext(context.WithValue(r.Context(), ModelRulesKey, rules))
if rules.SecurityDisabled {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
isRead := r.Method == http.MethodGet || r.Method == http.MethodHead
isUpdate := r.Method == http.MethodPut || r.Method == http.MethodPatch
if (isRead && rules.CanPublicRead) || (isUpdate && rules.CanPublicUpdate) {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
}
// Check if this route should skip authentication
if skip, ok := r.Context().Value(SkipAuthKey).(bool); ok && skip {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
// Get the security provider
provider := securityList.Provider()
if provider == nil {
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
return
}
// Check if this route has optional authentication
optional, _ := r.Context().Value(OptionalAuthKey).(bool)
// Try to authenticate
userCtx, err := provider.Authenticate(r)
if err != nil {
if optional {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
return
}
next.ServeHTTP(w, setUserContext(r, userCtx))
})
}
}
// SetSecurityMiddleware adds security context to requests // SetSecurityMiddleware adds security context to requests
// This middleware should be applied after AuthMiddleware // This middleware should be applied after AuthMiddleware
func SetSecurityMiddleware(securityList *SecurityList) func(http.Handler) http.Handler { func SetSecurityMiddleware(securityList *SecurityList) func(http.Handler) http.Handler {
@@ -366,6 +456,131 @@ func GetUserMeta(ctx context.Context) (map[string]any, bool) {
return meta, ok return meta, ok
} }
// SessionCookieOptions configures the session cookie set by SetSessionCookie.
// All fields are optional; sensible secure defaults are applied when omitted.
type SessionCookieOptions struct {
// Name is the cookie name. Defaults to "session_token".
Name string
// Path is the cookie path. Defaults to "/".
Path string
// Domain restricts the cookie to a specific domain. Empty means current host.
Domain string
// Secure sets the Secure flag. Defaults to true.
// Set to false only in local development over HTTP.
Secure *bool
// SameSite sets the SameSite policy. Defaults to http.SameSiteLaxMode.
SameSite http.SameSite
}
func (o SessionCookieOptions) name() string {
if o.Name != "" {
return o.Name
}
return "session_token"
}
func (o SessionCookieOptions) path() string {
if o.Path != "" {
return o.Path
}
return "/"
}
func (o SessionCookieOptions) secure() bool {
if o.Secure != nil {
return *o.Secure
}
return true
}
func (o SessionCookieOptions) sameSite() http.SameSite {
if o.SameSite != 0 {
return o.SameSite
}
return http.SameSiteLaxMode
}
// SetSessionCookie writes the session_token cookie to the response after a successful login.
// Call this immediately after a successful Authenticator.Login() call.
//
// Example:
//
// resp, err := auth.Login(r.Context(), req)
// if err != nil { ... }
// security.SetSessionCookie(w, resp)
// json.NewEncoder(w).Encode(resp)
func SetSessionCookie(w http.ResponseWriter, loginResp *LoginResponse, opts ...SessionCookieOptions) {
var o SessionCookieOptions
if len(opts) > 0 {
o = opts[0]
}
maxAge := 0
if loginResp.ExpiresIn > 0 {
maxAge = int(loginResp.ExpiresIn)
}
http.SetCookie(w, &http.Cookie{
Name: o.name(),
Value: loginResp.Token,
Path: o.path(),
Domain: o.Domain,
MaxAge: maxAge,
HttpOnly: true,
Secure: o.secure(),
SameSite: o.sameSite(),
})
}
// GetSessionCookie returns the session token value from the request cookie, or empty string if not present.
//
// Example:
//
// token := security.GetSessionCookie(r)
func GetSessionCookie(r *http.Request, opts ...SessionCookieOptions) string {
var o SessionCookieOptions
if len(opts) > 0 {
o = opts[0]
}
cookie, err := r.Cookie(o.name())
if err != nil {
return ""
}
return cookie.Value
}
// ClearSessionCookie expires the session_token cookie, effectively logging the user out on the browser side.
// Call this after a successful Authenticator.Logout() call.
//
// Example:
//
// err := auth.Logout(r.Context(), req)
// if err != nil { ... }
// security.ClearSessionCookie(w)
func ClearSessionCookie(w http.ResponseWriter, opts ...SessionCookieOptions) {
var o SessionCookieOptions
if len(opts) > 0 {
o = opts[0]
}
http.SetCookie(w, &http.Cookie{
Name: o.name(),
Value: "",
Path: o.path(),
Domain: o.Domain,
MaxAge: -1,
HttpOnly: true,
Secure: o.secure(),
SameSite: o.sameSite(),
})
}
// GetModelRulesFromContext extracts ModelRules stored by NewModelAuthMiddleware
func GetModelRulesFromContext(ctx context.Context) (modelregistry.ModelRules, bool) {
rules, ok := ctx.Value(ModelRulesKey).(modelregistry.ModelRules)
return rules, ok
}
// // Handler adapters for resolvespec/restheadspec compatibility // // Handler adapters for resolvespec/restheadspec compatibility
// // These functions allow using NewAuthHandler and NewOptionalAuthHandler with custom handler abstractions // // These functions allow using NewAuthHandler and NewOptionalAuthHandler with custom handler abstractions

View File

@@ -222,9 +222,8 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err
if sessionToken == "" { if sessionToken == "" {
// Try cookie // Try cookie
cookie, err := r.Cookie("session_token") if token := GetSessionCookie(r); token != "" {
if err == nil { tokens = []string{token}
tokens = []string{cookie.Value}
reference = "cookie" reference = "cookie"
} }
} else { } else {

View File

@@ -98,6 +98,7 @@ func (p *EmbedFSProvider) Open(name string) (fs.File, error) {
// Apply prefix stripping by prepending the prefix to the requested path // Apply prefix stripping by prepending the prefix to the requested path
actualPath := name actualPath := name
alternatePath := ""
if p.stripPrefix != "" { if p.stripPrefix != "" {
// Clean the paths to handle leading/trailing slashes // Clean the paths to handle leading/trailing slashes
prefix := strings.Trim(p.stripPrefix, "/") prefix := strings.Trim(p.stripPrefix, "/")
@@ -105,12 +106,25 @@ func (p *EmbedFSProvider) Open(name string) (fs.File, error) {
if prefix != "" { if prefix != "" {
actualPath = path.Join(prefix, cleanName) actualPath = path.Join(prefix, cleanName)
alternatePath = cleanName
} else { } else {
actualPath = cleanName actualPath = cleanName
} }
} }
// First try the actual path with prefix
if file, err := p.fs.Open(actualPath); err == nil {
return file, nil
}
return p.fs.Open(actualPath) // If alternate path is different, try it as well
if alternatePath != "" && alternatePath != actualPath {
if file, err := p.fs.Open(alternatePath); err == nil {
return file, nil
}
}
// If both attempts fail, return the error from the first attempt
return nil, fmt.Errorf("file not found: %s", name)
} }
// Close releases any resources held by the provider. // Close releases any resources held by the provider.

View File

@@ -53,6 +53,7 @@ func (p *LocalFSProvider) Open(name string) (fs.File, error) {
// Apply prefix stripping by prepending the prefix to the requested path // Apply prefix stripping by prepending the prefix to the requested path
actualPath := name actualPath := name
alternatePath := ""
if p.stripPrefix != "" { if p.stripPrefix != "" {
// Clean the paths to handle leading/trailing slashes // Clean the paths to handle leading/trailing slashes
prefix := strings.Trim(p.stripPrefix, "/") prefix := strings.Trim(p.stripPrefix, "/")
@@ -60,12 +61,26 @@ func (p *LocalFSProvider) Open(name string) (fs.File, error) {
if prefix != "" { if prefix != "" {
actualPath = path.Join(prefix, cleanName) actualPath = path.Join(prefix, cleanName)
alternatePath = cleanName
} else { } else {
actualPath = cleanName actualPath = cleanName
} }
} }
return p.fs.Open(actualPath) // First try the actual path with prefix
if file, err := p.fs.Open(actualPath); err == nil {
return file, nil
}
// If alternate path is different, try it as well
if alternatePath != "" && alternatePath != actualPath {
if file, err := p.fs.Open(alternatePath); err == nil {
return file, nil
}
}
// If both attempts fail, return the error from the first attempt
return nil, fmt.Errorf("file not found: %s", name)
} }
// Close releases any resources held by the provider. // Close releases any resources held by the provider.

View File

@@ -56,6 +56,7 @@ func (p *ZipFSProvider) Open(name string) (fs.File, error) {
// Apply prefix stripping by prepending the prefix to the requested path // Apply prefix stripping by prepending the prefix to the requested path
actualPath := name actualPath := name
alternatePath := ""
if p.stripPrefix != "" { if p.stripPrefix != "" {
// Clean the paths to handle leading/trailing slashes // Clean the paths to handle leading/trailing slashes
prefix := strings.Trim(p.stripPrefix, "/") prefix := strings.Trim(p.stripPrefix, "/")
@@ -63,12 +64,26 @@ func (p *ZipFSProvider) Open(name string) (fs.File, error) {
if prefix != "" { if prefix != "" {
actualPath = path.Join(prefix, cleanName) actualPath = path.Join(prefix, cleanName)
alternatePath = cleanName
} else { } else {
actualPath = cleanName actualPath = cleanName
} }
} }
return p.zipFS.Open(actualPath) // First try the actual path with prefix
if file, err := p.zipFS.Open(actualPath); err == nil {
return file, nil
}
// If alternate path is different, try it as well
if alternatePath != "" && alternatePath != actualPath {
if file, err := p.zipFS.Open(alternatePath); err == nil {
return file, nil
}
}
// If both attempts fail, return the error from the first attempt
return nil, fmt.Errorf("file not found: %s", name)
} }
// Close releases resources held by the zip reader. // Close releases resources held by the zip reader.

View File

@@ -330,6 +330,7 @@ Hooks allow you to intercept and modify operations at various points in the life
### Available Hook Types ### Available Hook Types
- **BeforeHandle** — fires after model resolution, before operation dispatch (auth checks)
- **BeforeRead** / **AfterRead** - **BeforeRead** / **AfterRead**
- **BeforeCreate** / **AfterCreate** - **BeforeCreate** / **AfterCreate**
- **BeforeUpdate** / **AfterUpdate** - **BeforeUpdate** / **AfterUpdate**
@@ -337,6 +338,8 @@ Hooks allow you to intercept and modify operations at various points in the life
- **BeforeSubscribe** / **AfterSubscribe** - **BeforeSubscribe** / **AfterSubscribe**
- **BeforeConnect** / **AfterConnect** - **BeforeConnect** / **AfterConnect**
`HookContext` includes `Operation string` (`"read"`, `"create"`, `"update"`, `"delete"`) and `Abort bool`, `AbortMessage string`, `AbortCode int` for abort signaling.
### Hook Example ### Hook Example
```go ```go
@@ -599,7 +602,19 @@ asyncio.run(main())
## Authentication ## Authentication
Implement authentication using hooks: Use `RegisterSecurityHooks` for integrated auth with model-rule support:
```go
import "github.com/bitechdev/ResolveSpec/pkg/security"
provider := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
securityList := security.NewSecurityList(provider)
websocketspec.RegisterSecurityHooks(handler, securityList)
// Registers BeforeHandle (model auth), BeforeRead (load rules),
// AfterRead (column security + audit), BeforeUpdate, BeforeDelete
```
Or implement custom authentication using hooks directly:
```go ```go
handler := websocketspec.NewHandlerWithGORM(db) handler := websocketspec.NewHandlerWithGORM(db)

View File

@@ -177,6 +177,16 @@ func (h *Handler) handleRequest(conn *Connection, msg *Message) {
Metadata: make(map[string]interface{}), Metadata: make(map[string]interface{}),
} }
// Execute BeforeHandle hook - auth check fires here, after model resolution
hookCtx.Operation = string(msg.Operation)
if err := h.hooks.Execute(BeforeHandle, hookCtx); err != nil {
if hookCtx.Abort {
errResp := NewErrorResponse(msg.ID, "unauthorized", hookCtx.AbortMessage)
_ = conn.SendJSON(errResp)
}
return
}
// Route to operation handler // Route to operation handler
switch msg.Operation { switch msg.Operation {
case OperationRead: case OperationRead:
@@ -210,10 +220,14 @@ func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContex
var metadata map[string]interface{} var metadata map[string]interface{}
var err error var err error
if hookCtx.ID != "" { // Check if FetchRowNumber is specified (treat as single record read)
// Read single record by ID isFetchRowNumber := hookCtx.Options != nil && hookCtx.Options.FetchRowNumber != nil && *hookCtx.Options.FetchRowNumber != ""
if hookCtx.ID != "" || isFetchRowNumber {
// Read single record by ID or FetchRowNumber
data, err = h.readByID(hookCtx) data, err = h.readByID(hookCtx)
metadata = map[string]interface{}{"total": 1} metadata = map[string]interface{}{"total": 1}
// The row number is already set on the record itself via setRowNumbersOnRecords
} else { } else {
// Read multiple records // Read multiple records
data, metadata, err = h.readMultiple(hookCtx) data, metadata, err = h.readMultiple(hookCtx)
@@ -510,10 +524,29 @@ func (h *Handler) notifySubscribers(schema, entity string, operation OperationTy
// CRUD operation implementations // CRUD operation implementations
func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) { func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
// Handle FetchRowNumber before building query
var fetchedRowNumber *int64
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
if hookCtx.Options != nil && hookCtx.Options.FetchRowNumber != nil && *hookCtx.Options.FetchRowNumber != "" {
fetchRowNumberPKValue := *hookCtx.Options.FetchRowNumber
logger.Debug("[WebSocketSpec] FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
rowNum, err := h.FetchRowNumber(hookCtx.Context, hookCtx.TableName, pkName, fetchRowNumberPKValue, hookCtx.Options, hookCtx.Model)
if err != nil {
return nil, fmt.Errorf("failed to fetch row number: %w", err)
}
fetchedRowNumber = &rowNum
logger.Debug("[WebSocketSpec] FetchRowNumber: Row number %d for PK %s = %s", rowNum, pkName, fetchRowNumberPKValue)
// Override ID with FetchRowNumber value
hookCtx.ID = fetchRowNumberPKValue
}
query := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) query := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
// Add ID filter // Add ID filter
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID) query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID)
// Apply columns // Apply columns
@@ -533,6 +566,12 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
return nil, fmt.Errorf("failed to read record: %w", err) return nil, fmt.Errorf("failed to read record: %w", err)
} }
// Set the fetched row number on the record if FetchRowNumber was used
if fetchedRowNumber != nil {
logger.Debug("[WebSocketSpec] FetchRowNumber: Setting row number %d on record", *fetchedRowNumber)
h.setRowNumbersOnRecords(hookCtx.ModelPtr, int(*fetchedRowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
}
return hookCtx.ModelPtr, nil return hookCtx.ModelPtr, nil
} }
@@ -589,7 +628,10 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
if hookCtx.Options != nil { if hookCtx.Options != nil {
for _, filter := range hookCtx.Options.Filters { for _, filter := range hookCtx.Options.Filters {
countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) cond, args := h.buildFilterCondition(filter)
if cond != "" {
countQuery = countQuery.Where(cond, args...)
}
} }
} }
count, _ := countQuery.Count(hookCtx.Context) count, _ := countQuery.Count(hookCtx.Context)
@@ -761,14 +803,12 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
// buildFilterCondition builds a filter condition and returns it with args // buildFilterCondition builds a filter condition and returns it with args
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) { func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
var condition string if strings.EqualFold(filter.Operator, "in") {
var args []interface{} cond, args := common.BuildInCondition(filter.Column, filter.Value)
return cond, args
}
operatorSQL := h.getOperatorSQL(filter.Operator) operatorSQL := h.getOperatorSQL(filter.Operator)
condition = fmt.Sprintf("%s %s ?", filter.Column, operatorSQL) return fmt.Sprintf("%s %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
args = []interface{}{filter.Value}
return condition, args
} }
// setRowNumbersOnRecords sets the RowNumber field on each record if it exists // setRowNumbersOnRecords sets the RowNumber field on each record if it exists
@@ -841,6 +881,92 @@ func (h *Handler) getOperatorSQL(operator string) string {
} }
} }
// FetchRowNumber calculates the row number of a specific record based on sorting and filtering
// Returns the 1-based row number of the record with the given primary key value
func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName string, pkValue string, options *common.RequestOptions, model interface{}) (int64, error) {
defer func() {
if r := recover(); r != nil {
logger.Error("[WebSocketSpec] Panic during FetchRowNumber: %v", r)
}
}()
// Build the sort order SQL
sortSQL := ""
if options != nil && len(options.Sort) > 0 {
sortParts := make([]string, 0, len(options.Sort))
for _, sort := range options.Sort {
if sort.Column == "" {
continue
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
sortParts = append(sortParts, fmt.Sprintf("%s %s", sort.Column, direction))
}
sortSQL = strings.Join(sortParts, ", ")
} else {
// Default sort by primary key
sortSQL = fmt.Sprintf("%s ASC", pkName)
}
// Build WHERE clause from filters
whereSQL := ""
var whereArgs []interface{}
if options != nil && len(options.Filters) > 0 {
var conditions []string
for _, filter := range options.Filters {
operatorSQL := h.getOperatorSQL(filter.Operator)
conditions = append(conditions, fmt.Sprintf("%s.%s %s ?", tableName, filter.Column, operatorSQL))
whereArgs = append(whereArgs, filter.Value)
}
if len(conditions) > 0 {
whereSQL = "WHERE " + strings.Join(conditions, " AND ")
}
}
// Build the final query with parameterized PK value
queryStr := fmt.Sprintf(`
SELECT search.rn
FROM (
SELECT %[1]s.%[2]s,
ROW_NUMBER() OVER(ORDER BY %[3]s) AS rn
FROM %[1]s
%[4]s
) search
WHERE search.%[2]s = ?
`,
tableName, // [1] - table name
pkName, // [2] - primary key column name
sortSQL, // [3] - sort order SQL
whereSQL, // [4] - WHERE clause
)
logger.Debug("[WebSocketSpec] FetchRowNumber query: %s, pkValue: %s", queryStr, pkValue)
// Append PK value to whereArgs
whereArgs = append(whereArgs, pkValue)
// Execute the raw query with parameterized PK value
var result []struct {
RN int64 `bun:"rn"`
}
err := h.db.Query(ctx, &result, queryStr, whereArgs...)
if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err)
}
if len(result) == 0 {
whereInfo := "none"
if whereSQL != "" {
whereInfo = whereSQL
}
return 0, fmt.Errorf("no row found for primary key %s=%s with active filters: %s", pkName, pkValue, whereInfo)
}
return result[0].RN, nil
}
// Shutdown gracefully shuts down the handler // Shutdown gracefully shuts down the handler
func (h *Handler) Shutdown() { func (h *Handler) Shutdown() {
h.connManager.Shutdown() h.connManager.Shutdown()

View File

@@ -2,6 +2,7 @@ package websocketspec
import ( import (
"context" "context"
"fmt"
"github.com/bitechdev/ResolveSpec/pkg/common" "github.com/bitechdev/ResolveSpec/pkg/common"
) )
@@ -10,6 +11,10 @@ import (
type HookType string type HookType string
const ( const (
// BeforeHandle fires after model resolution, before operation dispatch.
// Use this for auth checks that need model rules and user context simultaneously.
BeforeHandle HookType = "before_handle"
// BeforeRead is called before a read operation // BeforeRead is called before a read operation
BeforeRead HookType = "before_read" BeforeRead HookType = "before_read"
// AfterRead is called after a read operation // AfterRead is called after a read operation
@@ -83,6 +88,9 @@ type HookContext struct {
// Options contains the parsed request options // Options contains the parsed request options
Options *common.RequestOptions Options *common.RequestOptions
// Operation being dispatched (e.g. "read", "create", "update", "delete")
Operation string
// ID is the record ID for single-record operations // ID is the record ID for single-record operations
ID string ID string
@@ -98,6 +106,11 @@ type HookContext struct {
// Error is any error that occurred (for after hooks) // Error is any error that occurred (for after hooks)
Error error Error error
// Allow hooks to abort the operation
Abort bool // If set to true, the operation will be aborted
AbortMessage string // Message to return if aborted
AbortCode int // HTTP status code if aborted
// Metadata is additional context data // Metadata is additional context data
Metadata map[string]interface{} Metadata map[string]interface{}
} }
@@ -171,6 +184,11 @@ func (hr *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
if err := hook(ctx); err != nil { if err := hook(ctx); err != nil {
return err return err
} }
// Check if hook requested abort
if ctx.Abort {
return fmt.Errorf("operation aborted by hook: %s", ctx.AbortMessage)
}
} }
return nil return nil

View File

@@ -0,0 +1,108 @@
package websocketspec
import (
"context"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
// RegisterSecurityHooks registers all security-related hooks with the handler
func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) {
// Hook 0: BeforeHandle - enforce auth after model resolution
handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error {
if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil {
hookCtx.Abort = true
hookCtx.AbortMessage = err.Error()
hookCtx.AbortCode = http.StatusUnauthorized
return err
}
return nil
})
// Hook 1: BeforeRead - Load security rules
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.LoadSecurityRules(secCtx, securityList)
})
// Hook 2: AfterRead - Apply column-level security (masking)
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.ApplyColumnSecurity(secCtx, securityList)
})
// Hook 3 (Optional): Audit logging
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.LogDataAccess(secCtx)
})
// Hook 4: BeforeUpdate - enforce CanUpdate rule from context/registry
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelUpdateAllowed(secCtx)
})
// Hook 5: BeforeDelete - enforce CanDelete rule from context/registry
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelDeleteAllowed(secCtx)
})
logger.Info("Security hooks registered for websocketspec handler")
}
// securityContext adapts websocketspec.HookContext to security.SecurityContext interface
type securityContext struct {
ctx *HookContext
}
func newSecurityContext(ctx *HookContext) security.SecurityContext {
return &securityContext{ctx: ctx}
}
func (s *securityContext) GetContext() context.Context {
return s.ctx.Context
}
func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context)
}
func (s *securityContext) GetSchema() string {
return s.ctx.Schema
}
func (s *securityContext) GetEntity() string {
return s.ctx.Entity
}
func (s *securityContext) GetModel() interface{} {
return s.ctx.Model
}
// GetQuery retrieves a stored query from hook metadata (websocketspec has no Query field)
func (s *securityContext) GetQuery() interface{} {
if s.ctx.Metadata == nil {
return nil
}
return s.ctx.Metadata["query"]
}
// SetQuery stores the query in hook metadata
func (s *securityContext) SetQuery(query interface{}) {
if s.ctx.Metadata == nil {
s.ctx.Metadata = make(map[string]interface{})
}
s.ctx.Metadata["query"] = query
}
func (s *securityContext) GetResult() interface{} {
return s.ctx.Result
}
func (s *securityContext) SetResult(result interface{}) {
s.ctx.Result = result
}

View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -0,0 +1,7 @@
# @warkypublic/resolvespec-js
## 1.0.1
### Patch Changes
- Fixed headerpsec

132
resolvespec-js/PLAN.md Normal file
View File

@@ -0,0 +1,132 @@
# ResolveSpec JS - Implementation Plan
TypeScript client library for ResolveSpec, RestHeaderSpec, WebSocket and MQTT APIs.
---
## Status
| Phase | Description | Status |
|-------|-------------|--------|
| 0 | Restructure into folders | Done |
| 1 | Fix types (align with Go) | Done |
| 2 | Fix REST client | Done |
| 3 | Build config | Done |
| 4 | Tests | Done |
| 5 | HeaderSpec client | Done |
| 6 | MQTT client | Planned |
| 6.5 | Unified class pattern + singleton factories | Done |
| 7 | Response cache (TTL) | Planned |
| 8 | TanStack Query integration | Planned |
| 9 | React Hooks | Planned |
**Build:** `dist/index.js` (ES) + `dist/index.cjs` (CJS) + `.d.ts` declarations
**Tests:** 65 passing (common: 10, resolvespec: 13, websocketspec: 15, headerspec: 27)
---
## Folder Structure
```
src/
├── common/
│ ├── types.ts # Core types aligned with Go pkg/common/types.go
│ └── index.ts
├── resolvespec/
│ ├── client.ts # ResolveSpecClient class + createResolveSpecClient singleton
│ └── index.ts
├── headerspec/
│ ├── client.ts # HeaderSpecClient class + createHeaderSpecClient singleton + buildHeaders utility
│ └── index.ts
├── websocketspec/
│ ├── types.ts # WS-specific types (WSMessage, WSOptions, etc.)
│ ├── client.ts # WebSocketClient class + createWebSocketClient singleton
│ └── index.ts
├── mqttspec/ # Future
│ ├── types.ts
│ ├── client.ts
│ └── index.ts
├── __tests__/
│ ├── common.test.ts
│ ├── resolvespec.test.ts
│ ├── headerspec.test.ts
│ └── websocketspec.test.ts
└── index.ts # Root barrel export
```
---
## Type Alignment with Go
Types in `src/common/types.ts` match `pkg/common/types.go`:
- **Operator**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`, `contains`, `startswith`, `endswith`, `between`, `between_inclusive`, `is_null`, `is_not_null`
- **FilterOption**: `column`, `operator`, `value`, `logic_operator` (AND/OR)
- **Options**: `columns`, `omit_columns`, `filters`, `sort`, `limit`, `offset`, `preload`, `customOperators`, `computedColumns`, `parameters`, `cursor_forward`, `cursor_backward`, `fetch_row_number`
- **PreloadOption**: `relation`, `table_name`, `columns`, `omit_columns`, `sort`, `filters`, `where`, `limit`, `offset`, `updatable`, `recursive`, `computed_ql`, `primary_key`, `related_key`, `foreign_key`, `recursive_child_key`, `sql_joins`, `join_aliases`
- **Parameter**: `name`, `value`, `sequence?`
- **Metadata**: `total`, `count`, `filtered`, `limit`, `offset`, `row_number?`
- **APIError**: `code`, `message`, `details?`, `detail?`
---
## HeaderSpec Header Mapping
Maps Options to HTTP headers per Go `restheadspec/headers.go`:
| Header | Options field | Format |
|--------|--------------|--------|
| `X-Select-Fields` | `columns` | comma-separated |
| `X-Not-Select-Fields` | `omit_columns` | comma-separated |
| `X-FieldFilter-{col}` | `filters` (eq, AND) | value |
| `X-SearchOp-{op}-{col}` | `filters` (AND) | value |
| `X-SearchOr-{op}-{col}` | `filters` (OR) | value |
| `X-Sort` | `sort` | `+col` (asc), `-col` (desc) |
| `X-Limit` | `limit` | number |
| `X-Offset` | `offset` | number |
| `X-Cursor-Forward` | `cursor_forward` | string |
| `X-Cursor-Backward` | `cursor_backward` | string |
| `X-Preload` | `preload` | `Rel:col1,col2` pipe-separated |
| `X-Fetch-RowNumber` | `fetch_row_number` | string |
| `X-CQL-SEL-{col}` | `computedColumns` | expression |
| `X-Custom-SQL-W` | `customOperators` | SQL AND-joined |
Complex values use `ZIP_` + base64 encoding.
HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete.
---
## Build & Test
```bash
pnpm install
pnpm run build # vite library mode → dist/
pnpm run test # vitest
pnpm run lint # eslint
```
**Config files:** `tsconfig.json` (ES2020, strict, bundler), `vite.config.ts` (lib mode, dts via vite-plugin-dts)
**Externals:** `uuid`, `semver`
---
## Remaining Work
- **Phase 6 — MQTT Client**: Topic-based CRUD over MQTT (optional/future)
- **Phase 7 — Cache**: In-memory response cache with TTL, key = URL + options hash, auto-invalidation on CUD, `skipCache` flag
- **Phase 8 — TanStack Query Integration**: Query/mutation hooks wrapping each client, query key factories, automatic cache invalidation
- **Phase 9 — React Hooks**: `useResolveSpec`, `useHeaderSpec`, `useWebSocket` hooks with provider context, loading/error states
- ESLint config may need updating for new folder structure
---
## Reference Files
| Purpose | Path |
|---------|------|
| Go types (source of truth) | `pkg/common/types.go` |
| Go REST handler | `pkg/resolvespec/handler.go` |
| Go HeaderSpec handler | `pkg/restheadspec/handler.go` |
| Go HeaderSpec header parsing | `pkg/restheadspec/headers.go` |
| Go test models | `pkg/testmodels/business.go` |
| Go tests | `tests/crud_test.go` |

213
resolvespec-js/README.md Normal file
View File

@@ -0,0 +1,213 @@
# ResolveSpec JS
TypeScript client library for ResolveSpec APIs. Supports body-based REST, header-based REST, and WebSocket protocols.
## Install
```bash
pnpm add @warkypublic/resolvespec-js
```
## Clients
| Client | Protocol | Singleton Factory |
| --- | --- | --- |
| `ResolveSpecClient` | REST (body-based) | `getResolveSpecClient(config)` |
| `HeaderSpecClient` | REST (header-based) | `getHeaderSpecClient(config)` |
| `WebSocketClient` | WebSocket | `getWebSocketClient(config)` |
All clients use the class pattern. Singleton factories return cached instances keyed by URL.
## REST Client (Body-Based)
Options sent in JSON request body. Maps to Go `pkg/resolvespec`.
```typescript
import { ResolveSpecClient, getResolveSpecClient } from '@warkypublic/resolvespec-js';
// Class instantiation
const client = new ResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// Or singleton factory (returns cached instance per baseUrl)
const client = getResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// Read with filters, sort, pagination
const result = await client.read('public', 'users', undefined, {
columns: ['id', 'name', 'email'],
filters: [{ column: 'status', operator: 'eq', value: 'active' }],
sort: [{ column: 'name', direction: 'asc' }],
limit: 10,
offset: 0,
preload: [{ relation: 'Posts', columns: ['id', 'title'] }],
});
// Read by ID
const user = await client.read('public', 'users', 42);
// Create
const created = await client.create('public', 'users', { name: 'New User' });
// Update
await client.update('public', 'users', { name: 'Updated' }, 42);
// Delete
await client.delete('public', 'users', 42);
// Metadata
const meta = await client.getMetadata('public', 'users');
```
## HeaderSpec Client (Header-Based)
Options sent via HTTP headers. Maps to Go `pkg/restheadspec`.
```typescript
import { HeaderSpecClient, getHeaderSpecClient } from '@warkypublic/resolvespec-js';
const client = new HeaderSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// Or: const client = getHeaderSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// GET with options as headers
const result = await client.read('public', 'users', undefined, {
columns: ['id', 'name'],
filters: [
{ column: 'status', operator: 'eq', value: 'active' },
{ column: 'age', operator: 'gte', value: 18, logic_operator: 'AND' },
],
sort: [{ column: 'name', direction: 'asc' }],
limit: 50,
preload: [{ relation: 'Department', columns: ['id', 'name'] }],
});
// POST create
await client.create('public', 'users', { name: 'New User' });
// PUT update
await client.update('public', 'users', '42', { name: 'Updated' });
// DELETE
await client.delete('public', 'users', '42');
```
### Header Mapping
| Header | Options Field | Format |
| --- | --- | --- |
| `X-Select-Fields` | `columns` | comma-separated |
| `X-Not-Select-Fields` | `omit_columns` | comma-separated |
| `X-FieldFilter-{col}` | `filters` (eq, AND) | value |
| `X-SearchOp-{op}-{col}` | `filters` (AND) | value |
| `X-SearchOr-{op}-{col}` | `filters` (OR) | value |
| `X-Sort` | `sort` | `+col` asc, `-col` desc |
| `X-Limit` / `X-Offset` | `limit` / `offset` | number |
| `X-Cursor-Forward` | `cursor_forward` | string |
| `X-Cursor-Backward` | `cursor_backward` | string |
| `X-Preload` | `preload` | `Rel:col1,col2` pipe-separated |
| `X-Fetch-RowNumber` | `fetch_row_number` | string |
| `X-CQL-SEL-{col}` | `computedColumns` | expression |
| `X-Custom-SQL-W` | `customOperators` | SQL AND-joined |
### Utility Functions
```typescript
import { buildHeaders, encodeHeaderValue, decodeHeaderValue } from '@warkypublic/resolvespec-js';
const headers = buildHeaders({ columns: ['id', 'name'], limit: 10 });
// => { 'X-Select-Fields': 'id,name', 'X-Limit': '10' }
const encoded = encodeHeaderValue('complex value'); // 'ZIP_...'
const decoded = decodeHeaderValue(encoded); // 'complex value'
```
## WebSocket Client
Real-time CRUD with subscriptions. Maps to Go `pkg/websocketspec`.
```typescript
import { WebSocketClient, getWebSocketClient } from '@warkypublic/resolvespec-js';
const ws = new WebSocketClient({
url: 'ws://localhost:8080/ws',
reconnect: true,
heartbeatInterval: 30000,
});
// Or: const ws = getWebSocketClient({ url: 'ws://localhost:8080/ws' });
await ws.connect();
// CRUD
const users = await ws.read('users', { schema: 'public', limit: 10 });
const created = await ws.create('users', { name: 'New' }, { schema: 'public' });
await ws.update('users', '1', { name: 'Updated' });
await ws.delete('users', '1');
// Subscribe to changes
const subId = await ws.subscribe('users', (notification) => {
console.log(notification.operation, notification.data);
});
// Unsubscribe
await ws.unsubscribe(subId);
// Events
ws.on('connect', () => console.log('connected'));
ws.on('disconnect', () => console.log('disconnected'));
ws.on('error', (err) => console.error(err));
ws.disconnect();
```
## Types
All types align with Go `pkg/common/types.go`.
### Key Types
```typescript
interface Options {
columns?: string[];
omit_columns?: string[];
filters?: FilterOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
preload?: PreloadOption[];
customOperators?: CustomOperator[];
computedColumns?: ComputedColumn[];
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
}
interface FilterOption {
column: string;
operator: Operator | string;
value: any;
logic_operator?: 'AND' | 'OR';
}
// Operators: eq, neq, gt, gte, lt, lte, like, ilike, in,
// contains, startswith, endswith, between,
// between_inclusive, is_null, is_not_null
interface APIResponse<T> {
success: boolean;
data: T;
metadata?: Metadata;
error?: APIError;
}
```
## Build
```bash
pnpm install
pnpm run build # dist/index.js (ES) + dist/index.cjs (CJS) + .d.ts
pnpm run test # vitest
pnpm run lint # eslint
```
## License
MIT

View File

@@ -1,530 +0,0 @@
# WebSocketSpec JavaScript Client
A TypeScript/JavaScript client for connecting to WebSocketSpec servers with full support for real-time subscriptions, CRUD operations, and automatic reconnection.
## Installation
```bash
npm install @warkypublic/resolvespec-js
# or
yarn add @warkypublic/resolvespec-js
# or
pnpm add @warkypublic/resolvespec-js
```
## Quick Start
```typescript
import { WebSocketClient } from '@warkypublic/resolvespec-js';
// Create client
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws',
reconnect: true,
debug: true
});
// Connect
await client.connect();
// Read records
const users = await client.read('users', {
schema: 'public',
filters: [
{ column: 'status', operator: 'eq', value: 'active' }
],
limit: 10
});
// Subscribe to changes
const subscriptionId = await client.subscribe('users', (notification) => {
console.log('User changed:', notification.operation, notification.data);
}, { schema: 'public' });
// Clean up
await client.unsubscribe(subscriptionId);
client.disconnect();
```
## Features
- **Real-Time Updates**: Subscribe to entity changes and receive instant notifications
- **Full CRUD Support**: Create, read, update, and delete operations
- **TypeScript Support**: Full type definitions included
- **Auto Reconnection**: Automatic reconnection with configurable retry logic
- **Heartbeat**: Built-in keepalive mechanism
- **Event System**: Listen to connection, error, and message events
- **Promise-based API**: All async operations return promises
- **Filter & Sort**: Advanced querying with filters, sorting, and pagination
- **Preloading**: Load related entities in a single query
## Configuration
```typescript
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws', // WebSocket server URL
reconnect: true, // Enable auto-reconnection
reconnectInterval: 3000, // Reconnection delay (ms)
maxReconnectAttempts: 10, // Max reconnection attempts
heartbeatInterval: 30000, // Heartbeat interval (ms)
debug: false // Enable debug logging
});
```
## API Reference
### Connection Management
#### `connect(): Promise<void>`
Connect to the WebSocket server.
```typescript
await client.connect();
```
#### `disconnect(): void`
Disconnect from the server.
```typescript
client.disconnect();
```
#### `isConnected(): boolean`
Check if currently connected.
```typescript
if (client.isConnected()) {
console.log('Connected!');
}
```
#### `getState(): ConnectionState`
Get current connection state: `'connecting'`, `'connected'`, `'disconnecting'`, `'disconnected'`, or `'reconnecting'`.
```typescript
const state = client.getState();
console.log('State:', state);
```
### CRUD Operations
#### `read<T>(entity: string, options?): Promise<T>`
Read records from an entity.
```typescript
// Read all active users
const users = await client.read('users', {
schema: 'public',
filters: [
{ column: 'status', operator: 'eq', value: 'active' }
],
columns: ['id', 'name', 'email'],
sort: [
{ column: 'name', direction: 'asc' }
],
limit: 10,
offset: 0
});
// Read single record by ID
const user = await client.read('users', {
schema: 'public',
record_id: '123'
});
// Read with preloading
const posts = await client.read('posts', {
schema: 'public',
preload: [
{
relation: 'user',
columns: ['id', 'name', 'email']
},
{
relation: 'comments',
filters: [
{ column: 'status', operator: 'eq', value: 'approved' }
]
}
]
});
```
#### `create<T>(entity: string, data: any, options?): Promise<T>`
Create a new record.
```typescript
const newUser = await client.create('users', {
name: 'John Doe',
email: 'john@example.com',
status: 'active'
}, {
schema: 'public'
});
```
#### `update<T>(entity: string, id: string, data: any, options?): Promise<T>`
Update an existing record.
```typescript
const updatedUser = await client.update('users', '123', {
name: 'John Updated',
email: 'john.new@example.com'
}, {
schema: 'public'
});
```
#### `delete(entity: string, id: string, options?): Promise<void>`
Delete a record.
```typescript
await client.delete('users', '123', {
schema: 'public'
});
```
#### `meta<T>(entity: string, options?): Promise<T>`
Get metadata for an entity.
```typescript
const metadata = await client.meta('users', {
schema: 'public'
});
console.log('Columns:', metadata.columns);
console.log('Primary key:', metadata.primary_key);
```
### Subscriptions
#### `subscribe(entity: string, callback: Function, options?): Promise<string>`
Subscribe to entity changes.
```typescript
const subscriptionId = await client.subscribe(
'users',
(notification) => {
console.log('Operation:', notification.operation); // 'create', 'update', or 'delete'
console.log('Data:', notification.data);
console.log('Timestamp:', notification.timestamp);
},
{
schema: 'public',
filters: [
{ column: 'status', operator: 'eq', value: 'active' }
]
}
);
```
#### `unsubscribe(subscriptionId: string): Promise<void>`
Unsubscribe from entity changes.
```typescript
await client.unsubscribe(subscriptionId);
```
#### `getSubscriptions(): Subscription[]`
Get list of active subscriptions.
```typescript
const subscriptions = client.getSubscriptions();
console.log('Active subscriptions:', subscriptions.length);
```
### Event Handling
#### `on(event: string, callback: Function): void`
Add event listener.
```typescript
// Connection events
client.on('connect', () => {
console.log('Connected!');
});
client.on('disconnect', (event) => {
console.log('Disconnected:', event.code, event.reason);
});
client.on('error', (error) => {
console.error('Error:', error);
});
// State changes
client.on('stateChange', (state) => {
console.log('State:', state);
});
// All messages
client.on('message', (message) => {
console.log('Message:', message);
});
```
#### `off(event: string): void`
Remove event listener.
```typescript
client.off('connect');
```
## Filter Operators
- `eq` - Equal (=)
- `neq` - Not Equal (!=)
- `gt` - Greater Than (>)
- `gte` - Greater Than or Equal (>=)
- `lt` - Less Than (<)
- `lte` - Less Than or Equal (<=)
- `like` - LIKE (case-sensitive)
- `ilike` - ILIKE (case-insensitive)
- `in` - IN (array of values)
## Examples
### Basic CRUD
```typescript
const client = new WebSocketClient({ url: 'ws://localhost:8080/ws' });
await client.connect();
// Create
const user = await client.create('users', {
name: 'Alice',
email: 'alice@example.com'
});
// Read
const users = await client.read('users', {
filters: [{ column: 'status', operator: 'eq', value: 'active' }]
});
// Update
await client.update('users', user.id, { name: 'Alice Updated' });
// Delete
await client.delete('users', user.id);
client.disconnect();
```
### Real-Time Subscriptions
```typescript
const client = new WebSocketClient({ url: 'ws://localhost:8080/ws' });
await client.connect();
// Subscribe to all user changes
const subId = await client.subscribe('users', (notification) => {
switch (notification.operation) {
case 'create':
console.log('New user:', notification.data);
break;
case 'update':
console.log('User updated:', notification.data);
break;
case 'delete':
console.log('User deleted:', notification.data);
break;
}
});
// Later: unsubscribe
await client.unsubscribe(subId);
```
### React Integration
```typescript
import { useEffect, useState } from 'react';
import { WebSocketClient } from '@warkypublic/resolvespec-js';
function useWebSocket(url: string) {
const [client] = useState(() => new WebSocketClient({ url }));
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
client.on('connect', () => setIsConnected(true));
client.on('disconnect', () => setIsConnected(false));
client.connect();
return () => client.disconnect();
}, [client]);
return { client, isConnected };
}
function UsersComponent() {
const { client, isConnected } = useWebSocket('ws://localhost:8080/ws');
const [users, setUsers] = useState([]);
useEffect(() => {
if (!isConnected) return;
const loadUsers = async () => {
// Subscribe to changes
await client.subscribe('users', (notification) => {
if (notification.operation === 'create') {
setUsers(prev => [...prev, notification.data]);
} else if (notification.operation === 'update') {
setUsers(prev => prev.map(u =>
u.id === notification.data.id ? notification.data : u
));
} else if (notification.operation === 'delete') {
setUsers(prev => prev.filter(u => u.id !== notification.data.id));
}
});
// Load initial data
const data = await client.read('users');
setUsers(data);
};
loadUsers();
}, [client, isConnected]);
return (
<div>
<h2>Users {isConnected ? '🟢' : '🔴'}</h2>
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
```
### TypeScript with Typed Models
```typescript
interface User {
id: number;
name: string;
email: string;
status: 'active' | 'inactive';
}
interface Post {
id: number;
title: string;
content: string;
user_id: number;
user?: User;
}
const client = new WebSocketClient({ url: 'ws://localhost:8080/ws' });
await client.connect();
// Type-safe operations
const users = await client.read<User[]>('users', {
filters: [{ column: 'status', operator: 'eq', value: 'active' }]
});
const newUser = await client.create<User>('users', {
name: 'Bob',
email: 'bob@example.com',
status: 'active'
});
// Type-safe subscriptions
await client.subscribe(
'posts',
(notification) => {
const post = notification.data as Post;
console.log('Post:', post.title);
}
);
```
### Error Handling
```typescript
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws',
reconnect: true,
maxReconnectAttempts: 5
});
client.on('error', (error) => {
console.error('Connection error:', error);
});
client.on('stateChange', (state) => {
console.log('State:', state);
if (state === 'reconnecting') {
console.log('Attempting to reconnect...');
}
});
try {
await client.connect();
try {
const user = await client.read('users', { record_id: '999' });
} catch (error) {
console.error('Record not found:', error);
}
try {
await client.create('users', { /* invalid data */ });
} catch (error) {
console.error('Validation failed:', error);
}
} catch (error) {
console.error('Connection failed:', error);
}
```
### Multiple Subscriptions
```typescript
const client = new WebSocketClient({ url: 'ws://localhost:8080/ws' });
await client.connect();
// Subscribe to multiple entities
const userSub = await client.subscribe('users', (n) => {
console.log('[Users]', n.operation, n.data);
});
const postSub = await client.subscribe('posts', (n) => {
console.log('[Posts]', n.operation, n.data);
}, {
filters: [{ column: 'status', operator: 'eq', value: 'published' }]
});
const commentSub = await client.subscribe('comments', (n) => {
console.log('[Comments]', n.operation, n.data);
});
// Check active subscriptions
console.log('Active:', client.getSubscriptions().length);
// Clean up
await client.unsubscribe(userSub);
await client.unsubscribe(postSub);
await client.unsubscribe(commentSub);
```
## Best Practices
1. **Always Clean Up**: Call `disconnect()` when done to close the connection properly
2. **Use TypeScript**: Leverage type definitions for better type safety
3. **Handle Errors**: Always wrap operations in try-catch blocks
4. **Limit Subscriptions**: Don't create too many subscriptions per connection
5. **Use Filters**: Apply filters to subscriptions to reduce unnecessary notifications
6. **Connection State**: Check `isConnected()` before operations
7. **Event Listeners**: Remove event listeners when no longer needed with `off()`
8. **Reconnection**: Enable auto-reconnection for production apps
## Browser Support
- Chrome/Edge 88+
- Firefox 85+
- Safari 14+
- Node.js 14.16+
## License
MIT

1
resolvespec-js/dist/index.cjs vendored Normal file

File diff suppressed because one or more lines are too long

366
resolvespec-js/dist/index.d.ts vendored Normal file
View File

@@ -0,0 +1,366 @@
export declare interface APIError {
code: string;
message: string;
details?: any;
detail?: string;
}
export declare interface APIResponse<T = any> {
success: boolean;
data: T;
metadata?: Metadata;
error?: APIError;
}
/**
* Build HTTP headers from Options, matching Go's restheadspec handler conventions.
*
* Header mapping:
* - X-Select-Fields: comma-separated columns
* - X-Not-Select-Fields: comma-separated omit_columns
* - X-FieldFilter-{col}: exact match (eq)
* - X-SearchOp-{operator}-{col}: AND filter
* - X-SearchOr-{operator}-{col}: OR filter
* - X-Sort: +col (asc), -col (desc)
* - X-Limit, X-Offset: pagination
* - X-Cursor-Forward, X-Cursor-Backward: cursor pagination
* - X-Preload: RelationName:field1,field2 pipe-separated
* - X-Fetch-RowNumber: row number fetch
* - X-CQL-SEL-{col}: computed columns
* - X-Custom-SQL-W: custom operators (AND)
*/
export declare function buildHeaders(options: Options): Record<string, string>;
export declare interface ClientConfig {
baseUrl: string;
token?: string;
}
export declare interface Column {
name: string;
type: string;
is_nullable: boolean;
is_primary: boolean;
is_unique: boolean;
has_index: boolean;
}
export declare interface ComputedColumn {
name: string;
expression: string;
}
export declare type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'reconnecting';
export declare interface CustomOperator {
name: string;
sql: string;
}
/**
* Decode a header value that may be base64 encoded with ZIP_ or __ prefix.
*/
export declare function decodeHeaderValue(value: string): string;
/**
* Encode a value with base64 and ZIP_ prefix for complex header values.
*/
export declare function encodeHeaderValue(value: string): string;
export declare interface FilterOption {
column: string;
operator: Operator | string;
value: any;
logic_operator?: 'AND' | 'OR';
}
export declare function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient;
export declare function getResolveSpecClient(config: ClientConfig): ResolveSpecClient;
export declare function getWebSocketClient(config: WebSocketClientConfig): WebSocketClient;
/**
* HeaderSpec REST client.
* Sends query options via HTTP headers instead of request body, matching the Go restheadspec handler.
*
* HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete
*/
export declare class HeaderSpecClient {
private config;
constructor(config: ClientConfig);
private buildUrl;
private baseHeaders;
private fetchWithError;
read<T = any>(schema: string, entity: string, id?: string, options?: Options): Promise<APIResponse<T>>;
create<T = any>(schema: string, entity: string, data: any, options?: Options): Promise<APIResponse<T>>;
update<T = any>(schema: string, entity: string, id: string, data: any, options?: Options): Promise<APIResponse<T>>;
delete(schema: string, entity: string, id: string): Promise<APIResponse<void>>;
}
export declare type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong';
export declare interface Metadata {
total: number;
count: number;
filtered: number;
limit: number;
offset: number;
row_number?: number;
}
export declare type Operation = 'read' | 'create' | 'update' | 'delete';
export declare type Operator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'in' | 'contains' | 'startswith' | 'endswith' | 'between' | 'between_inclusive' | 'is_null' | 'is_not_null';
export declare interface Options {
preload?: PreloadOption[];
columns?: string[];
omit_columns?: string[];
filters?: FilterOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
customOperators?: CustomOperator[];
computedColumns?: ComputedColumn[];
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
}
export declare interface Parameter {
name: string;
value: string;
sequence?: number;
}
export declare interface PreloadOption {
relation: string;
table_name?: string;
columns?: string[];
omit_columns?: string[];
sort?: SortOption[];
filters?: FilterOption[];
where?: string;
limit?: number;
offset?: number;
updatable?: boolean;
computed_ql?: Record<string, string>;
recursive?: boolean;
primary_key?: string;
related_key?: string;
foreign_key?: string;
recursive_child_key?: string;
sql_joins?: string[];
join_aliases?: string[];
}
export declare interface RequestBody {
operation: Operation;
id?: number | string | string[];
data?: any | any[];
options?: Options;
}
export declare class ResolveSpecClient {
private config;
constructor(config: ClientConfig);
private buildUrl;
private baseHeaders;
private fetchWithError;
getMetadata(schema: string, entity: string): Promise<APIResponse<TableMetadata>>;
read<T = any>(schema: string, entity: string, id?: number | string | string[], options?: Options): Promise<APIResponse<T>>;
create<T = any>(schema: string, entity: string, data: any | any[], options?: Options): Promise<APIResponse<T>>;
update<T = any>(schema: string, entity: string, data: any | any[], id?: number | string | string[], options?: Options): Promise<APIResponse<T>>;
delete(schema: string, entity: string, id: number | string): Promise<APIResponse<void>>;
}
export declare type SortDirection = 'asc' | 'desc' | 'ASC' | 'DESC';
export declare interface SortOption {
column: string;
direction: SortDirection;
}
export declare interface Subscription {
id: string;
entity: string;
schema?: string;
options?: WSOptions;
callback?: (notification: WSNotificationMessage) => void;
}
export declare interface SubscriptionOptions {
filters?: FilterOption[];
onNotification?: (notification: WSNotificationMessage) => void;
}
export declare interface TableMetadata {
schema: string;
table: string;
columns: Column[];
relations: string[];
}
export declare class WebSocketClient {
private ws;
private config;
private messageHandlers;
private subscriptions;
private eventListeners;
private state;
private reconnectAttempts;
private reconnectTimer;
private heartbeatTimer;
private isManualClose;
constructor(config: WebSocketClientConfig);
connect(): Promise<void>;
disconnect(): void;
request<T = any>(operation: WSOperation, entity: string, options?: {
schema?: string;
record_id?: string;
data?: any;
options?: WSOptions;
}): Promise<T>;
read<T = any>(entity: string, options?: {
schema?: string;
record_id?: string;
filters?: FilterOption[];
columns?: string[];
sort?: SortOption[];
preload?: PreloadOption[];
limit?: number;
offset?: number;
}): Promise<T>;
create<T = any>(entity: string, data: any, options?: {
schema?: string;
}): Promise<T>;
update<T = any>(entity: string, id: string, data: any, options?: {
schema?: string;
}): Promise<T>;
delete(entity: string, id: string, options?: {
schema?: string;
}): Promise<void>;
meta<T = any>(entity: string, options?: {
schema?: string;
}): Promise<T>;
subscribe(entity: string, callback: (notification: WSNotificationMessage) => void, options?: {
schema?: string;
filters?: FilterOption[];
}): Promise<string>;
unsubscribe(subscriptionId: string): Promise<void>;
getSubscriptions(): Subscription[];
getState(): ConnectionState;
isConnected(): boolean;
on<K extends keyof WebSocketClientEvents>(event: K, callback: WebSocketClientEvents[K]): void;
off<K extends keyof WebSocketClientEvents>(event: K): void;
private handleMessage;
private handleResponse;
private handleNotification;
private send;
private startHeartbeat;
private stopHeartbeat;
private setState;
private ensureConnected;
private emit;
private log;
}
export declare interface WebSocketClientConfig {
url: string;
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
heartbeatInterval?: number;
debug?: boolean;
}
export declare interface WebSocketClientEvents {
connect: () => void;
disconnect: (event: CloseEvent) => void;
error: (error: Error) => void;
message: (message: WSMessage) => void;
stateChange: (state: ConnectionState) => void;
}
export declare interface WSErrorInfo {
code: string;
message: string;
details?: Record<string, any>;
}
export declare interface WSMessage {
id?: string;
type: MessageType;
operation?: WSOperation;
schema?: string;
entity?: string;
record_id?: string;
data?: any;
options?: WSOptions;
subscription_id?: string;
success?: boolean;
error?: WSErrorInfo;
metadata?: Record<string, any>;
timestamp?: string;
}
export declare interface WSNotificationMessage {
type: 'notification';
operation: WSOperation;
subscription_id: string;
schema?: string;
entity: string;
data: any;
timestamp: string;
}
export declare type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta';
export declare interface WSOptions {
filters?: FilterOption[];
columns?: string[];
omit_columns?: string[];
preload?: PreloadOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
}
export declare interface WSRequestMessage {
id: string;
type: 'request';
operation: WSOperation;
schema?: string;
entity: string;
record_id?: string;
data?: any;
options?: WSOptions;
}
export declare interface WSResponseMessage {
id: string;
type: 'response';
success: boolean;
data?: any;
error?: WSErrorInfo;
metadata?: Record<string, any>;
timestamp: string;
}
export declare interface WSSubscriptionMessage {
id: string;
type: 'subscription';
operation: 'subscribe' | 'unsubscribe';
schema?: string;
entity: string;
options?: WSOptions;
subscription_id?: string;
}
export { }

469
resolvespec-js/dist/index.js vendored Normal file
View File

@@ -0,0 +1,469 @@
import { v4 as l } from "uuid";
const d = /* @__PURE__ */ new Map();
function E(n) {
const e = n.baseUrl;
let t = d.get(e);
return t || (t = new g(n), d.set(e, t)), t;
}
class g {
constructor(e) {
this.config = e;
}
buildUrl(e, t, s) {
let r = `${this.config.baseUrl}/${e}/${t}`;
return s && (r += `/${s}`), r;
}
baseHeaders() {
const e = {
"Content-Type": "application/json"
};
return this.config.token && (e.Authorization = `Bearer ${this.config.token}`), e;
}
async fetchWithError(e, t) {
const s = await fetch(e, t), r = await s.json();
if (!s.ok)
throw new Error(r.error?.message || "An error occurred");
return r;
}
async getMetadata(e, t) {
const s = this.buildUrl(e, t);
return this.fetchWithError(s, {
method: "GET",
headers: this.baseHeaders()
});
}
async read(e, t, s, r) {
const i = typeof s == "number" || typeof s == "string" ? String(s) : void 0, a = this.buildUrl(e, t, i), c = {
operation: "read",
id: Array.isArray(s) ? s : void 0,
options: r
};
return this.fetchWithError(a, {
method: "POST",
headers: this.baseHeaders(),
body: JSON.stringify(c)
});
}
async create(e, t, s, r) {
const i = this.buildUrl(e, t), a = {
operation: "create",
data: s,
options: r
};
return this.fetchWithError(i, {
method: "POST",
headers: this.baseHeaders(),
body: JSON.stringify(a)
});
}
async update(e, t, s, r, i) {
const a = typeof r == "number" || typeof r == "string" ? String(r) : void 0, c = this.buildUrl(e, t, a), o = {
operation: "update",
id: Array.isArray(r) ? r : void 0,
data: s,
options: i
};
return this.fetchWithError(c, {
method: "POST",
headers: this.baseHeaders(),
body: JSON.stringify(o)
});
}
async delete(e, t, s) {
const r = this.buildUrl(e, t, String(s)), i = {
operation: "delete"
};
return this.fetchWithError(r, {
method: "POST",
headers: this.baseHeaders(),
body: JSON.stringify(i)
});
}
}
const f = /* @__PURE__ */ new Map();
function _(n) {
const e = n.url;
let t = f.get(e);
return t || (t = new p(n), f.set(e, t)), t;
}
class p {
constructor(e) {
this.ws = null, this.messageHandlers = /* @__PURE__ */ new Map(), this.subscriptions = /* @__PURE__ */ new Map(), this.eventListeners = {}, this.state = "disconnected", this.reconnectAttempts = 0, this.reconnectTimer = null, this.heartbeatTimer = null, this.isManualClose = !1, this.config = {
url: e.url,
reconnect: e.reconnect ?? !0,
reconnectInterval: e.reconnectInterval ?? 3e3,
maxReconnectAttempts: e.maxReconnectAttempts ?? 10,
heartbeatInterval: e.heartbeatInterval ?? 3e4,
debug: e.debug ?? !1
};
}
async connect() {
if (this.ws?.readyState === WebSocket.OPEN) {
this.log("Already connected");
return;
}
return this.isManualClose = !1, this.setState("connecting"), new Promise((e, t) => {
try {
this.ws = new WebSocket(this.config.url), this.ws.onopen = () => {
this.log("Connected to WebSocket server"), this.setState("connected"), this.reconnectAttempts = 0, this.startHeartbeat(), this.emit("connect"), e();
}, this.ws.onmessage = (s) => {
this.handleMessage(s.data);
}, this.ws.onerror = (s) => {
this.log("WebSocket error:", s);
const r = new Error("WebSocket connection error");
this.emit("error", r), t(r);
}, this.ws.onclose = (s) => {
this.log("WebSocket closed:", s.code, s.reason), this.stopHeartbeat(), this.setState("disconnected"), this.emit("disconnect", s), this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts && (this.reconnectAttempts++, this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`), this.setState("reconnecting"), this.reconnectTimer = setTimeout(() => {
this.connect().catch((r) => {
this.log("Reconnection failed:", r);
});
}, this.config.reconnectInterval));
};
} catch (s) {
t(s);
}
});
}
disconnect() {
this.isManualClose = !0, this.reconnectTimer && (clearTimeout(this.reconnectTimer), this.reconnectTimer = null), this.stopHeartbeat(), this.ws && (this.setState("disconnecting"), this.ws.close(), this.ws = null), this.setState("disconnected"), this.messageHandlers.clear();
}
async request(e, t, s) {
this.ensureConnected();
const r = l(), i = {
id: r,
type: "request",
operation: e,
entity: t,
schema: s?.schema,
record_id: s?.record_id,
data: s?.data,
options: s?.options
};
return new Promise((a, c) => {
this.messageHandlers.set(r, (o) => {
o.success ? a(o.data) : c(new Error(o.error?.message || "Request failed"));
}), this.send(i), setTimeout(() => {
this.messageHandlers.has(r) && (this.messageHandlers.delete(r), c(new Error("Request timeout")));
}, 3e4);
});
}
async read(e, t) {
return this.request("read", e, {
schema: t?.schema,
record_id: t?.record_id,
options: {
filters: t?.filters,
columns: t?.columns,
sort: t?.sort,
preload: t?.preload,
limit: t?.limit,
offset: t?.offset
}
});
}
async create(e, t, s) {
return this.request("create", e, {
schema: s?.schema,
data: t
});
}
async update(e, t, s, r) {
return this.request("update", e, {
schema: r?.schema,
record_id: t,
data: s
});
}
async delete(e, t, s) {
await this.request("delete", e, {
schema: s?.schema,
record_id: t
});
}
async meta(e, t) {
return this.request("meta", e, {
schema: t?.schema
});
}
async subscribe(e, t, s) {
this.ensureConnected();
const r = l(), i = {
id: r,
type: "subscription",
operation: "subscribe",
entity: e,
schema: s?.schema,
options: {
filters: s?.filters
}
};
return new Promise((a, c) => {
this.messageHandlers.set(r, (o) => {
if (o.success && o.data?.subscription_id) {
const h = o.data.subscription_id;
this.subscriptions.set(h, {
id: h,
entity: e,
schema: s?.schema,
options: { filters: s?.filters },
callback: t
}), this.log(`Subscribed to ${e} with ID: ${h}`), a(h);
} else
c(new Error(o.error?.message || "Subscription failed"));
}), this.send(i), setTimeout(() => {
this.messageHandlers.has(r) && (this.messageHandlers.delete(r), c(new Error("Subscription timeout")));
}, 1e4);
});
}
async unsubscribe(e) {
this.ensureConnected();
const t = l(), s = {
id: t,
type: "subscription",
operation: "unsubscribe",
subscription_id: e
};
return new Promise((r, i) => {
this.messageHandlers.set(t, (a) => {
a.success ? (this.subscriptions.delete(e), this.log(`Unsubscribed from ${e}`), r()) : i(new Error(a.error?.message || "Unsubscribe failed"));
}), this.send(s), setTimeout(() => {
this.messageHandlers.has(t) && (this.messageHandlers.delete(t), i(new Error("Unsubscribe timeout")));
}, 1e4);
});
}
getSubscriptions() {
return Array.from(this.subscriptions.values());
}
getState() {
return this.state;
}
isConnected() {
return this.ws?.readyState === WebSocket.OPEN;
}
on(e, t) {
this.eventListeners[e] = t;
}
off(e) {
delete this.eventListeners[e];
}
// Private methods
handleMessage(e) {
try {
const t = JSON.parse(e);
switch (this.log("Received message:", t), this.emit("message", t), t.type) {
case "response":
this.handleResponse(t);
break;
case "notification":
this.handleNotification(t);
break;
case "pong":
break;
default:
this.log("Unknown message type:", t.type);
}
} catch (t) {
this.log("Error parsing message:", t);
}
}
handleResponse(e) {
const t = this.messageHandlers.get(e.id);
t && (t(e), this.messageHandlers.delete(e.id));
}
handleNotification(e) {
const t = this.subscriptions.get(e.subscription_id);
t?.callback && t.callback(e);
}
send(e) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
throw new Error("WebSocket is not connected");
const t = JSON.stringify(e);
this.log("Sending message:", e), this.ws.send(t);
}
startHeartbeat() {
this.heartbeatTimer || (this.heartbeatTimer = setInterval(() => {
if (this.isConnected()) {
const e = {
id: l(),
type: "ping"
};
this.send(e);
}
}, this.config.heartbeatInterval));
}
stopHeartbeat() {
this.heartbeatTimer && (clearInterval(this.heartbeatTimer), this.heartbeatTimer = null);
}
setState(e) {
this.state !== e && (this.state = e, this.emit("stateChange", e));
}
ensureConnected() {
if (!this.isConnected())
throw new Error("WebSocket is not connected. Call connect() first.");
}
emit(e, ...t) {
const s = this.eventListeners[e];
s && s(...t);
}
log(...e) {
this.config.debug && console.log("[WebSocketClient]", ...e);
}
}
function v(n) {
return typeof btoa == "function" ? "ZIP_" + btoa(n) : "ZIP_" + Buffer.from(n, "utf-8").toString("base64");
}
function w(n) {
let e = n;
return e.startsWith("ZIP_") ? (e = e.slice(4).replace(/[\n\r ]/g, ""), e = m(e)) : e.startsWith("__") && (e = e.slice(2).replace(/[\n\r ]/g, ""), e = m(e)), (e.startsWith("ZIP_") || e.startsWith("__")) && (e = w(e)), e;
}
function m(n) {
return typeof atob == "function" ? atob(n) : Buffer.from(n, "base64").toString("utf-8");
}
function u(n) {
const e = {};
if (n.columns?.length && (e["X-Select-Fields"] = n.columns.join(",")), n.omit_columns?.length && (e["X-Not-Select-Fields"] = n.omit_columns.join(",")), n.filters?.length)
for (const t of n.filters) {
const s = t.logic_operator ?? "AND", r = y(t.operator), i = S(t);
t.operator === "eq" && s === "AND" ? e[`X-FieldFilter-${t.column}`] = i : s === "OR" ? e[`X-SearchOr-${r}-${t.column}`] = i : e[`X-SearchOp-${r}-${t.column}`] = i;
}
if (n.sort?.length) {
const t = n.sort.map((s) => s.direction.toUpperCase() === "DESC" ? `-${s.column}` : `+${s.column}`);
e["X-Sort"] = t.join(",");
}
if (n.limit !== void 0 && (e["X-Limit"] = String(n.limit)), n.offset !== void 0 && (e["X-Offset"] = String(n.offset)), n.cursor_forward && (e["X-Cursor-Forward"] = n.cursor_forward), n.cursor_backward && (e["X-Cursor-Backward"] = n.cursor_backward), n.preload?.length) {
const t = n.preload.map((s) => s.columns?.length ? `${s.relation}:${s.columns.join(",")}` : s.relation);
e["X-Preload"] = t.join("|");
}
if (n.fetch_row_number && (e["X-Fetch-RowNumber"] = n.fetch_row_number), n.computedColumns?.length)
for (const t of n.computedColumns)
e[`X-CQL-SEL-${t.name}`] = t.expression;
if (n.customOperators?.length) {
const t = n.customOperators.map(
(s) => s.sql
);
e["X-Custom-SQL-W"] = t.join(" AND ");
}
return e;
}
function y(n) {
switch (n) {
case "eq":
return "equals";
case "neq":
return "notequals";
case "gt":
return "greaterthan";
case "gte":
return "greaterthanorequal";
case "lt":
return "lessthan";
case "lte":
return "lessthanorequal";
case "like":
case "ilike":
case "contains":
return "contains";
case "startswith":
return "beginswith";
case "endswith":
return "endswith";
case "in":
return "in";
case "between":
return "between";
case "between_inclusive":
return "betweeninclusive";
case "is_null":
return "empty";
case "is_not_null":
return "notempty";
default:
return n;
}
}
function S(n) {
return n.value === null || n.value === void 0 ? "" : Array.isArray(n.value) ? n.value.join(",") : String(n.value);
}
const b = /* @__PURE__ */ new Map();
function C(n) {
const e = n.baseUrl;
let t = b.get(e);
return t || (t = new H(n), b.set(e, t)), t;
}
class H {
constructor(e) {
this.config = e;
}
buildUrl(e, t, s) {
let r = `${this.config.baseUrl}/${e}/${t}`;
return s && (r += `/${s}`), r;
}
baseHeaders() {
const e = {
"Content-Type": "application/json"
};
return this.config.token && (e.Authorization = `Bearer ${this.config.token}`), e;
}
async fetchWithError(e, t) {
const s = await fetch(e, t), r = await s.json();
if (!s.ok)
throw new Error(
r.error?.message || `${s.statusText} (${s.status})`
);
return {
data: r,
success: !0,
error: r.error ? r.error : void 0,
metadata: {
count: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0,
total: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0,
filtered: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0,
offset: s.headers.get("content-range") ? Number(
s.headers.get("content-range")?.split("/")[0].split("-")[0]
) : 0,
limit: s.headers.get("x-limit") ? Number(s.headers.get("x-limit")) : 0
}
};
}
async read(e, t, s, r) {
const i = this.buildUrl(e, t, s), a = r ? u(r) : {};
return this.fetchWithError(i, {
method: "GET",
headers: { ...this.baseHeaders(), ...a }
});
}
async create(e, t, s, r) {
const i = this.buildUrl(e, t), a = r ? u(r) : {};
return this.fetchWithError(i, {
method: "POST",
headers: { ...this.baseHeaders(), ...a },
body: JSON.stringify(s)
});
}
async update(e, t, s, r, i) {
const a = this.buildUrl(e, t, s), c = i ? u(i) : {};
return this.fetchWithError(a, {
method: "PUT",
headers: { ...this.baseHeaders(), ...c },
body: JSON.stringify(r)
});
}
async delete(e, t, s) {
const r = this.buildUrl(e, t, s);
return this.fetchWithError(r, {
method: "DELETE",
headers: this.baseHeaders()
});
}
}
export {
H as HeaderSpecClient,
g as ResolveSpecClient,
p as WebSocketClient,
u as buildHeaders,
w as decodeHeaderValue,
v as encodeHeaderValue,
C as getHeaderSpecClient,
E as getResolveSpecClient,
_ as getWebSocketClient
};

View File

@@ -1,20 +1,23 @@
{ {
"name": "@warkypublic/resolvespec-js", "name": "@warkypublic/resolvespec-js",
"version": "1.0.0", "version": "1.0.1",
"description": "Client side library for the ResolveSpec API", "description": "TypeScript client library for ResolveSpec REST, HeaderSpec, and WebSocket APIs",
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./dist/index.cjs",
"module": "./src/index.ts", "module": "./dist/index.js",
"types": "./src/index.ts", "types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"publishConfig": { "publishConfig": {
"access": "public", "access": "public"
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts"
}, },
"files": [ "files": [
"dist", "dist",
"bin",
"README.md" "README.md"
], ],
"scripts": { "scripts": {
@@ -25,38 +28,33 @@
"lint": "eslint src" "lint": "eslint src"
}, },
"keywords": [ "keywords": [
"string", "resolvespec",
"blob", "headerspec",
"dependencies", "websocket",
"workspace", "rest-client",
"package", "typescript",
"cli", "api-client"
"tools",
"npm",
"yarn",
"pnpm"
], ],
"author": "Hein (Warkanum) Puth", "author": "Hein (Warkanum) Puth",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"semver": "^7.6.3", "uuid": "^13.0.0"
"uuid": "^11.0.3"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.27.10", "@changesets/cli": "^2.29.8",
"@eslint/js": "^9.16.0", "@eslint/js": "^10.0.1",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^27.0.0",
"eslint": "^9.16.0", "eslint": "^10.0.0",
"globals": "^15.13.0", "globals": "^17.3.0",
"jsdom": "^25.0.1", "jsdom": "^28.1.0",
"typescript": "^5.7.2", "typescript": "^5.9.3",
"typescript-eslint": "^8.17.0", "typescript-eslint": "^8.55.0",
"vite": "^6.0.2", "vite": "^7.3.1",
"vite-plugin-dts": "^4.3.0", "vite-plugin-dts": "^4.5.4",
"vitest": "^2.1.8" "vitest": "^4.0.18"
}, },
"engines": { "engines": {
"node": ">=14.16" "node": ">=18"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

3376
resolvespec-js/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import type {
Options,
FilterOption,
SortOption,
PreloadOption,
RequestBody,
APIResponse,
Metadata,
APIError,
Parameter,
ComputedColumn,
CustomOperator,
} from '../common/types';
describe('Common Types', () => {
it('should construct a valid FilterOption with logic_operator', () => {
const filter: FilterOption = {
column: 'name',
operator: 'eq',
value: 'test',
logic_operator: 'OR',
};
expect(filter.logic_operator).toBe('OR');
expect(filter.operator).toBe('eq');
});
it('should construct Options with all new fields', () => {
const opts: Options = {
columns: ['id', 'name'],
omit_columns: ['secret'],
filters: [{ column: 'age', operator: 'gte', value: 18 }],
sort: [{ column: 'name', direction: 'asc' }],
limit: 10,
offset: 0,
cursor_forward: 'abc123',
cursor_backward: 'xyz789',
fetch_row_number: '42',
parameters: [{ name: 'param1', value: 'val1', sequence: 1 }],
computedColumns: [{ name: 'full_name', expression: "first || ' ' || last" }],
customOperators: [{ name: 'custom', sql: "status = 'active'" }],
preload: [{
relation: 'Items',
columns: ['id', 'title'],
omit_columns: ['internal'],
sort: [{ column: 'id', direction: 'ASC' }],
recursive: true,
primary_key: 'id',
related_key: 'parent_id',
sql_joins: ['LEFT JOIN other ON other.id = items.other_id'],
join_aliases: ['other'],
}],
};
expect(opts.omit_columns).toEqual(['secret']);
expect(opts.cursor_forward).toBe('abc123');
expect(opts.fetch_row_number).toBe('42');
expect(opts.parameters![0].sequence).toBe(1);
expect(opts.preload![0].recursive).toBe(true);
});
it('should construct a RequestBody with numeric id', () => {
const body: RequestBody = {
operation: 'read',
id: 42,
options: { limit: 10 },
};
expect(body.id).toBe(42);
});
it('should construct a RequestBody with string array id', () => {
const body: RequestBody = {
operation: 'delete',
id: ['1', '2', '3'],
};
expect(Array.isArray(body.id)).toBe(true);
});
it('should construct Metadata with count and row_number', () => {
const meta: Metadata = {
total: 100,
count: 10,
filtered: 50,
limit: 10,
offset: 0,
row_number: 5,
};
expect(meta.count).toBe(10);
expect(meta.row_number).toBe(5);
});
it('should construct APIError with detail field', () => {
const err: APIError = {
code: 'not_found',
message: 'Record not found',
detail: 'The record with id 42 does not exist',
};
expect(err.detail).toBeDefined();
});
it('should construct APIResponse with metadata', () => {
const resp: APIResponse<string[]> = {
success: true,
data: ['a', 'b'],
metadata: { total: 2, count: 2, filtered: 2, limit: 10, offset: 0 },
};
expect(resp.metadata?.count).toBe(2);
});
it('should support all operator types', () => {
const operators: FilterOption['operator'][] = [
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
'like', 'ilike', 'in',
'contains', 'startswith', 'endswith',
'between', 'between_inclusive',
'is_null', 'is_not_null',
];
for (const op of operators) {
const f: FilterOption = { column: 'x', operator: op, value: 'v' };
expect(f.operator).toBe(op);
}
});
it('should support PreloadOption with computed_ql and where', () => {
const preload: PreloadOption = {
relation: 'Details',
where: "status = 'active'",
computed_ql: { cql1: 'SUM(amount)' },
table_name: 'detail_table',
updatable: true,
foreign_key: 'detail_id',
recursive_child_key: 'parent_detail_id',
};
expect(preload.computed_ql?.cql1).toBe('SUM(amount)');
expect(preload.updatable).toBe(true);
});
it('should support Parameter interface', () => {
const p: Parameter = { name: 'key', value: 'val' };
expect(p.name).toBe('key');
const p2: Parameter = { name: 'key2', value: 'val2', sequence: 5 };
expect(p2.sequence).toBe(5);
});
});

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildHeaders, encodeHeaderValue, decodeHeaderValue, HeaderSpecClient, getHeaderSpecClient } from '../headerspec/client';
import type { Options, ClientConfig, APIResponse } from '../common/types';
describe('buildHeaders', () => {
it('should set X-Select-Fields for columns', () => {
const h = buildHeaders({ columns: ['id', 'name', 'email'] });
expect(h['X-Select-Fields']).toBe('id,name,email');
});
it('should set X-Not-Select-Fields for omit_columns', () => {
const h = buildHeaders({ omit_columns: ['secret', 'internal'] });
expect(h['X-Not-Select-Fields']).toBe('secret,internal');
});
it('should set X-FieldFilter for eq AND filters', () => {
const h = buildHeaders({
filters: [{ column: 'status', operator: 'eq', value: 'active' }],
});
expect(h['X-FieldFilter-status']).toBe('active');
});
it('should set X-SearchOp for non-eq AND filters', () => {
const h = buildHeaders({
filters: [{ column: 'age', operator: 'gte', value: 18 }],
});
expect(h['X-SearchOp-greaterthanorequal-age']).toBe('18');
});
it('should set X-SearchOr for OR filters', () => {
const h = buildHeaders({
filters: [{ column: 'name', operator: 'contains', value: 'test', logic_operator: 'OR' }],
});
expect(h['X-SearchOr-contains-name']).toBe('test');
});
it('should set X-Sort with direction prefixes', () => {
const h = buildHeaders({
sort: [
{ column: 'name', direction: 'asc' },
{ column: 'created_at', direction: 'DESC' },
],
});
expect(h['X-Sort']).toBe('+name,-created_at');
});
it('should set X-Limit and X-Offset', () => {
const h = buildHeaders({ limit: 25, offset: 50 });
expect(h['X-Limit']).toBe('25');
expect(h['X-Offset']).toBe('50');
});
it('should set cursor pagination headers', () => {
const h = buildHeaders({ cursor_forward: 'abc', cursor_backward: 'xyz' });
expect(h['X-Cursor-Forward']).toBe('abc');
expect(h['X-Cursor-Backward']).toBe('xyz');
});
it('should set X-Preload with pipe-separated relations', () => {
const h = buildHeaders({
preload: [
{ relation: 'Items', columns: ['id', 'name'] },
{ relation: 'Category' },
],
});
expect(h['X-Preload']).toBe('Items:id,name|Category');
});
it('should set X-Fetch-RowNumber', () => {
const h = buildHeaders({ fetch_row_number: '42' });
expect(h['X-Fetch-RowNumber']).toBe('42');
});
it('should set X-CQL-SEL for computed columns', () => {
const h = buildHeaders({
computedColumns: [
{ name: 'total', expression: 'price * qty' },
],
});
expect(h['X-CQL-SEL-total']).toBe('price * qty');
});
it('should set X-Custom-SQL-W for custom operators', () => {
const h = buildHeaders({
customOperators: [
{ name: 'active', sql: "status = 'active'" },
{ name: 'verified', sql: "verified = true" },
],
});
expect(h['X-Custom-SQL-W']).toBe("status = 'active' AND verified = true");
});
it('should return empty object for empty options', () => {
const h = buildHeaders({});
expect(Object.keys(h)).toHaveLength(0);
});
it('should handle between filter with array value', () => {
const h = buildHeaders({
filters: [{ column: 'price', operator: 'between', value: [10, 100] }],
});
expect(h['X-SearchOp-between-price']).toBe('10,100');
});
it('should handle is_null filter with null value', () => {
const h = buildHeaders({
filters: [{ column: 'deleted_at', operator: 'is_null', value: null }],
});
expect(h['X-SearchOp-empty-deleted_at']).toBe('');
});
it('should handle in filter with array value', () => {
const h = buildHeaders({
filters: [{ column: 'id', operator: 'in', value: [1, 2, 3] }],
});
expect(h['X-SearchOp-in-id']).toBe('1,2,3');
});
});
describe('encodeHeaderValue / decodeHeaderValue', () => {
it('should round-trip encode/decode', () => {
const original = 'some complex value with spaces & symbols!';
const encoded = encodeHeaderValue(original);
expect(encoded.startsWith('ZIP_')).toBe(true);
const decoded = decodeHeaderValue(encoded);
expect(decoded).toBe(original);
});
it('should decode __ prefixed values', () => {
const encoded = '__' + btoa('hello');
expect(decodeHeaderValue(encoded)).toBe('hello');
});
it('should return plain values as-is', () => {
expect(decodeHeaderValue('plain')).toBe('plain');
});
});
describe('HeaderSpecClient', () => {
const config: ClientConfig = { baseUrl: 'http://localhost:3000', token: 'tok' };
function mockFetch<T>(data: APIResponse<T>, ok = true) {
return vi.fn().mockResolvedValue({
ok,
json: () => Promise.resolve(data),
});
}
beforeEach(() => {
vi.restoreAllMocks();
});
it('read() sends GET with headers from options', async () => {
globalThis.fetch = mockFetch({ success: true, data: [{ id: 1 }] });
const client = new HeaderSpecClient(config);
await client.read('public', 'users', undefined, {
columns: ['id', 'name'],
limit: 10,
});
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users');
expect(opts.method).toBe('GET');
expect(opts.headers['X-Select-Fields']).toBe('id,name');
expect(opts.headers['X-Limit']).toBe('10');
expect(opts.headers['Authorization']).toBe('Bearer tok');
});
it('read() with id appends to URL', async () => {
globalThis.fetch = mockFetch({ success: true, data: {} });
const client = new HeaderSpecClient(config);
await client.read('public', 'users', '42');
const [url] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/42');
});
it('create() sends POST with body and headers', async () => {
globalThis.fetch = mockFetch({ success: true, data: { id: 1 } });
const client = new HeaderSpecClient(config);
await client.create('public', 'users', { name: 'Test' });
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(opts.method).toBe('POST');
expect(JSON.parse(opts.body)).toEqual({ name: 'Test' });
});
it('update() sends PUT with id in URL', async () => {
globalThis.fetch = mockFetch({ success: true, data: {} });
const client = new HeaderSpecClient(config);
await client.update('public', 'users', '1', { name: 'Updated' }, {
filters: [{ column: 'active', operator: 'eq', value: true }],
});
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
expect(opts.method).toBe('PUT');
expect(opts.headers['X-FieldFilter-active']).toBe('true');
});
it('delete() sends DELETE', async () => {
globalThis.fetch = mockFetch({ success: true, data: undefined as any });
const client = new HeaderSpecClient(config);
await client.delete('public', 'users', '1');
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
expect(opts.method).toBe('DELETE');
});
it('throws on non-ok response', async () => {
globalThis.fetch = mockFetch(
{ success: false, data: null as any, error: { code: 'err', message: 'fail' } },
false
);
const client = new HeaderSpecClient(config);
await expect(client.read('public', 'users')).rejects.toThrow('fail');
});
});
describe('getHeaderSpecClient singleton', () => {
it('returns same instance for same baseUrl', () => {
const a = getHeaderSpecClient({ baseUrl: 'http://hs-singleton:3000' });
const b = getHeaderSpecClient({ baseUrl: 'http://hs-singleton:3000' });
expect(a).toBe(b);
});
it('returns different instances for different baseUrls', () => {
const a = getHeaderSpecClient({ baseUrl: 'http://hs-singleton-a:3000' });
const b = getHeaderSpecClient({ baseUrl: 'http://hs-singleton-b:3000' });
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ResolveSpecClient, getResolveSpecClient } from '../resolvespec/client';
import type { ClientConfig, APIResponse } from '../common/types';
const config: ClientConfig = { baseUrl: 'http://localhost:3000', token: 'test-token' };
function mockFetchResponse<T>(data: APIResponse<T>, ok = true, status = 200) {
return vi.fn().mockResolvedValue({
ok,
status,
json: () => Promise.resolve(data),
});
}
beforeEach(() => {
vi.restoreAllMocks();
});
describe('ResolveSpecClient', () => {
it('read() sends POST with operation read', async () => {
const response: APIResponse = { success: true, data: [{ id: 1 }] };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
const result = await client.read('public', 'users', 1);
expect(result.success).toBe(true);
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
expect(opts.method).toBe('POST');
expect(opts.headers['Authorization']).toBe('Bearer test-token');
const body = JSON.parse(opts.body);
expect(body.operation).toBe('read');
});
it('read() with string array id puts id in body', async () => {
const response: APIResponse = { success: true, data: [] };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.read('public', 'users', ['1', '2']);
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
expect(body.id).toEqual(['1', '2']);
});
it('read() passes options through', async () => {
const response: APIResponse = { success: true, data: [] };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.read('public', 'users', undefined, {
columns: ['id', 'name'],
omit_columns: ['secret'],
filters: [{ column: 'active', operator: 'eq', value: true }],
sort: [{ column: 'name', direction: 'asc' }],
limit: 10,
offset: 0,
cursor_forward: 'cursor1',
fetch_row_number: '5',
});
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
expect(body.options.columns).toEqual(['id', 'name']);
expect(body.options.omit_columns).toEqual(['secret']);
expect(body.options.cursor_forward).toBe('cursor1');
expect(body.options.fetch_row_number).toBe('5');
});
it('create() sends POST with operation create and data', async () => {
const response: APIResponse = { success: true, data: { id: 1, name: 'Test' } };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
const result = await client.create('public', 'users', { name: 'Test' });
expect(result.data.name).toBe('Test');
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
expect(body.operation).toBe('create');
expect(body.data.name).toBe('Test');
});
it('update() with single id puts id in URL', async () => {
const response: APIResponse = { success: true, data: { id: 1 } };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.update('public', 'users', { name: 'Updated' }, 1);
const [url] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
});
it('update() with string array id puts id in body', async () => {
const response: APIResponse = { success: true, data: {} };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.update('public', 'users', { active: false }, ['1', '2']);
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
expect(body.id).toEqual(['1', '2']);
});
it('delete() sends POST with operation delete', async () => {
const response: APIResponse<void> = { success: true, data: undefined as any };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.delete('public', 'users', 1);
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
const body = JSON.parse(opts.body);
expect(body.operation).toBe('delete');
});
it('getMetadata() sends GET request', async () => {
const response: APIResponse = {
success: true,
data: { schema: 'public', table: 'users', columns: [], relations: [] },
};
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
const result = await client.getMetadata('public', 'users');
expect(result.data.table).toBe('users');
const opts = (globalThis.fetch as any).mock.calls[0][1];
expect(opts.method).toBe('GET');
});
it('throws on non-ok response', async () => {
const errorResp = {
success: false,
data: null,
error: { code: 'not_found', message: 'Not found' },
};
globalThis.fetch = mockFetchResponse(errorResp as any, false, 404);
const client = new ResolveSpecClient(config);
await expect(client.read('public', 'users', 999)).rejects.toThrow('Not found');
});
it('throws generic error when no error message', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ success: false, data: null }),
});
const client = new ResolveSpecClient(config);
await expect(client.read('public', 'users')).rejects.toThrow('An error occurred');
});
it('config without token omits Authorization header', async () => {
const noAuthConfig: ClientConfig = { baseUrl: 'http://localhost:3000' };
const response: APIResponse = { success: true, data: [] };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(noAuthConfig);
await client.read('public', 'users');
const opts = (globalThis.fetch as any).mock.calls[0][1];
expect(opts.headers['Authorization']).toBeUndefined();
});
});
describe('getResolveSpecClient singleton', () => {
it('returns same instance for same baseUrl', () => {
const a = getResolveSpecClient({ baseUrl: 'http://singleton-test:3000' });
const b = getResolveSpecClient({ baseUrl: 'http://singleton-test:3000' });
expect(a).toBe(b);
});
it('returns different instances for different baseUrls', () => {
const a = getResolveSpecClient({ baseUrl: 'http://singleton-a:3000' });
const b = getResolveSpecClient({ baseUrl: 'http://singleton-b:3000' });
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,336 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { WebSocketClient, getWebSocketClient } from '../websocketspec/client';
import type { WebSocketClientConfig } from '../websocketspec/types';
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid-1234'),
}));
// Mock WebSocket
class MockWebSocket {
static OPEN = 1;
static CLOSED = 3;
url: string;
readyState = MockWebSocket.OPEN;
onopen: ((ev: any) => void) | null = null;
onclose: ((ev: any) => void) | null = null;
onmessage: ((ev: any) => void) | null = null;
onerror: ((ev: any) => void) | null = null;
private sentMessages: string[] = [];
constructor(url: string) {
this.url = url;
// Simulate async open
setTimeout(() => {
this.onopen?.({});
}, 0);
}
send(data: string) {
this.sentMessages.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code: 1000, reason: 'Normal closure' } as any);
}
getSentMessages(): any[] {
return this.sentMessages.map((m) => JSON.parse(m));
}
simulateMessage(data: any) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
let mockWsInstance: MockWebSocket | null = null;
beforeEach(() => {
mockWsInstance = null;
(globalThis as any).WebSocket = class extends MockWebSocket {
constructor(url: string) {
super(url);
mockWsInstance = this;
}
};
(globalThis as any).WebSocket.OPEN = MockWebSocket.OPEN;
(globalThis as any).WebSocket.CLOSED = MockWebSocket.CLOSED;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('WebSocketClient', () => {
const wsConfig: WebSocketClientConfig = {
url: 'ws://localhost:8080',
reconnect: false,
heartbeatInterval: 60000,
};
it('should connect and set state to connected', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
expect(client.getState()).toBe('connected');
expect(client.isConnected()).toBe(true);
client.disconnect();
});
it('should disconnect and set state to disconnected', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
client.disconnect();
expect(client.getState()).toBe('disconnected');
expect(client.isConnected()).toBe(false);
});
it('should send read request', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const readPromise = client.read('users', {
schema: 'public',
filters: [{ column: 'active', operator: 'eq', value: true }],
limit: 10,
});
// Simulate server response
const sent = mockWsInstance!.getSentMessages();
expect(sent.length).toBe(1);
expect(sent[0].operation).toBe('read');
expect(sent[0].entity).toBe('users');
expect(sent[0].options.filters[0].column).toBe('active');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: [{ id: 1 }],
timestamp: new Date().toISOString(),
});
const result = await readPromise;
expect(result).toEqual([{ id: 1 }]);
client.disconnect();
});
it('should send create request', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const createPromise = client.create('users', { name: 'Test' }, { schema: 'public' });
const sent = mockWsInstance!.getSentMessages();
expect(sent[0].operation).toBe('create');
expect(sent[0].data.name).toBe('Test');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: { id: 1, name: 'Test' },
timestamp: new Date().toISOString(),
});
const result = await createPromise;
expect(result.name).toBe('Test');
client.disconnect();
});
it('should send update request with record_id', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const updatePromise = client.update('users', '1', { name: 'Updated' });
const sent = mockWsInstance!.getSentMessages();
expect(sent[0].operation).toBe('update');
expect(sent[0].record_id).toBe('1');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: { id: 1, name: 'Updated' },
timestamp: new Date().toISOString(),
});
await updatePromise;
client.disconnect();
});
it('should send delete request', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const deletePromise = client.delete('users', '1');
const sent = mockWsInstance!.getSentMessages();
expect(sent[0].operation).toBe('delete');
expect(sent[0].record_id).toBe('1');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
timestamp: new Date().toISOString(),
});
await deletePromise;
client.disconnect();
});
it('should reject on failed request', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const readPromise = client.read('users');
const sent = mockWsInstance!.getSentMessages();
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: false,
error: { code: 'not_found', message: 'Not found' },
timestamp: new Date().toISOString(),
});
await expect(readPromise).rejects.toThrow('Not found');
client.disconnect();
});
it('should handle subscriptions', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const callback = vi.fn();
const subPromise = client.subscribe('users', callback, {
schema: 'public',
});
const sent = mockWsInstance!.getSentMessages();
expect(sent[0].type).toBe('subscription');
expect(sent[0].operation).toBe('subscribe');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: { subscription_id: 'sub-1' },
timestamp: new Date().toISOString(),
});
const subId = await subPromise;
expect(subId).toBe('sub-1');
expect(client.getSubscriptions()).toHaveLength(1);
// Simulate notification
mockWsInstance!.simulateMessage({
type: 'notification',
operation: 'create',
subscription_id: 'sub-1',
entity: 'users',
data: { id: 2, name: 'New' },
timestamp: new Date().toISOString(),
});
expect(callback).toHaveBeenCalledTimes(1);
expect(callback.mock.calls[0][0].data.id).toBe(2);
client.disconnect();
});
it('should handle unsubscribe', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
// Subscribe first
const subPromise = client.subscribe('users', vi.fn());
let sent = mockWsInstance!.getSentMessages();
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: { subscription_id: 'sub-1' },
timestamp: new Date().toISOString(),
});
await subPromise;
// Unsubscribe
const unsubPromise = client.unsubscribe('sub-1');
sent = mockWsInstance!.getSentMessages();
mockWsInstance!.simulateMessage({
id: sent[sent.length - 1].id,
type: 'response',
success: true,
timestamp: new Date().toISOString(),
});
await unsubPromise;
expect(client.getSubscriptions()).toHaveLength(0);
client.disconnect();
});
it('should emit events', async () => {
const client = new WebSocketClient(wsConfig);
const connectCb = vi.fn();
const stateChangeCb = vi.fn();
client.on('connect', connectCb);
client.on('stateChange', stateChangeCb);
await client.connect();
expect(connectCb).toHaveBeenCalledTimes(1);
expect(stateChangeCb).toHaveBeenCalled();
client.off('connect');
client.disconnect();
});
it('should reject when sending without connection', async () => {
const client = new WebSocketClient(wsConfig);
await expect(client.read('users')).rejects.toThrow('WebSocket is not connected');
});
it('should handle pong messages without error', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
// Should not throw
mockWsInstance!.simulateMessage({ type: 'pong' });
client.disconnect();
});
it('should handle malformed messages gracefully', async () => {
const client = new WebSocketClient({ ...wsConfig, debug: false });
await client.connect();
// Simulate non-JSON message
mockWsInstance!.onmessage?.({ data: 'not-json' } as any);
client.disconnect();
});
});
describe('getWebSocketClient singleton', () => {
it('returns same instance for same url', () => {
const a = getWebSocketClient({ url: 'ws://ws-singleton:8080' });
const b = getWebSocketClient({ url: 'ws://ws-singleton:8080' });
expect(a).toBe(b);
});
it('returns different instances for different urls', () => {
const a = getWebSocketClient({ url: 'ws://ws-singleton-a:8080' });
const b = getWebSocketClient({ url: 'ws://ws-singleton-b:8080' });
expect(a).not.toBe(b);
});
});

View File

@@ -1,132 +0,0 @@
import { ClientConfig, APIResponse, TableMetadata, Options, RequestBody } from "./types";
// Helper functions
const getHeaders = (options?: Record<string,any>): HeadersInit => {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (options?.token) {
headers['Authorization'] = `Bearer ${options.token}`;
}
return headers;
};
const buildUrl = (config: ClientConfig, schema: string, entity: string, id?: string): string => {
let url = `${config.baseUrl}/${schema}/${entity}`;
if (id) {
url += `/${id}`;
}
return url;
};
const fetchWithError = async <T>(url: string, options: RequestInit): Promise<APIResponse<T>> => {
try {
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error?.message || 'An error occurred');
}
return data;
} catch (error) {
throw error;
}
};
// API Functions
export const getMetadata = async (
config: ClientConfig,
schema: string,
entity: string
): Promise<APIResponse<TableMetadata>> => {
const url = buildUrl(config, schema, entity);
return fetchWithError<TableMetadata>(url, {
method: 'GET',
headers: getHeaders(config),
});
};
export const read = async <T = any>(
config: ClientConfig,
schema: string,
entity: string,
id?: string,
options?: Options
): Promise<APIResponse<T>> => {
const url = buildUrl(config, schema, entity, id);
const body: RequestBody = {
operation: 'read',
options,
};
return fetchWithError<T>(url, {
method: 'POST',
headers: getHeaders(config),
body: JSON.stringify(body),
});
};
export const create = async <T = any>(
config: ClientConfig,
schema: string,
entity: string,
data: any | any[],
options?: Options
): Promise<APIResponse<T>> => {
const url = buildUrl(config, schema, entity);
const body: RequestBody = {
operation: 'create',
data,
options,
};
return fetchWithError<T>(url, {
method: 'POST',
headers: getHeaders(config),
body: JSON.stringify(body),
});
};
export const update = async <T = any>(
config: ClientConfig,
schema: string,
entity: string,
data: any | any[],
id?: string | string[],
options?: Options
): Promise<APIResponse<T>> => {
const url = buildUrl(config, schema, entity, typeof id === 'string' ? id : undefined);
const body: RequestBody = {
operation: 'update',
id: typeof id === 'string' ? undefined : id,
data,
options,
};
return fetchWithError<T>(url, {
method: 'POST',
headers: getHeaders(config),
body: JSON.stringify(body),
});
};
export const deleteEntity = async (
config: ClientConfig,
schema: string,
entity: string,
id: string
): Promise<APIResponse<void>> => {
const url = buildUrl(config, schema, entity, id);
const body: RequestBody = {
operation: 'delete',
};
return fetchWithError<void>(url, {
method: 'POST',
headers: getHeaders(config),
body: JSON.stringify(body),
});
};

View File

@@ -0,0 +1 @@
export * from './types';

View File

@@ -0,0 +1,129 @@
// Types aligned with Go pkg/common/types.go
export type Operator =
| 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
| 'like' | 'ilike' | 'in'
| 'contains' | 'startswith' | 'endswith'
| 'between' | 'between_inclusive'
| 'is_null' | 'is_not_null';
export type Operation = 'read' | 'create' | 'update' | 'delete';
export type SortDirection = 'asc' | 'desc' | 'ASC' | 'DESC';
export interface Parameter {
name: string;
value: string;
sequence?: number;
}
export interface PreloadOption {
relation: string;
table_name?: string;
columns?: string[];
omit_columns?: string[];
sort?: SortOption[];
filters?: FilterOption[];
where?: string;
limit?: number;
offset?: number;
updatable?: boolean;
computed_ql?: Record<string, string>;
recursive?: boolean;
// Relationship keys
primary_key?: string;
related_key?: string;
foreign_key?: string;
recursive_child_key?: string;
// Custom SQL JOINs
sql_joins?: string[];
join_aliases?: string[];
}
export interface FilterOption {
column: string;
operator: Operator | string;
value: any;
logic_operator?: 'AND' | 'OR';
}
export interface SortOption {
column: string;
direction: SortDirection;
}
export interface CustomOperator {
name: string;
sql: string;
}
export interface ComputedColumn {
name: string;
expression: string;
}
export interface Options {
preload?: PreloadOption[];
columns?: string[];
omit_columns?: string[];
filters?: FilterOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
customOperators?: CustomOperator[];
computedColumns?: ComputedColumn[];
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
}
export interface RequestBody {
operation: Operation;
id?: number | string | string[];
data?: any | any[];
options?: Options;
}
export interface Metadata {
total: number;
count: number;
filtered: number;
limit: number;
offset: number;
row_number?: number;
}
export interface APIError {
code: string;
message: string;
details?: any;
detail?: string;
}
export interface APIResponse<T = any> {
success: boolean;
data: T;
metadata?: Metadata;
error?: APIError;
}
export interface Column {
name: string;
type: string;
is_nullable: boolean;
is_primary: boolean;
is_unique: boolean;
has_index: boolean;
}
export interface TableMetadata {
schema: string;
table: string;
columns: Column[];
relations: string[];
}
export interface ClientConfig {
baseUrl: string;
token?: string;
}

View File

@@ -1,68 +0,0 @@
import { getMetadata, read, create, update, deleteEntity } from "./api";
import { ClientConfig } from "./types";
// Usage Examples
const config: ClientConfig = {
baseUrl: 'http://api.example.com/v1',
token: 'your-token-here'
};
// Example usage
const examples = async () => {
// Get metadata
const metadata = await getMetadata(config, 'test', 'employees');
// Read with relations
const employees = await read(config, 'test', 'employees', undefined, {
preload: [
{
relation: 'department',
columns: ['id', 'name']
}
],
filters: [
{
column: 'status',
operator: 'eq',
value: 'active'
}
]
});
// Create single record
const newEmployee = await create(config, 'test', 'employees', {
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com'
});
// Bulk create
const newEmployees = await create(config, 'test', 'employees', [
{
first_name: 'Jane',
last_name: 'Smith',
email: 'jane@example.com'
},
{
first_name: 'Bob',
last_name: 'Johnson',
email: 'bob@example.com'
}
]);
// Update single record
const updatedEmployee = await update(config, 'test', 'employees',
{ status: 'inactive' },
'emp123'
);
// Bulk update
const updatedEmployees = await update(config, 'test', 'employees',
{ department_id: 'dept2' },
['emp1', 'emp2', 'emp3']
);
// Delete
await deleteEntity(config, 'test', 'employees', 'emp123');
};

View File

@@ -0,0 +1,345 @@
import type {
APIResponse,
ClientConfig,
CustomOperator,
FilterOption,
Options,
PreloadOption,
SortOption,
} from "../common/types";
/**
* Encode a value with base64 and ZIP_ prefix for complex header values.
*/
export function encodeHeaderValue(value: string): string {
if (typeof btoa === "function") {
return "ZIP_" + btoa(value);
}
return "ZIP_" + Buffer.from(value, "utf-8").toString("base64");
}
/**
* Decode a header value that may be base64 encoded with ZIP_ or __ prefix.
*/
export function decodeHeaderValue(value: string): string {
let code = value;
if (code.startsWith("ZIP_")) {
code = code.slice(4).replace(/[\n\r ]/g, "");
code = decodeBase64(code);
} else if (code.startsWith("__")) {
code = code.slice(2).replace(/[\n\r ]/g, "");
code = decodeBase64(code);
}
// Handle nested encoding
if (code.startsWith("ZIP_") || code.startsWith("__")) {
code = decodeHeaderValue(code);
}
return code;
}
function decodeBase64(str: string): string {
if (typeof atob === "function") {
return atob(str);
}
return Buffer.from(str, "base64").toString("utf-8");
}
/**
* Build HTTP headers from Options, matching Go's restheadspec handler conventions.
*
* Header mapping:
* - X-Select-Fields: comma-separated columns
* - X-Not-Select-Fields: comma-separated omit_columns
* - X-FieldFilter-{col}: exact match (eq)
* - X-SearchOp-{operator}-{col}: AND filter
* - X-SearchOr-{operator}-{col}: OR filter
* - X-Sort: +col (asc), -col (desc)
* - X-Limit, X-Offset: pagination
* - X-Cursor-Forward, X-Cursor-Backward: cursor pagination
* - X-Preload: RelationName:field1,field2 pipe-separated
* - X-Fetch-RowNumber: row number fetch
* - X-CQL-SEL-{col}: computed columns
* - X-Custom-SQL-W: custom operators (AND)
*/
export function buildHeaders(options: Options): Record<string, string> {
const headers: Record<string, string> = {};
// Column selection
if (options.columns?.length) {
headers["X-Select-Fields"] = options.columns.join(",");
}
if (options.omit_columns?.length) {
headers["X-Not-Select-Fields"] = options.omit_columns.join(",");
}
// Filters
if (options.filters?.length) {
for (const filter of options.filters) {
const logicOp = filter.logic_operator ?? "AND";
const op = mapOperatorToHeaderOp(filter.operator);
const valueStr = formatFilterValue(filter);
if (filter.operator === "eq" && logicOp === "AND") {
// Simple field filter shorthand
headers[`X-FieldFilter-${filter.column}`] = valueStr;
} else if (logicOp === "OR") {
headers[`X-SearchOr-${op}-${filter.column}`] = valueStr;
} else {
headers[`X-SearchOp-${op}-${filter.column}`] = valueStr;
}
}
}
// Sort
if (options.sort?.length) {
const sortParts = options.sort.map((s: SortOption) => {
const dir = s.direction.toUpperCase();
return dir === "DESC" ? `-${s.column}` : `+${s.column}`;
});
headers["X-Sort"] = sortParts.join(",");
}
// Pagination
if (options.limit !== undefined) {
headers["X-Limit"] = String(options.limit);
}
if (options.offset !== undefined) {
headers["X-Offset"] = String(options.offset);
}
// Cursor pagination
if (options.cursor_forward) {
headers["X-Cursor-Forward"] = options.cursor_forward;
}
if (options.cursor_backward) {
headers["X-Cursor-Backward"] = options.cursor_backward;
}
// Preload
if (options.preload?.length) {
const parts = options.preload.map((p: PreloadOption) => {
if (p.columns?.length) {
return `${p.relation}:${p.columns.join(",")}`;
}
return p.relation;
});
headers["X-Preload"] = parts.join("|");
}
// Fetch row number
if (options.fetch_row_number) {
headers["X-Fetch-RowNumber"] = options.fetch_row_number;
}
// Computed columns
if (options.computedColumns?.length) {
for (const cc of options.computedColumns) {
headers[`X-CQL-SEL-${cc.name}`] = cc.expression;
}
}
// Custom operators -> X-Custom-SQL-W
if (options.customOperators?.length) {
const sqlParts = options.customOperators.map(
(co: CustomOperator) => co.sql,
);
headers["X-Custom-SQL-W"] = sqlParts.join(" AND ");
}
return headers;
}
function mapOperatorToHeaderOp(operator: string): string {
switch (operator) {
case "eq":
return "equals";
case "neq":
return "notequals";
case "gt":
return "greaterthan";
case "gte":
return "greaterthanorequal";
case "lt":
return "lessthan";
case "lte":
return "lessthanorequal";
case "like":
case "ilike":
case "contains":
return "contains";
case "startswith":
return "beginswith";
case "endswith":
return "endswith";
case "in":
return "in";
case "between":
return "between";
case "between_inclusive":
return "betweeninclusive";
case "is_null":
return "empty";
case "is_not_null":
return "notempty";
default:
return operator;
}
}
function formatFilterValue(filter: FilterOption): string {
if (filter.value === null || filter.value === undefined) {
return "";
}
if (Array.isArray(filter.value)) {
return filter.value.join(",");
}
return String(filter.value);
}
const instances = new Map<string, HeaderSpecClient>();
export function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient {
const key = config.baseUrl;
let instance = instances.get(key);
if (!instance) {
instance = new HeaderSpecClient(config);
instances.set(key, instance);
}
return instance;
}
/**
* HeaderSpec REST client.
* Sends query options via HTTP headers instead of request body, matching the Go restheadspec handler.
*
* HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete
*/
export class HeaderSpecClient {
private config: ClientConfig;
constructor(config: ClientConfig) {
this.config = config;
}
private buildUrl(schema: string, entity: string, id?: string): string {
let url = `${this.config.baseUrl}/${schema}/${entity}`;
if (id) {
url += `/${id}`;
}
return url;
}
private baseHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.config.token) {
headers["Authorization"] = `Bearer ${this.config.token}`;
}
return headers;
}
private async fetchWithError<T>(
url: string,
init: RequestInit,
): Promise<APIResponse<T>> {
const response = await fetch(url, init);
const data = await response.json();
if (!response.ok) {
throw new Error(
data.error?.message ||
`${response.statusText} ` + `(${response.status})`,
);
}
return {
data: data,
success: true,
error: data.error ? data.error : undefined,
metadata: {
count: response.headers.get("content-range")
? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
total: response.headers.get("content-range")
? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
filtered: response.headers.get("content-range")
? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
offset: response.headers.get("content-range")
? Number(
response.headers
.get("content-range")
?.split("/")[0]
.split("-")[0],
)
: 0,
limit: response.headers.get("x-limit")
? Number(response.headers.get("x-limit"))
: 0,
},
};
}
async read<T = any>(
schema: string,
entity: string,
id?: string,
options?: Options,
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity, id);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: "GET",
headers: { ...this.baseHeaders(), ...optHeaders },
});
}
async create<T = any>(
schema: string,
entity: string,
data: any,
options?: Options,
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: "POST",
headers: { ...this.baseHeaders(), ...optHeaders },
body: JSON.stringify(data),
});
}
async update<T = any>(
schema: string,
entity: string,
id: string,
data: any,
options?: Options,
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity, id);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: "PUT",
headers: { ...this.baseHeaders(), ...optHeaders },
body: JSON.stringify(data),
});
}
async delete(
schema: string,
entity: string,
id: string,
): Promise<APIResponse<void>> {
const url = this.buildUrl(schema, entity, id);
return this.fetchWithError<void>(url, {
method: "DELETE",
headers: this.baseHeaders(),
});
}
}

View File

@@ -0,0 +1,7 @@
export {
HeaderSpecClient,
getHeaderSpecClient,
buildHeaders,
encodeHeaderValue,
decodeHeaderValue,
} from './client';

View File

@@ -1,7 +1,11 @@
// Types // Common types
export * from './types'; export * from './common';
export * from './websocket-types';
// WebSocket Client // REST client (ResolveSpec)
export { WebSocketClient } from './websocket-client'; export * from './resolvespec';
export type { WebSocketClient as default } from './websocket-client';
// WebSocket client
export * from './websocketspec';
// HeaderSpec client
export * from './headerspec';

View File

@@ -0,0 +1,141 @@
import type { ClientConfig, APIResponse, TableMetadata, Options, RequestBody } from '../common/types';
const instances = new Map<string, ResolveSpecClient>();
export function getResolveSpecClient(config: ClientConfig): ResolveSpecClient {
const key = config.baseUrl;
let instance = instances.get(key);
if (!instance) {
instance = new ResolveSpecClient(config);
instances.set(key, instance);
}
return instance;
}
export class ResolveSpecClient {
private config: ClientConfig;
constructor(config: ClientConfig) {
this.config = config;
}
private buildUrl(schema: string, entity: string, id?: string): string {
let url = `${this.config.baseUrl}/${schema}/${entity}`;
if (id) {
url += `/${id}`;
}
return url;
}
private baseHeaders(): HeadersInit {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.config.token) {
headers['Authorization'] = `Bearer ${this.config.token}`;
}
return headers;
}
private async fetchWithError<T>(url: string, options: RequestInit): Promise<APIResponse<T>> {
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error?.message || 'An error occurred');
}
return data;
}
async getMetadata(schema: string, entity: string): Promise<APIResponse<TableMetadata>> {
const url = this.buildUrl(schema, entity);
return this.fetchWithError<TableMetadata>(url, {
method: 'GET',
headers: this.baseHeaders(),
});
}
async read<T = any>(
schema: string,
entity: string,
id?: number | string | string[],
options?: Options
): Promise<APIResponse<T>> {
const urlId = typeof id === 'number' || typeof id === 'string' ? String(id) : undefined;
const url = this.buildUrl(schema, entity, urlId);
const body: RequestBody = {
operation: 'read',
id: Array.isArray(id) ? id : undefined,
options,
};
return this.fetchWithError<T>(url, {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body),
});
}
async create<T = any>(
schema: string,
entity: string,
data: any | any[],
options?: Options
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity);
const body: RequestBody = {
operation: 'create',
data,
options,
};
return this.fetchWithError<T>(url, {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body),
});
}
async update<T = any>(
schema: string,
entity: string,
data: any | any[],
id?: number | string | string[],
options?: Options
): Promise<APIResponse<T>> {
const urlId = typeof id === 'number' || typeof id === 'string' ? String(id) : undefined;
const url = this.buildUrl(schema, entity, urlId);
const body: RequestBody = {
operation: 'update',
id: Array.isArray(id) ? id : undefined,
data,
options,
};
return this.fetchWithError<T>(url, {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body),
});
}
async delete(
schema: string,
entity: string,
id: number | string
): Promise<APIResponse<void>> {
const url = this.buildUrl(schema, entity, String(id));
const body: RequestBody = {
operation: 'delete',
};
return this.fetchWithError<void>(url, {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body),
});
}
}

View File

@@ -0,0 +1 @@
export { ResolveSpecClient, getResolveSpecClient } from './client';

View File

@@ -1,86 +0,0 @@
// Types
export type Operator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'in';
export type Operation = 'read' | 'create' | 'update' | 'delete';
export type SortDirection = 'asc' | 'desc';
export interface PreloadOption {
relation: string;
columns?: string[];
filters?: FilterOption[];
}
export interface FilterOption {
column: string;
operator: Operator;
value: any;
}
export interface SortOption {
column: string;
direction: SortDirection;
}
export interface CustomOperator {
name: string;
sql: string;
}
export interface ComputedColumn {
name: string;
expression: string;
}
export interface Options {
preload?: PreloadOption[];
columns?: string[];
filters?: FilterOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
customOperators?: CustomOperator[];
computedColumns?: ComputedColumn[];
}
export interface RequestBody {
operation: Operation;
id?: string | string[];
data?: any | any[];
options?: Options;
}
export interface APIResponse<T = any> {
success: boolean;
data: T;
metadata?: {
total: number;
filtered: number;
limit: number;
offset: number;
};
error?: {
code: string;
message: string;
details?: any;
};
}
export interface Column {
name: string;
type: string;
is_nullable: boolean;
is_primary: boolean;
is_unique: boolean;
has_index: boolean;
}
export interface TableMetadata {
schema: string;
table: string;
columns: Column[];
relations: string[];
}
export interface ClientConfig {
baseUrl: string;
token?: string;
}

View File

@@ -1,427 +0,0 @@
import { WebSocketClient } from './websocket-client';
import type { WSNotificationMessage } from './websocket-types';
/**
* Example 1: Basic Usage
*/
export async function basicUsageExample() {
// Create client
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws',
reconnect: true,
debug: true
});
// Connect
await client.connect();
// Read users
const users = await client.read('users', {
schema: 'public',
filters: [
{ column: 'status', operator: 'eq', value: 'active' }
],
limit: 10,
sort: [
{ column: 'name', direction: 'asc' }
]
});
console.log('Users:', users);
// Create a user
const newUser = await client.create('users', {
name: 'John Doe',
email: 'john@example.com',
status: 'active'
}, { schema: 'public' });
console.log('Created user:', newUser);
// Update user
const updatedUser = await client.update('users', '123', {
name: 'John Updated'
}, { schema: 'public' });
console.log('Updated user:', updatedUser);
// Delete user
await client.delete('users', '123', { schema: 'public' });
// Disconnect
client.disconnect();
}
/**
* Example 2: Real-time Subscriptions
*/
export async function subscriptionExample() {
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws',
debug: true
});
await client.connect();
// Subscribe to user changes
const subscriptionId = await client.subscribe(
'users',
(notification: WSNotificationMessage) => {
console.log('User changed:', notification.operation, notification.data);
switch (notification.operation) {
case 'create':
console.log('New user created:', notification.data);
break;
case 'update':
console.log('User updated:', notification.data);
break;
case 'delete':
console.log('User deleted:', notification.data);
break;
}
},
{
schema: 'public',
filters: [
{ column: 'status', operator: 'eq', value: 'active' }
]
}
);
console.log('Subscribed with ID:', subscriptionId);
// Later: unsubscribe
setTimeout(async () => {
await client.unsubscribe(subscriptionId);
console.log('Unsubscribed');
client.disconnect();
}, 60000);
}
/**
* Example 3: Event Handling
*/
export async function eventHandlingExample() {
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws'
});
// Listen to connection events
client.on('connect', () => {
console.log('Connected!');
});
client.on('disconnect', (event) => {
console.log('Disconnected:', event.code, event.reason);
});
client.on('error', (error) => {
console.error('WebSocket error:', error);
});
client.on('stateChange', (state) => {
console.log('State changed to:', state);
});
client.on('message', (message) => {
console.log('Received message:', message);
});
await client.connect();
// Your operations here...
}
/**
* Example 4: Multiple Subscriptions
*/
export async function multipleSubscriptionsExample() {
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws',
debug: true
});
await client.connect();
// Subscribe to users
const userSubId = await client.subscribe(
'users',
(notification) => {
console.log('[Users]', notification.operation, notification.data);
},
{ schema: 'public' }
);
// Subscribe to posts
const postSubId = await client.subscribe(
'posts',
(notification) => {
console.log('[Posts]', notification.operation, notification.data);
},
{
schema: 'public',
filters: [
{ column: 'status', operator: 'eq', value: 'published' }
]
}
);
// Subscribe to comments
const commentSubId = await client.subscribe(
'comments',
(notification) => {
console.log('[Comments]', notification.operation, notification.data);
},
{ schema: 'public' }
);
console.log('Active subscriptions:', client.getSubscriptions());
// Clean up after 60 seconds
setTimeout(async () => {
await client.unsubscribe(userSubId);
await client.unsubscribe(postSubId);
await client.unsubscribe(commentSubId);
client.disconnect();
}, 60000);
}
/**
* Example 5: Advanced Queries
*/
export async function advancedQueriesExample() {
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws'
});
await client.connect();
// Complex query with filters, sorting, pagination, and preloading
const posts = await client.read('posts', {
schema: 'public',
filters: [
{ column: 'status', operator: 'eq', value: 'published' },
{ column: 'views', operator: 'gte', value: 100 }
],
columns: ['id', 'title', 'content', 'user_id', 'created_at'],
sort: [
{ column: 'created_at', direction: 'desc' },
{ column: 'views', direction: 'desc' }
],
preload: [
{
relation: 'user',
columns: ['id', 'name', 'email']
},
{
relation: 'comments',
columns: ['id', 'content', 'user_id'],
filters: [
{ column: 'status', operator: 'eq', value: 'approved' }
]
}
],
limit: 20,
offset: 0
});
console.log('Posts:', posts);
// Get single record by ID
const post = await client.read('posts', {
schema: 'public',
record_id: '123'
});
console.log('Single post:', post);
client.disconnect();
}
/**
* Example 6: Error Handling
*/
export async function errorHandlingExample() {
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws',
reconnect: true,
maxReconnectAttempts: 5
});
client.on('error', (error) => {
console.error('Connection error:', error);
});
client.on('stateChange', (state) => {
console.log('Connection state:', state);
});
try {
await client.connect();
try {
// Try to read non-existent entity
await client.read('nonexistent', { schema: 'public' });
} catch (error) {
console.error('Read error:', error);
}
try {
// Try to create invalid record
await client.create('users', {
// Missing required fields
}, { schema: 'public' });
} catch (error) {
console.error('Create error:', error);
}
} catch (error) {
console.error('Connection failed:', error);
} finally {
client.disconnect();
}
}
/**
* Example 7: React Integration
*/
export function reactIntegrationExample() {
const exampleCode = `
import { useEffect, useState } from 'react';
import { WebSocketClient } from '@warkypublic/resolvespec-js';
export function useWebSocket(url: string) {
const [client] = useState(() => new WebSocketClient({ url }));
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
client.on('connect', () => setIsConnected(true));
client.on('disconnect', () => setIsConnected(false));
client.connect();
return () => {
client.disconnect();
};
}, [client]);
return { client, isConnected };
}
export function UsersComponent() {
const { client, isConnected } = useWebSocket('ws://localhost:8080/ws');
const [users, setUsers] = useState([]);
useEffect(() => {
if (!isConnected) return;
// Subscribe to user changes
const subscribeToUsers = async () => {
const subId = await client.subscribe('users', (notification) => {
if (notification.operation === 'create') {
setUsers(prev => [...prev, notification.data]);
} else if (notification.operation === 'update') {
setUsers(prev => prev.map(u =>
u.id === notification.data.id ? notification.data : u
));
} else if (notification.operation === 'delete') {
setUsers(prev => prev.filter(u => u.id !== notification.data.id));
}
}, { schema: 'public' });
// Load initial users
const initialUsers = await client.read('users', {
schema: 'public',
filters: [{ column: 'status', operator: 'eq', value: 'active' }]
});
setUsers(initialUsers);
return () => client.unsubscribe(subId);
};
subscribeToUsers();
}, [client, isConnected]);
const createUser = async (name: string, email: string) => {
await client.create('users', { name, email, status: 'active' }, {
schema: 'public'
});
};
return (
<div>
<h2>Users ({users.length})</h2>
{isConnected ? '🟢 Connected' : '🔴 Disconnected'}
{/* Render users... */}
</div>
);
}
`;
console.log(exampleCode);
}
/**
* Example 8: TypeScript with Typed Models
*/
export async function typedModelsExample() {
// Define your models
interface User {
id: number;
name: string;
email: string;
status: 'active' | 'inactive';
created_at: string;
}
interface Post {
id: number;
title: string;
content: string;
user_id: number;
status: 'draft' | 'published';
views: number;
user?: User;
}
const client = new WebSocketClient({
url: 'ws://localhost:8080/ws'
});
await client.connect();
// Type-safe operations
const users = await client.read<User[]>('users', {
schema: 'public',
filters: [{ column: 'status', operator: 'eq', value: 'active' }]
});
const newUser = await client.create<User>('users', {
name: 'Alice',
email: 'alice@example.com',
status: 'active'
}, { schema: 'public' });
const posts = await client.read<Post[]>('posts', {
schema: 'public',
preload: [
{
relation: 'user',
columns: ['id', 'name', 'email']
}
]
});
// Type-safe subscriptions
await client.subscribe(
'users',
(notification) => {
const user = notification.data as User;
console.log('User changed:', user.name, user.email);
},
{ schema: 'public' }
);
client.disconnect();
}

View File

@@ -8,10 +8,22 @@ import type {
WSOperation, WSOperation,
WSOptions, WSOptions,
Subscription, Subscription,
SubscriptionOptions,
ConnectionState, ConnectionState,
WebSocketClientEvents WebSocketClientEvents
} from './websocket-types'; } from './types';
import type { FilterOption, SortOption, PreloadOption } from '../common/types';
const instances = new Map<string, WebSocketClient>();
export function getWebSocketClient(config: WebSocketClientConfig): WebSocketClient {
const key = config.url;
let instance = instances.get(key);
if (!instance) {
instance = new WebSocketClient(config);
instances.set(key, instance);
}
return instance;
}
export class WebSocketClient { export class WebSocketClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
@@ -36,9 +48,6 @@ export class WebSocketClient {
}; };
} }
/**
* Connect to WebSocket server
*/
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN) { if (this.ws?.readyState === WebSocket.OPEN) {
this.log('Already connected'); this.log('Already connected');
@@ -78,7 +87,6 @@ export class WebSocketClient {
this.setState('disconnected'); this.setState('disconnected');
this.emit('disconnect', event); this.emit('disconnect', event);
// Attempt reconnection if enabled and not manually closed
if (this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts) { if (this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts) {
this.reconnectAttempts++; this.reconnectAttempts++;
this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`); this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`);
@@ -97,9 +105,6 @@ export class WebSocketClient {
}); });
} }
/**
* Disconnect from WebSocket server
*/
disconnect(): void { disconnect(): void {
this.isManualClose = true; this.isManualClose = true;
@@ -120,9 +125,6 @@ export class WebSocketClient {
this.messageHandlers.clear(); this.messageHandlers.clear();
} }
/**
* Send a CRUD request and wait for response
*/
async request<T = any>( async request<T = any>(
operation: WSOperation, operation: WSOperation,
entity: string, entity: string,
@@ -148,7 +150,6 @@ export class WebSocketClient {
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Set up response handler
this.messageHandlers.set(id, (response: WSResponseMessage) => { this.messageHandlers.set(id, (response: WSResponseMessage) => {
if (response.success) { if (response.success) {
resolve(response.data); resolve(response.data);
@@ -157,10 +158,8 @@ export class WebSocketClient {
} }
}); });
// Send message
this.send(message); this.send(message);
// Timeout after 30 seconds
setTimeout(() => { setTimeout(() => {
if (this.messageHandlers.has(id)) { if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id); this.messageHandlers.delete(id);
@@ -170,16 +169,13 @@ export class WebSocketClient {
}); });
} }
/**
* Read records
*/
async read<T = any>(entity: string, options?: { async read<T = any>(entity: string, options?: {
schema?: string; schema?: string;
record_id?: string; record_id?: string;
filters?: import('./types').FilterOption[]; filters?: FilterOption[];
columns?: string[]; columns?: string[];
sort?: import('./types').SortOption[]; sort?: SortOption[];
preload?: import('./types').PreloadOption[]; preload?: PreloadOption[];
limit?: number; limit?: number;
offset?: number; offset?: number;
}): Promise<T> { }): Promise<T> {
@@ -197,9 +193,6 @@ export class WebSocketClient {
}); });
} }
/**
* Create a record
*/
async create<T = any>(entity: string, data: any, options?: { async create<T = any>(entity: string, data: any, options?: {
schema?: string; schema?: string;
}): Promise<T> { }): Promise<T> {
@@ -209,9 +202,6 @@ export class WebSocketClient {
}); });
} }
/**
* Update a record
*/
async update<T = any>(entity: string, id: string, data: any, options?: { async update<T = any>(entity: string, id: string, data: any, options?: {
schema?: string; schema?: string;
}): Promise<T> { }): Promise<T> {
@@ -222,9 +212,6 @@ export class WebSocketClient {
}); });
} }
/**
* Delete a record
*/
async delete(entity: string, id: string, options?: { async delete(entity: string, id: string, options?: {
schema?: string; schema?: string;
}): Promise<void> { }): Promise<void> {
@@ -234,9 +221,6 @@ export class WebSocketClient {
}); });
} }
/**
* Get metadata for an entity
*/
async meta<T = any>(entity: string, options?: { async meta<T = any>(entity: string, options?: {
schema?: string; schema?: string;
}): Promise<T> { }): Promise<T> {
@@ -245,15 +229,12 @@ export class WebSocketClient {
}); });
} }
/**
* Subscribe to entity changes
*/
async subscribe( async subscribe(
entity: string, entity: string,
callback: (notification: WSNotificationMessage) => void, callback: (notification: WSNotificationMessage) => void,
options?: { options?: {
schema?: string; schema?: string;
filters?: import('./types').FilterOption[]; filters?: FilterOption[];
} }
): Promise<string> { ): Promise<string> {
this.ensureConnected(); this.ensureConnected();
@@ -275,7 +256,6 @@ export class WebSocketClient {
if (response.success && response.data?.subscription_id) { if (response.success && response.data?.subscription_id) {
const subscriptionId = response.data.subscription_id; const subscriptionId = response.data.subscription_id;
// Store subscription
this.subscriptions.set(subscriptionId, { this.subscriptions.set(subscriptionId, {
id: subscriptionId, id: subscriptionId,
entity, entity,
@@ -293,7 +273,6 @@ export class WebSocketClient {
this.send(message); this.send(message);
// Timeout
setTimeout(() => { setTimeout(() => {
if (this.messageHandlers.has(id)) { if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id); this.messageHandlers.delete(id);
@@ -303,9 +282,6 @@ export class WebSocketClient {
}); });
} }
/**
* Unsubscribe from entity changes
*/
async unsubscribe(subscriptionId: string): Promise<void> { async unsubscribe(subscriptionId: string): Promise<void> {
this.ensureConnected(); this.ensureConnected();
@@ -330,7 +306,6 @@ export class WebSocketClient {
this.send(message); this.send(message);
// Timeout
setTimeout(() => { setTimeout(() => {
if (this.messageHandlers.has(id)) { if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id); this.messageHandlers.delete(id);
@@ -340,37 +315,22 @@ export class WebSocketClient {
}); });
} }
/**
* Get list of active subscriptions
*/
getSubscriptions(): Subscription[] { getSubscriptions(): Subscription[] {
return Array.from(this.subscriptions.values()); return Array.from(this.subscriptions.values());
} }
/**
* Get connection state
*/
getState(): ConnectionState { getState(): ConnectionState {
return this.state; return this.state;
} }
/**
* Check if connected
*/
isConnected(): boolean { isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN; return this.ws?.readyState === WebSocket.OPEN;
} }
/**
* Add event listener
*/
on<K extends keyof WebSocketClientEvents>(event: K, callback: WebSocketClientEvents[K]): void { on<K extends keyof WebSocketClientEvents>(event: K, callback: WebSocketClientEvents[K]): void {
this.eventListeners[event] = callback as any; this.eventListeners[event] = callback as any;
} }
/**
* Remove event listener
*/
off<K extends keyof WebSocketClientEvents>(event: K): void { off<K extends keyof WebSocketClientEvents>(event: K): void {
delete this.eventListeners[event]; delete this.eventListeners[event];
} }
@@ -384,7 +344,6 @@ export class WebSocketClient {
this.emit('message', message); this.emit('message', message);
// Handle different message types
switch (message.type) { switch (message.type) {
case 'response': case 'response':
this.handleResponse(message as WSResponseMessage); this.handleResponse(message as WSResponseMessage);
@@ -395,7 +354,6 @@ export class WebSocketClient {
break; break;
case 'pong': case 'pong':
// Heartbeat response
break; break;
default: default:

View File

@@ -0,0 +1,2 @@
export * from './types';
export { WebSocketClient, getWebSocketClient } from './client';

View File

@@ -1,17 +1,24 @@
import type { FilterOption, SortOption, PreloadOption, Parameter } from '../common/types';
// Re-export common types
export type { FilterOption, SortOption, PreloadOption, Operator, SortDirection } from '../common/types';
// WebSocket Message Types // WebSocket Message Types
export type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong'; export type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong';
export type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta'; export type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta';
// Re-export common types
export type { FilterOption, SortOption, PreloadOption, Operator, SortDirection } from './types';
export interface WSOptions { export interface WSOptions {
filters?: import('./types').FilterOption[]; filters?: FilterOption[];
columns?: string[]; columns?: string[];
preload?: import('./types').PreloadOption[]; omit_columns?: string[];
sort?: import('./types').SortOption[]; preload?: PreloadOption[];
sort?: SortOption[];
limit?: number; limit?: number;
offset?: number; offset?: number;
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
} }
export interface WSMessage { export interface WSMessage {
@@ -78,7 +85,7 @@ export interface WSSubscriptionMessage {
} }
export interface SubscriptionOptions { export interface SubscriptionOptions {
filters?: import('./types').FilterOption[]; filters?: FilterOption[];
onNotification?: (notification: WSNotificationMessage) => void; onNotification?: (notification: WSNotificationMessage) => void;
} }

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2020", "DOM"]
},
"include": ["src"],
"exclude": ["node_modules", "dist", "src/__tests__"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
export default defineConfig({
plugins: [
dts({ rollupTypes: true }),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'ResolveSpec',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: ['uuid', 'semver'],
},
},
});