Skip to content

Commit 878a2f1

Browse files
authored
Merge pull request #3 from feedloop/feature/apicall-action
Feature/apicall action
2 parents bd89c29 + 43b913d commit 878a2f1

17 files changed

Lines changed: 1244 additions & 449 deletions

README.md

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -117,29 +117,69 @@ Qhronos manages database schema changes using embedded migration files. You can
117117
## API Usage
118118
See [API documentation](docs/api.md) for full details.
119119

120-
**Create an event:**
121-
```sh
122-
curl -X POST http://localhost:8080/events \
123-
-H 'Authorization: Bearer <token>' \
124-
-H 'Content-Type: application/json' \
125-
-d '{
126-
"name": "Daily Backup",
127-
"start_time": "2024-03-20T00:00:00Z",
128-
"webhook": "https://example.com/webhook",
129-
"schedule": {
130-
"frequency": "weekly",
131-
"interval": 1,
132-
"by_day": ["MO", "WE", "FR"]
133-
},
134-
"tags": ["system:backup"]
135-
}'
120+
## Event Creation Example
121+
122+
You can now specify an action for event delivery. The `action` field supports both webhook and websocket types.
123+
124+
### Webhook Example
125+
```json
126+
{
127+
"name": "My Event",
128+
"description": "A test event",
129+
"start_time": "2025-01-01T00:00:00Z",
130+
"action": {
131+
"type": "webhook",
132+
"params": { "url": "https://example.com/webhook" }
133+
},
134+
"tags": ["api"]
135+
}
136+
```
137+
138+
### Websocket Example
139+
```json
140+
{
141+
"name": "Websocket Event",
142+
"description": "A test event for websocket client",
143+
"start_time": "2025-01-01T00:00:00Z",
144+
"action": {
145+
"type": "websocket",
146+
"params": { "client_name": "client1" }
147+
},
148+
"tags": ["api"]
149+
}
150+
```
151+
152+
### API Call Example
153+
```json
154+
{
155+
"name": "API Call Event",
156+
"description": "A test event for apicall action",
157+
"start_time": "2025-01-01T00:00:00Z",
158+
"action": {
159+
"type": "apicall",
160+
"params": {
161+
"method": "POST",
162+
"url": "https://api.example.com/endpoint",
163+
"headers": { "Authorization": "Bearer token", "Content-Type": "application/json" },
164+
"body": "{ \"foo\": \"bar\" }"
165+
}
166+
},
167+
"tags": ["api"]
168+
}
136169
```
137170

138-
> **Note:**
139-
> - For one-time events, omit the `schedule` field and provide `start_time`.
140-
> - For recurring events, provide both `start_time` and a `schedule` object.
141-
> - The `webhook` field is required (not `webhook_url`).
142-
> - The `Authorization` header is required for all requests.
171+
### Backward Compatibility
172+
173+
The legacy `webhook` field is still supported for backward compatibility. If you provide `webhook`, it will be automatically mapped to the appropriate `action`.
174+
175+
## Action System
176+
177+
Qhronos uses an extensible action system for event delivery. Each event can specify an `action` object with a `type` and `params`. Supported types:
178+
- `webhook`: Delivers the event to an HTTP endpoint.
179+
- `websocket`: Delivers the event to a connected websocket client.
180+
- `apicall`: Makes a generic HTTP request with custom method, headers, body, and url.
181+
182+
The system is designed to be extensible for future action types.
143183

144184
## Schedule Parameter Tutorial
145185

design.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,72 @@ Qhronos provides a WebSocket server to enable real-time event delivery for two t
344344

345345
---
346346

347+
## Event Model
348+
349+
Events now use an extensible action system for delivery. The `action` field specifies how the event is delivered.
350+
351+
### Event Table (simplified)
352+
| Field | Type | Description |
353+
|-------------- |-------------------- |---------------------------------------------|
354+
| id | UUID | Primary key |
355+
| name | TEXT | Event name |
356+
| description | TEXT | Event description |
357+
| start_time | TIMESTAMP | When the event is scheduled to start |
358+
| action | JSONB | Action object (see below) |
359+
| webhook | TEXT | (Deprecated, for backward compatibility) |
360+
| ... | ... | ... |
361+
362+
### Action Structure
363+
364+
The `action` field is a JSON object:
365+
```json
366+
{
367+
"type": "webhook" | "websocket" | "apicall",
368+
"params": { ... }
369+
}
370+
```
371+
- **type**: The action type. Supported: `webhook`, `websocket`, `apicall`.
372+
- **params**: Parameters for the action type.
373+
- For `webhook`: `{ "url": "https://..." }`
374+
- For `websocket`: `{ "client_name": "client1" }`
375+
- For `apicall`: `{ "method": "POST", "url": "https://...", "headers": { ... }, "body": "..." }`
376+
377+
#### Example: Webhook Action
378+
```json
379+
{
380+
"type": "webhook",
381+
"params": { "url": "https://example.com/webhook" }
382+
}
383+
```
384+
385+
#### Example: Websocket Action
386+
```json
387+
{
388+
"type": "websocket",
389+
"params": { "client_name": "client1" }
390+
}
391+
```
392+
393+
#### Example: API Call Action
394+
```json
395+
{
396+
"type": "apicall",
397+
"params": {
398+
"method": "POST",
399+
"url": "https://api.example.com/endpoint",
400+
"headers": { "Authorization": "Bearer token", "Content-Type": "application/json" },
401+
"body": "{ \"foo\": \"bar\" }"
402+
}
403+
}
404+
```
405+
406+
### Backward Compatibility
407+
- The legacy `webhook` field is still supported for legacy clients. If provided, it is mapped to the appropriate `action`.
408+
409+
## Dispatcher and Action System
410+
411+
The dispatcher no longer switches directly on webhook/websocket. Instead, it delegates event delivery to the action system:
412+
- The dispatcher uses an `ActionsManager` to execute the action specified in the event.
413+
- Each action type (webhook, websocket, apicall) is registered with the manager and can be extended in the future.
414+
- This design allows for easy addition of new action types (e.g., email, SMS, etc.) without changing the dispatcher logic.
415+

internal/actions/apicall.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package actions
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
10+
"github.com/feedloop/qhronos/internal/models"
11+
)
12+
13+
func NewAPICallExecutor(httpClient HTTPClient) ActionExecutor {
14+
return func(ctx context.Context, event *models.Event, params json.RawMessage) error {
15+
var apiParams models.ApicallActionParams
16+
if err := json.Unmarshal(params, &apiParams); err != nil {
17+
return fmt.Errorf("failed to unmarshal apicall params: %w", err)
18+
}
19+
if apiParams.URL == "" || apiParams.Method == "" {
20+
return fmt.Errorf("apicall action requires both url and method")
21+
}
22+
var bodyReader *bytes.Reader
23+
if len(apiParams.Body) > 0 {
24+
bodyReader = bytes.NewReader(apiParams.Body)
25+
} else {
26+
bodyReader = bytes.NewReader([]byte{})
27+
}
28+
req, err := http.NewRequestWithContext(ctx, apiParams.Method, apiParams.URL, bodyReader)
29+
if err != nil {
30+
return fmt.Errorf("failed to build apicall request: %w", err)
31+
}
32+
for k, v := range apiParams.Headers {
33+
req.Header.Set(k, v)
34+
}
35+
resp, err := httpClient.Do(req)
36+
if err != nil {
37+
return fmt.Errorf("apicall request failed: %w", err)
38+
}
39+
defer resp.Body.Close()
40+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
41+
return fmt.Errorf("apicall returned non-2xx status: %d", resp.StatusCode)
42+
}
43+
return nil
44+
}
45+
}

internal/actions/apicall_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package actions
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"io/ioutil"
8+
"net/http"
9+
"testing"
10+
11+
"github.com/feedloop/qhronos/internal/models"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/mock"
14+
)
15+
16+
type MockHTTPClient struct {
17+
mock.Mock
18+
}
19+
20+
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
21+
args := m.Called(req)
22+
resp, _ := args.Get(0).(*http.Response)
23+
return resp, args.Error(1)
24+
}
25+
26+
func TestAPICallExecutor_Success(t *testing.T) {
27+
mockHTTP := new(MockHTTPClient)
28+
executor := NewAPICallExecutor(mockHTTP)
29+
params := models.ApicallActionParams{
30+
Method: "POST",
31+
URL: "https://api.example.com/endpoint",
32+
Headers: map[string]string{"Authorization": "Bearer token", "Content-Type": "application/json"},
33+
Body: json.RawMessage(`{"foo":"bar"}`),
34+
}
35+
paramsBytes, _ := json.Marshal(params)
36+
mockHTTP.On("Do", mock.AnythingOfType("*http.Request")).Return(&http.Response{
37+
StatusCode: 200,
38+
Body: ioutil.NopCloser(nil),
39+
}, nil)
40+
event := &models.Event{ID: [16]byte{1}, Name: "API Call Event"}
41+
err := executor(context.Background(), event, paramsBytes)
42+
assert.NoError(t, err)
43+
mockHTTP.AssertCalled(t, "Do", mock.AnythingOfType("*http.Request"))
44+
}
45+
46+
func TestAPICallExecutor_Non2xx(t *testing.T) {
47+
mockHTTP := new(MockHTTPClient)
48+
executor := NewAPICallExecutor(mockHTTP)
49+
params := models.ApicallActionParams{
50+
Method: "POST",
51+
URL: "https://api.example.com/endpoint",
52+
Headers: map[string]string{},
53+
Body: json.RawMessage(`{"foo":"bar"}`),
54+
}
55+
paramsBytes, _ := json.Marshal(params)
56+
mockHTTP.On("Do", mock.AnythingOfType("*http.Request")).Return(&http.Response{
57+
StatusCode: 500,
58+
Body: ioutil.NopCloser(nil),
59+
}, nil)
60+
event := &models.Event{ID: [16]byte{1}, Name: "API Call Event"}
61+
err := executor(context.Background(), event, paramsBytes)
62+
assert.Error(t, err)
63+
assert.Contains(t, err.Error(), "non-2xx")
64+
}
65+
66+
func TestAPICallExecutor_NetworkError(t *testing.T) {
67+
mockHTTP := new(MockHTTPClient)
68+
executor := NewAPICallExecutor(mockHTTP)
69+
params := models.ApicallActionParams{
70+
Method: "POST",
71+
URL: "https://api.example.com/endpoint",
72+
Headers: map[string]string{},
73+
Body: json.RawMessage(`{"foo":"bar"}`),
74+
}
75+
paramsBytes, _ := json.Marshal(params)
76+
mockHTTP.On("Do", mock.AnythingOfType("*http.Request")).Return((*http.Response)(nil), errors.New("network error"))
77+
event := &models.Event{ID: [16]byte{1}, Name: "API Call Event"}
78+
err := executor(context.Background(), event, paramsBytes)
79+
assert.Error(t, err)
80+
assert.Contains(t, err.Error(), "network error")
81+
}
82+
83+
func TestAPICallExecutor_MissingParams(t *testing.T) {
84+
mockHTTP := new(MockHTTPClient)
85+
executor := NewAPICallExecutor(mockHTTP)
86+
params := models.ApicallActionParams{
87+
Method: "",
88+
URL: "",
89+
Headers: map[string]string{},
90+
Body: json.RawMessage(`{"foo":"bar"}`),
91+
}
92+
paramsBytes, _ := json.Marshal(params)
93+
event := &models.Event{ID: [16]byte{1}, Name: "API Call Event"}
94+
err := executor(context.Background(), event, paramsBytes)
95+
assert.Error(t, err)
96+
assert.Contains(t, err.Error(), "requires both url and method")
97+
}

internal/actions/manager.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package actions
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
9+
"github.com/feedloop/qhronos/internal/models"
10+
)
11+
12+
type ActionExecutor func(ctx context.Context, event *models.Event, params json.RawMessage) error
13+
14+
type ActionsManager struct {
15+
executors map[models.ActionType]ActionExecutor
16+
}
17+
18+
func NewActionsManager() *ActionsManager {
19+
return &ActionsManager{
20+
executors: make(map[models.ActionType]ActionExecutor),
21+
}
22+
}
23+
24+
func (am *ActionsManager) Register(actionType models.ActionType, executor ActionExecutor) {
25+
am.executors[actionType] = executor
26+
}
27+
28+
func (am *ActionsManager) Execute(ctx context.Context, event *models.Event) error {
29+
if event.Action == nil {
30+
return errors.New("event action is nil")
31+
}
32+
exec, ok := am.executors[event.Action.Type]
33+
if !ok {
34+
return fmt.Errorf("no executor registered for action type: %s", event.Action.Type)
35+
}
36+
return exec(ctx, event, event.Action.Params)
37+
}

0 commit comments

Comments
 (0)