ed593efb71
Execution events (started/failed/succeeded) never carried the correlation/causation IDs from the ExecuteRequest, even though the fields existed on Execution. Added CausationID to ExecuteRequest and Execution, and split emitEvent into a correlated variant used throughout the execution lifecycle. handleExecute falls back to X-Correlation-ID/X-Causation-ID headers when the body omits them. Also add X-Hexis-Version negotiation on /api/v1/*, matching Nexus. Part of Vikunja #273. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
196 lines
6.1 KiB
Go
196 lines
6.1 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// APIVersion is sent as X-Hexis-Version on every request so the server can
|
|
// negotiate/reject unsupported client versions.
|
|
const APIVersion = "v1"
|
|
|
|
type correlationKey struct{}
|
|
type causationKey struct{}
|
|
|
|
// WithCorrelationID returns a context that carries a correlation ID to be
|
|
// sent as X-Correlation-ID on every Hexis request made with it.
|
|
func WithCorrelationID(ctx context.Context, id string) context.Context {
|
|
return context.WithValue(ctx, correlationKey{}, id)
|
|
}
|
|
|
|
// WithCausationID returns a context that carries a causation ID to be sent
|
|
// as X-Causation-ID on every Hexis request made with it.
|
|
func WithCausationID(ctx context.Context, id string) context.Context {
|
|
return context.WithValue(ctx, causationKey{}, id)
|
|
}
|
|
|
|
func correlationIDFrom(ctx context.Context) string {
|
|
id, _ := ctx.Value(correlationKey{}).(string)
|
|
return id
|
|
}
|
|
|
|
func causationIDFrom(ctx context.Context) string {
|
|
id, _ := ctx.Value(causationKey{}).(string)
|
|
return id
|
|
}
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func New(baseURL string) *Client {
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body, result any) error {
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
reqBody = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Hexis-Version", APIVersion)
|
|
if id := correlationIDFrom(ctx); id != "" {
|
|
req.Header.Set("X-Correlation-ID", id)
|
|
}
|
|
if id := causationIDFrom(ctx); id != "" {
|
|
req.Header.Set("X-Causation-ID", id)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
var errResp struct {
|
|
Error string `json:"error"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&errResp)
|
|
if errResp.Error != "" {
|
|
return fmt.Errorf("%s: %s", http.StatusText(resp.StatusCode), errResp.Error)
|
|
}
|
|
return fmt.Errorf("%s", http.StatusText(resp.StatusCode))
|
|
}
|
|
|
|
if result != nil {
|
|
return json.NewDecoder(resp.Body).Decode(result)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Capability struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
TargetTypes []string `json:"target_types"`
|
|
TargetEntityID string `json:"target_entity_id,omitempty"`
|
|
Provider string `json:"provider"`
|
|
Operation string `json:"operation"`
|
|
Risk string `json:"risk,omitempty"`
|
|
ReadOnly bool `json:"read_only"`
|
|
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
|
|
}
|
|
|
|
type Execution struct {
|
|
ID string `json:"id"`
|
|
CapabilityID string `json:"capability_id"`
|
|
TargetEntityID string `json:"target_entity_id"`
|
|
Status string `json:"status"`
|
|
Result map[string]any `json:"result,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
RequestedBy map[string]string `json:"requested_by,omitempty"`
|
|
CorrelationID string `json:"correlation_id,omitempty"`
|
|
CausationID string `json:"causation_id,omitempty"`
|
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
|
}
|
|
|
|
type ExecuteRequest struct {
|
|
CapabilityID string `json:"capability_id"`
|
|
TargetEntityID string `json:"target_entity_id"`
|
|
Arguments map[string]any `json:"arguments,omitempty"`
|
|
RequestedBy map[string]string `json:"requested_by,omitempty"`
|
|
Origin map[string]string `json:"origin,omitempty"`
|
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
|
CorrelationID string `json:"correlation_id,omitempty"`
|
|
CausationID string `json:"causation_id,omitempty"`
|
|
}
|
|
|
|
type CreateCapabilityRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
TargetTypes []string `json:"target_types"`
|
|
TargetEntityID string `json:"target_entity_id,omitempty"`
|
|
Provider string `json:"provider"`
|
|
Operation string `json:"operation"`
|
|
Risk string `json:"risk,omitempty"`
|
|
ReadOnly bool `json:"read_only"`
|
|
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
|
|
}
|
|
|
|
func (c *Client) Capabilities(ctx context.Context, entityID string) ([]Capability, error) {
|
|
var result []Capability
|
|
path := "/api/v1/capabilities"
|
|
if entityID != "" {
|
|
path += "?entity_id=" + entityID
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c *Client) GetCapability(ctx context.Context, id string) (*Capability, error) {
|
|
var result Capability
|
|
if err := c.do(ctx, http.MethodGet, "/api/v1/capabilities/"+id, nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *Client) CreateCapability(ctx context.Context, req CreateCapabilityRequest) (*Capability, error) {
|
|
var result Capability
|
|
if err := c.do(ctx, http.MethodPost, "/api/v1/capabilities", req, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *Client) Execute(ctx context.Context, req ExecuteRequest) (*Execution, error) {
|
|
var result Execution
|
|
if err := c.do(ctx, http.MethodPost, "/api/v1/execute", req, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *Client) GetExecution(ctx context.Context, id string) (*Execution, error) {
|
|
var result Execution
|
|
if err := c.do(ctx, http.MethodGet, "/api/v1/executions/"+id, nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *Client) Health(ctx context.Context) error {
|
|
return c.do(ctx, http.MethodGet, "/health", nil, nil)
|
|
}
|