Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 61 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,29 +117,69 @@ Qhronos manages database schema changes using embedded migration files. You can
## API Usage
See [API documentation](docs/api.md) for full details.

**Create an event:**
```sh
curl -X POST http://localhost:8080/events \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"name": "Daily Backup",
"start_time": "2024-03-20T00:00:00Z",
"webhook": "https://example.com/webhook",
"schedule": {
"frequency": "weekly",
"interval": 1,
"by_day": ["MO", "WE", "FR"]
},
"tags": ["system:backup"]
}'
## Event Creation Example

You can now specify an action for event delivery. The `action` field supports both webhook and websocket types.

### Webhook Example
```json
{
"name": "My Event",
"description": "A test event",
"start_time": "2025-01-01T00:00:00Z",
"action": {
"type": "webhook",
"params": { "url": "https://example.com/webhook" }
},
"tags": ["api"]
}
```

### Websocket Example
```json
{
"name": "Websocket Event",
"description": "A test event for websocket client",
"start_time": "2025-01-01T00:00:00Z",
"action": {
"type": "websocket",
"params": { "client_name": "client1" }
},
"tags": ["api"]
}
```

### API Call Example
```json
{
"name": "API Call Event",
"description": "A test event for apicall action",
"start_time": "2025-01-01T00:00:00Z",
"action": {
"type": "apicall",
"params": {
"method": "POST",
"url": "https://api.example.com/endpoint",
"headers": { "Authorization": "Bearer token", "Content-Type": "application/json" },
"body": "{ \"foo\": \"bar\" }"
}
},
"tags": ["api"]
}
```

> **Note:**
> - For one-time events, omit the `schedule` field and provide `start_time`.
> - For recurring events, provide both `start_time` and a `schedule` object.
> - The `webhook` field is required (not `webhook_url`).
> - The `Authorization` header is required for all requests.
### Backward Compatibility

The legacy `webhook` field is still supported for backward compatibility. If you provide `webhook`, it will be automatically mapped to the appropriate `action`.

## Action System

Qhronos uses an extensible action system for event delivery. Each event can specify an `action` object with a `type` and `params`. Supported types:
- `webhook`: Delivers the event to an HTTP endpoint.
- `websocket`: Delivers the event to a connected websocket client.
- `apicall`: Makes a generic HTTP request with custom method, headers, body, and url.

The system is designed to be extensible for future action types.

## Schedule Parameter Tutorial

Expand Down
69 changes: 69 additions & 0 deletions design.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,72 @@ Qhronos provides a WebSocket server to enable real-time event delivery for two t

---

## Event Model

Events now use an extensible action system for delivery. The `action` field specifies how the event is delivered.

### Event Table (simplified)
| Field | Type | Description |
|-------------- |-------------------- |---------------------------------------------|
| id | UUID | Primary key |
| name | TEXT | Event name |
| description | TEXT | Event description |
| start_time | TIMESTAMP | When the event is scheduled to start |
| action | JSONB | Action object (see below) |
| webhook | TEXT | (Deprecated, for backward compatibility) |
| ... | ... | ... |

### Action Structure

The `action` field is a JSON object:
```json
{
"type": "webhook" | "websocket" | "apicall",
"params": { ... }
}
```
- **type**: The action type. Supported: `webhook`, `websocket`, `apicall`.
- **params**: Parameters for the action type.
- For `webhook`: `{ "url": "https://..." }`
- For `websocket`: `{ "client_name": "client1" }`
- For `apicall`: `{ "method": "POST", "url": "https://...", "headers": { ... }, "body": "..." }`

#### Example: Webhook Action
```json
{
"type": "webhook",
"params": { "url": "https://example.com/webhook" }
}
```

#### Example: Websocket Action
```json
{
"type": "websocket",
"params": { "client_name": "client1" }
}
```

#### Example: API Call Action
```json
{
"type": "apicall",
"params": {
"method": "POST",
"url": "https://api.example.com/endpoint",
"headers": { "Authorization": "Bearer token", "Content-Type": "application/json" },
"body": "{ \"foo\": \"bar\" }"
}
}
```

### Backward Compatibility
- The legacy `webhook` field is still supported for legacy clients. If provided, it is mapped to the appropriate `action`.

## Dispatcher and Action System

The dispatcher no longer switches directly on webhook/websocket. Instead, it delegates event delivery to the action system:
- The dispatcher uses an `ActionsManager` to execute the action specified in the event.
- Each action type (webhook, websocket, apicall) is registered with the manager and can be extended in the future.
- This design allows for easy addition of new action types (e.g., email, SMS, etc.) without changing the dispatcher logic.

45 changes: 45 additions & 0 deletions internal/actions/apicall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package actions

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"

"github.com/feedloop/qhronos/internal/models"
)

func NewAPICallExecutor(httpClient HTTPClient) ActionExecutor {
return func(ctx context.Context, event *models.Event, params json.RawMessage) error {
var apiParams models.ApicallActionParams
if err := json.Unmarshal(params, &apiParams); err != nil {
return fmt.Errorf("failed to unmarshal apicall params: %w", err)
}
if apiParams.URL == "" || apiParams.Method == "" {
return fmt.Errorf("apicall action requires both url and method")
}
var bodyReader *bytes.Reader
if len(apiParams.Body) > 0 {
bodyReader = bytes.NewReader(apiParams.Body)
} else {
bodyReader = bytes.NewReader([]byte{})
}
req, err := http.NewRequestWithContext(ctx, apiParams.Method, apiParams.URL, bodyReader)
if err != nil {
return fmt.Errorf("failed to build apicall request: %w", err)
}
for k, v := range apiParams.Headers {
req.Header.Set(k, v)
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("apicall request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("apicall returned non-2xx status: %d", resp.StatusCode)
}
return nil
}
}
97 changes: 97 additions & 0 deletions internal/actions/apicall_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package actions

import (
"context"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"testing"

"github.com/feedloop/qhronos/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

type MockHTTPClient struct {
mock.Mock
}

func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
args := m.Called(req)
resp, _ := args.Get(0).(*http.Response)
return resp, args.Error(1)
}

func TestAPICallExecutor_Success(t *testing.T) {
mockHTTP := new(MockHTTPClient)
executor := NewAPICallExecutor(mockHTTP)
params := models.ApicallActionParams{
Method: "POST",
URL: "https://api.example.com/endpoint",
Headers: map[string]string{"Authorization": "Bearer token", "Content-Type": "application/json"},
Body: json.RawMessage(`{"foo":"bar"}`),
}
paramsBytes, _ := json.Marshal(params)
mockHTTP.On("Do", mock.AnythingOfType("*http.Request")).Return(&http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(nil),
}, nil)
event := &models.Event{ID: [16]byte{1}, Name: "API Call Event"}
err := executor(context.Background(), event, paramsBytes)
assert.NoError(t, err)
mockHTTP.AssertCalled(t, "Do", mock.AnythingOfType("*http.Request"))
}

func TestAPICallExecutor_Non2xx(t *testing.T) {
mockHTTP := new(MockHTTPClient)
executor := NewAPICallExecutor(mockHTTP)
params := models.ApicallActionParams{
Method: "POST",
URL: "https://api.example.com/endpoint",
Headers: map[string]string{},
Body: json.RawMessage(`{"foo":"bar"}`),
}
paramsBytes, _ := json.Marshal(params)
mockHTTP.On("Do", mock.AnythingOfType("*http.Request")).Return(&http.Response{
StatusCode: 500,
Body: ioutil.NopCloser(nil),
}, nil)
event := &models.Event{ID: [16]byte{1}, Name: "API Call Event"}
err := executor(context.Background(), event, paramsBytes)
assert.Error(t, err)
assert.Contains(t, err.Error(), "non-2xx")
}

func TestAPICallExecutor_NetworkError(t *testing.T) {
mockHTTP := new(MockHTTPClient)
executor := NewAPICallExecutor(mockHTTP)
params := models.ApicallActionParams{
Method: "POST",
URL: "https://api.example.com/endpoint",
Headers: map[string]string{},
Body: json.RawMessage(`{"foo":"bar"}`),
}
paramsBytes, _ := json.Marshal(params)
mockHTTP.On("Do", mock.AnythingOfType("*http.Request")).Return((*http.Response)(nil), errors.New("network error"))
event := &models.Event{ID: [16]byte{1}, Name: "API Call Event"}
err := executor(context.Background(), event, paramsBytes)
assert.Error(t, err)
assert.Contains(t, err.Error(), "network error")
}

func TestAPICallExecutor_MissingParams(t *testing.T) {
mockHTTP := new(MockHTTPClient)
executor := NewAPICallExecutor(mockHTTP)
params := models.ApicallActionParams{
Method: "",
URL: "",
Headers: map[string]string{},
Body: json.RawMessage(`{"foo":"bar"}`),
}
paramsBytes, _ := json.Marshal(params)
event := &models.Event{ID: [16]byte{1}, Name: "API Call Event"}
err := executor(context.Background(), event, paramsBytes)
assert.Error(t, err)
assert.Contains(t, err.Error(), "requires both url and method")
}
37 changes: 37 additions & 0 deletions internal/actions/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package actions

import (
"context"
"encoding/json"
"errors"
"fmt"

"github.com/feedloop/qhronos/internal/models"
)

type ActionExecutor func(ctx context.Context, event *models.Event, params json.RawMessage) error

type ActionsManager struct {
executors map[models.ActionType]ActionExecutor
}

func NewActionsManager() *ActionsManager {
return &ActionsManager{
executors: make(map[models.ActionType]ActionExecutor),
}
}

func (am *ActionsManager) Register(actionType models.ActionType, executor ActionExecutor) {
am.executors[actionType] = executor
}

func (am *ActionsManager) Execute(ctx context.Context, event *models.Event) error {
if event.Action == nil {
return errors.New("event action is nil")
}
exec, ok := am.executors[event.Action.Type]
if !ok {
return fmt.Errorf("no executor registered for action type: %s", event.Action.Type)
}
return exec(ctx, event, event.Action.Params)
}
Loading
Loading