ResolveSpec/pkg/websocketspec/README.md
2025-12-12 16:14:47 +02:00

19 KiB

WebSocketSpec - Real-Time WebSocket API Framework

WebSocketSpec provides a WebSocket-based API specification for real-time, bidirectional communication with full CRUD operations, subscriptions, and lifecycle hooks.

Table of Contents

Features

  • Real-Time Bidirectional Communication: WebSocket-based persistent connections
  • Full CRUD Operations: Create, Read, Update, Delete with rich query options
  • Real-Time Subscriptions: Subscribe to entity changes with filter support
  • Automatic Notifications: Server pushes updates to subscribed clients
  • Lifecycle Hooks: Before/after hooks for all operations
  • Database Agnostic: Works with GORM and Bun ORM through adapters
  • Connection Management: Automatic connection tracking and cleanup
  • Request/Response Correlation: Message IDs for tracking requests
  • Filter & Sort: Advanced filtering, sorting, pagination, and preloading

Installation

go get github.com/bitechdev/ResolveSpec

Quick Start

Server Setup

package main

import (
    "net/http"
    "github.com/bitechdev/ResolveSpec/pkg/websocketspec"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func main() {
    // Connect to database
    db, _ := gorm.Open(postgres.Open("your-connection-string"), &gorm.Config{})

    // Create WebSocket handler
    handler := websocketspec.NewHandlerWithGORM(db)

    // Register models
    handler.Registry.RegisterModel("public.users", &User{})
    handler.Registry.RegisterModel("public.posts", &Post{})

    // Setup WebSocket endpoint
    http.HandleFunc("/ws", handler.HandleWebSocket)

    // Start server
    http.ListenAndServe(":8080", nil)
}

type User struct {
    ID     uint   `json:"id" gorm:"primaryKey"`
    Name   string `json:"name"`
    Email  string `json:"email"`
    Status string `json:"status"`
}

type Post struct {
    ID      uint   `json:"id" gorm:"primaryKey"`
    Title   string `json:"title"`
    Content string `json:"content"`
    UserID  uint   `json:"user_id"`
}

Client Setup (JavaScript)

const ws = new WebSocket("ws://localhost:8080/ws");

ws.onopen = () => {
    console.log("Connected to WebSocket");
};

ws.onmessage = (event) => {
    const message = JSON.parse(event.data);
    console.log("Received:", message);
};

ws.onerror = (error) => {
    console.error("WebSocket error:", error);
};

Message Protocol

All messages are JSON-encoded with the following structure:

interface Message {
    id: string;                  // Unique message ID for correlation
    type: "request" | "response" | "notification" | "subscription";
    operation?: "read" | "create" | "update" | "delete" | "subscribe" | "unsubscribe" | "meta";
    schema?: string;             // Database schema
    entity: string;              // Table/model name
    record_id?: string;          // For single-record operations
    data?: any;                  // Request/response payload
    options?: QueryOptions;      // Filters, sorting, pagination
    subscription_id?: string;    // For subscription messages
    success?: boolean;           // Response success indicator
    error?: ErrorInfo;           // Error details
    metadata?: Record<string, any>; // Additional metadata
    timestamp?: string;          // Message timestamp
}

interface QueryOptions {
    filters?: FilterOption[];
    columns?: string[];
    preload?: PreloadOption[];
    sort?: SortOption[];
    limit?: number;
    offset?: number;
}

CRUD Operations

CREATE - Create New Records

Request:

{
    "id": "msg-1",
    "type": "request",
    "operation": "create",
    "schema": "public",
    "entity": "users",
    "data": {
        "name": "John Doe",
        "email": "john@example.com",
        "status": "active"
    }
}

Response:

{
    "id": "msg-1",
    "type": "response",
    "success": true,
    "data": {
        "id": 123,
        "name": "John Doe",
        "email": "john@example.com",
        "status": "active"
    },
    "timestamp": "2025-12-12T10:30:00Z"
}

READ - Query Records

Read Multiple Records:

{
    "id": "msg-2",
    "type": "request",
    "operation": "read",
    "schema": "public",
    "entity": "users",
    "options": {
        "filters": [
            {"column": "status", "operator": "eq", "value": "active"}
        ],
        "columns": ["id", "name", "email"],
        "sort": [
            {"column": "name", "direction": "asc"}
        ],
        "limit": 10,
        "offset": 0
    }
}

Read Single Record:

{
    "id": "msg-3",
    "type": "request",
    "operation": "read",
    "schema": "public",
    "entity": "users",
    "record_id": "123"
}

Response:

{
    "id": "msg-2",
    "type": "response",
    "success": true,
    "data": [
        {"id": 1, "name": "Alice", "email": "alice@example.com"},
        {"id": 2, "name": "Bob", "email": "bob@example.com"}
    ],
    "metadata": {
        "total": 50,
        "count": 2
    },
    "timestamp": "2025-12-12T10:30:00Z"
}

UPDATE - Update Records

{
    "id": "msg-4",
    "type": "request",
    "operation": "update",
    "schema": "public",
    "entity": "users",
    "record_id": "123",
    "data": {
        "name": "John Updated",
        "email": "john.updated@example.com"
    }
}

DELETE - Delete Records

{
    "id": "msg-5",
    "type": "request",
    "operation": "delete",
    "schema": "public",
    "entity": "users",
    "record_id": "123"
}

Subscriptions

Subscriptions allow clients to receive real-time notifications when entities change.

Subscribe to Changes

{
    "id": "sub-1",
    "type": "subscription",
    "operation": "subscribe",
    "schema": "public",
    "entity": "users",
    "options": {
        "filters": [
            {"column": "status", "operator": "eq", "value": "active"}
        ]
    }
}

Response:

{
    "id": "sub-1",
    "type": "response",
    "success": true,
    "data": {
        "subscription_id": "sub-abc123",
        "schema": "public",
        "entity": "users"
    },
    "timestamp": "2025-12-12T10:30:00Z"
}

Receive Notifications

When a subscribed entity changes, clients automatically receive notifications:

{
    "type": "notification",
    "operation": "create",
    "subscription_id": "sub-abc123",
    "schema": "public",
    "entity": "users",
    "data": {
        "id": 124,
        "name": "Jane Smith",
        "email": "jane@example.com",
        "status": "active"
    },
    "timestamp": "2025-12-12T10:35:00Z"
}

Notification Operations:

  • create - New record created
  • update - Record updated
  • delete - Record deleted

Unsubscribe

{
    "id": "unsub-1",
    "type": "subscription",
    "operation": "unsubscribe",
    "subscription_id": "sub-abc123"
}

Lifecycle Hooks

Hooks allow you to intercept and modify operations at various points in the lifecycle.

Available Hook Types

  • BeforeRead / AfterRead
  • BeforeCreate / AfterCreate
  • BeforeUpdate / AfterUpdate
  • BeforeDelete / AfterDelete
  • BeforeSubscribe / AfterSubscribe
  • BeforeConnect / AfterConnect

Hook Example

handler := websocketspec.NewHandlerWithGORM(db)

// Authorization hook
handler.Hooks().RegisterBefore(websocketspec.OperationRead, func(ctx *websocketspec.HookContext) error {
    // Check permissions
    userID, _ := ctx.Connection.GetMetadata("user_id")
    if userID == nil {
        return fmt.Errorf("unauthorized: user not authenticated")
    }

    // Add filter to only show user's own records
    if ctx.Entity == "posts" {
        ctx.Options.Filters = append(ctx.Options.Filters, common.FilterOption{
            Column:   "user_id",
            Operator: "eq",
            Value:    userID,
        })
    }

    return nil
})

// Logging hook
handler.Hooks().RegisterAfter(websocketspec.OperationCreate, func(ctx *websocketspec.HookContext) error {
    log.Printf("Created %s in %s.%s", ctx.Result, ctx.Schema, ctx.Entity)
    return nil
})

// Validation hook
handler.Hooks().RegisterBefore(websocketspec.OperationCreate, func(ctx *websocketspec.HookContext) error {
    // Validate data before creation
    if data, ok := ctx.Data.(map[string]interface{}); ok {
        if email, exists := data["email"]; !exists || email == "" {
            return fmt.Errorf("email is required")
        }
    }
    return nil
})

Client Examples

JavaScript/TypeScript Client

class WebSocketClient {
    private ws: WebSocket;
    private messageHandlers: Map<string, (data: any) => void> = new Map();
    private subscriptions: Map<string, (data: any) => void> = new Map();

    constructor(url: string) {
        this.ws = new WebSocket(url);
        this.ws.onmessage = (event) => this.handleMessage(event);
    }

    // Send request and wait for response
    async request(operation: string, entity: string, options?: any): Promise<any> {
        const id = this.generateId();

        return new Promise((resolve, reject) => {
            this.messageHandlers.set(id, (data) => {
                if (data.success) {
                    resolve(data.data);
                } else {
                    reject(data.error);
                }
            });

            this.ws.send(JSON.stringify({
                id,
                type: "request",
                operation,
                entity,
                ...options
            }));
        });
    }

    // Subscribe to entity changes
    async subscribe(entity: string, filters?: any[], callback?: (data: any) => void): Promise<string> {
        const id = this.generateId();

        return new Promise((resolve, reject) => {
            this.messageHandlers.set(id, (data) => {
                if (data.success) {
                    const subId = data.data.subscription_id;
                    if (callback) {
                        this.subscriptions.set(subId, callback);
                    }
                    resolve(subId);
                } else {
                    reject(data.error);
                }
            });

            this.ws.send(JSON.stringify({
                id,
                type: "subscription",
                operation: "subscribe",
                entity,
                options: { filters }
            }));
        });
    }

    private handleMessage(event: MessageEvent) {
        const message = JSON.parse(event.data);

        if (message.type === "response") {
            const handler = this.messageHandlers.get(message.id);
            if (handler) {
                handler(message);
                this.messageHandlers.delete(message.id);
            }
        } else if (message.type === "notification") {
            const callback = this.subscriptions.get(message.subscription_id);
            if (callback) {
                callback(message);
            }
        }
    }

    private generateId(): string {
        return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
    }
}

// Usage
const client = new WebSocketClient("ws://localhost:8080/ws");

// Read users
const users = await client.request("read", "users", {
    options: {
        filters: [{ column: "status", operator: "eq", value: "active" }],
        limit: 10
    }
});

// Subscribe to user changes
await client.subscribe("users",
    [{ column: "status", operator: "eq", value: "active" }],
    (notification) => {
        console.log("User changed:", notification.operation, notification.data);
    }
);

// Create user
const newUser = await client.request("create", "users", {
    data: {
        name: "Alice",
        email: "alice@example.com",
        status: "active"
    }
});

Python Client Example

import asyncio
import websockets
import json
import uuid

class WebSocketClient:
    def __init__(self, url):
        self.url = url
        self.ws = None
        self.handlers = {}
        self.subscriptions = {}

    async def connect(self):
        self.ws = await websockets.connect(self.url)
        asyncio.create_task(self.listen())

    async def listen(self):
        async for message in self.ws:
            data = json.loads(message)

            if data["type"] == "response":
                handler = self.handlers.get(data["id"])
                if handler:
                    handler(data)
                    del self.handlers[data["id"]]

            elif data["type"] == "notification":
                callback = self.subscriptions.get(data["subscription_id"])
                if callback:
                    callback(data)

    async def request(self, operation, entity, **kwargs):
        msg_id = str(uuid.uuid4())
        future = asyncio.Future()

        self.handlers[msg_id] = lambda data: future.set_result(data)

        await self.ws.send(json.dumps({
            "id": msg_id,
            "type": "request",
            "operation": operation,
            "entity": entity,
            **kwargs
        }))

        result = await future
        if result["success"]:
            return result["data"]
        else:
            raise Exception(result["error"]["message"])

    async def subscribe(self, entity, callback, filters=None):
        msg_id = str(uuid.uuid4())
        future = asyncio.Future()

        self.handlers[msg_id] = lambda data: future.set_result(data)

        await self.ws.send(json.dumps({
            "id": msg_id,
            "type": "subscription",
            "operation": "subscribe",
            "entity": entity,
            "options": {"filters": filters} if filters else {}
        }))

        result = await future
        if result["success"]:
            sub_id = result["data"]["subscription_id"]
            self.subscriptions[sub_id] = callback
            return sub_id
        else:
            raise Exception(result["error"]["message"])

# Usage
async def main():
    client = WebSocketClient("ws://localhost:8080/ws")
    await client.connect()

    # Read users
    users = await client.request("read", "users",
        options={
            "filters": [{"column": "status", "operator": "eq", "value": "active"}],
            "limit": 10
        }
    )
    print("Users:", users)

    # Subscribe to changes
    def on_user_change(notification):
        print(f"User {notification['operation']}: {notification['data']}")

    await client.subscribe("users", on_user_change,
        filters=[{"column": "status", "operator": "eq", "value": "active"}]
    )

asyncio.run(main())

Authentication

Implement authentication using hooks:

handler := websocketspec.NewHandlerWithGORM(db)

// Authentication on connection
handler.Hooks().Register(websocketspec.BeforeConnect, func(ctx *websocketspec.HookContext) error {
    // Extract token from query params or headers
    r := ctx.Connection.ws.UnderlyingConn().RemoteAddr()

    // Validate token (implement your auth logic)
    token := extractToken(r)
    user, err := validateToken(token)
    if err != nil {
        return fmt.Errorf("authentication failed: %w", err)
    }

    // Store user info in connection metadata
    ctx.Connection.SetMetadata("user", user)
    ctx.Connection.SetMetadata("user_id", user.ID)

    return nil
})

// Check permissions for each operation
handler.Hooks().RegisterBefore(websocketspec.OperationRead, func(ctx *websocketspec.HookContext) error {
    userID, ok := ctx.Connection.GetMetadata("user_id")
    if !ok {
        return fmt.Errorf("unauthorized")
    }

    // Add user-specific filters
    if ctx.Entity == "orders" {
        ctx.Options.Filters = append(ctx.Options.Filters, common.FilterOption{
            Column:   "user_id",
            Operator: "eq",
            Value:    userID,
        })
    }

    return nil
})

Error Handling

Errors are returned in a consistent format:

{
    "id": "msg-1",
    "type": "response",
    "success": false,
    "error": {
        "code": "validation_error",
        "message": "Email is required",
        "details": {
            "field": "email"
        }
    },
    "timestamp": "2025-12-12T10:30:00Z"
}

Common Error Codes:

  • invalid_message - Message format is invalid
  • model_not_found - Entity not registered
  • invalid_model - Model validation failed
  • read_error - Read operation failed
  • create_error - Create operation failed
  • update_error - Update operation failed
  • delete_error - Delete operation failed
  • hook_error - Hook execution failed
  • unauthorized - Authentication/authorization failed

Best Practices

  1. Always Use Message IDs: Correlate requests with responses using unique IDs
  2. Handle Reconnections: Implement automatic reconnection logic on the client
  3. Validate Data: Use before-hooks to validate data before operations
  4. Limit Subscriptions: Implement limits on subscriptions per connection
  5. Use Filters: Apply filters to subscriptions to reduce unnecessary notifications
  6. Implement Authentication: Always validate users before processing operations
  7. Handle Errors Gracefully: Display user-friendly error messages
  8. Clean Up: Unsubscribe when components unmount or disconnect
  9. Rate Limiting: Implement rate limiting to prevent abuse
  10. Monitor Connections: Track active connections and subscriptions

Filter Operators

Supported 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)

Performance Considerations

  • Connection Pooling: WebSocket connections are reused, reducing overhead
  • Subscription Filtering: Only matching updates are sent to clients
  • Efficient Queries: Uses database adapters for optimized queries
  • Message Batching: Multiple messages can be sent in one write
  • Keepalive: Automatic ping/pong for connection health

Comparison with Other Specs

Feature WebSocketSpec RestHeadSpec ResolveSpec
Protocol WebSocket HTTP/REST HTTP/REST
Real-time Yes No No
Subscriptions Yes No No
Bidirectional Yes No No
Query Options In Message In Headers In Body
Overhead Low Medium Medium
Use Case Real-time apps Traditional APIs Body-based APIs

License

MIT License - See LICENSE file for details