chore: initialize go-sip repository
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
// Package control implements the internal executor control/query/replay API
|
||||
// from the pinned OpenAPI contract. It is not a call-execution ingress.
|
||||
package control
|
||||
@@ -0,0 +1,199 @@
|
||||
// Package control exposes the contract-defined internal control/query/replay
|
||||
// HTTP surface. Call execution itself remains a RabbitMQ command path.
|
||||
package control
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store *store.Store
|
||||
BearerToken string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type controlRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Action string `json:"action"`
|
||||
ExpectedTaskRevision int64 `json:"expected_task_revision"`
|
||||
ActiveCallPolicy string `json:"active_call_policy,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type replayRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Store == nil {
|
||||
writeProblem(w, r, http.StatusInternalServerError, "store_unavailable", "store is not configured", true)
|
||||
return
|
||||
}
|
||||
if !h.authorized(r) {
|
||||
writeProblem(w, r, http.StatusUnauthorized, "unauthorized", "bearer authentication is required", false)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("X-Tenant-ID") == "" || r.Header.Get("X-Request-ID") == "" {
|
||||
writeProblem(w, r, http.StatusBadRequest, "missing_header", "X-Tenant-ID and X-Request-ID are required", false)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "tasks" && parts[5] == "controls":
|
||||
h.controlTask(w, r, parts[4])
|
||||
case r.Method == http.MethodGet && len(parts) == 5 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "commands":
|
||||
h.getCommand(w, r, parts[4])
|
||||
case r.Method == http.MethodGet && len(parts) == 5 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "calls":
|
||||
writeProblem(w, r, http.StatusNotFound, "not_found", "call snapshot is not available", false)
|
||||
case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "calls" && parts[5] == "replays":
|
||||
writeProblem(w, r, http.StatusNotFound, "not_found", "call snapshot is not available", false)
|
||||
case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "commands" && parts[5] == "replays":
|
||||
h.replayCommand(w, r, parts[4])
|
||||
default:
|
||||
writeProblem(w, r, http.StatusNotFound, "not_found", "resource not found", false)
|
||||
}
|
||||
}
|
||||
|
||||
func (h Handler) authorized(r *http.Request) bool {
|
||||
value := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(value, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) == "" {
|
||||
return false
|
||||
}
|
||||
return h.BearerToken == "" || strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) == h.BearerToken
|
||||
}
|
||||
|
||||
func (h Handler) controlTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
var req controlRequest
|
||||
if err := decodeStrict(r, &req); err != nil || req.CommandID == "" || req.Reason == "" || req.ExpectedTaskRevision < 1 || (req.Action != "pause" && req.Action != "resume" && req.Action != "stop") || (req.ActiveCallPolicy != "" && req.ActiveCallPolicy != "drain" && req.ActiveCallPolicy != "hangup") || len(req.Reason) > 512 {
|
||||
writeProblem(w, r, http.StatusBadRequest, "invalid_control", "invalid control request", false)
|
||||
return
|
||||
}
|
||||
task, err := h.Store.FindTask(r.Header.Get("X-Tenant-ID"), taskID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeProblem(w, r, http.StatusNotFound, "not_found", "task not found", false)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(w, r, http.StatusInternalServerError, "lookup_failed", err.Error(), true)
|
||||
return
|
||||
}
|
||||
if err := h.Store.ApplyControlDetailed(task.ExecutionID, req.ExpectedTaskRevision, req.Action, req.ActiveCallPolicy, req.Reason, r.Header.Get("Idempotency-Key")); err != nil {
|
||||
if errors.Is(err, store.ErrCASConflict) {
|
||||
writeProblem(w, r, http.StatusConflict, "revision_conflict", err.Error(), false)
|
||||
return
|
||||
}
|
||||
writeProblem(w, r, http.StatusInternalServerError, "control_failed", err.Error(), true)
|
||||
return
|
||||
}
|
||||
acceptedAt := h.now().UTC().Format(time.RFC3339Nano)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"command_id": req.CommandID, "tenant_id": task.TenantID, "tenant_key": task.TenantKey, "task_id": task.TaskID, "status": "accepted", "requested_task_revision": req.ExpectedTaskRevision, "accepted_at": acceptedAt})
|
||||
}
|
||||
|
||||
func (h Handler) getCommand(w http.ResponseWriter, r *http.Request, commandID string) {
|
||||
record, err := h.Store.GetCommand(r.Header.Get("X-Tenant-ID"), commandID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeProblem(w, r, http.StatusNotFound, "not_found", "command not found", false)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(w, r, http.StatusInternalServerError, "lookup_failed", err.Error(), true)
|
||||
return
|
||||
}
|
||||
var envelope struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
CommandType string `json:"command_type"`
|
||||
CommandID string `json:"command_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantKey string `json:"tenant_key"`
|
||||
TraceID string `json:"trace_id"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
NotAfter string `json:"not_after"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(record.Body, &envelope); err != nil {
|
||||
writeProblem(w, r, http.StatusInternalServerError, "decode_failed", err.Error(), true)
|
||||
return
|
||||
}
|
||||
var taskID, executionID string
|
||||
var requestedRevision any
|
||||
var payload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
TaskRevision int64 `json:"task_revision"`
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err == nil {
|
||||
taskID, executionID, requestedRevision = payload.TaskID, payload.ExecutionID, payload.TaskRevision
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"command_id": record.CommandID, "command_type": record.CommandType,
|
||||
"tenant_id": record.TenantID, "tenant_key": record.TenantKey,
|
||||
"task_id": taskID, "execution_id": executionID, "call_id": nil,
|
||||
"status": record.Status, "reason_code": nil, "wait_reason_code": nil,
|
||||
"accepted_at": record.PersistedAt, "waiting_since": nil,
|
||||
"admission_deadline": envelope.NotAfter, "requested_task_revision": requestedRevision,
|
||||
"applied_task_revision": nil, "task_state": nil,
|
||||
"aggregate_version": 1, "updated_at": record.ReceivedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h Handler) replayCommand(w http.ResponseWriter, r *http.Request, sourceCommandID string) {
|
||||
var req replayRequest
|
||||
if err := decodeStrict(r, &req); err != nil || req.CommandID == "" || req.Reason == "" || len(req.Reason) > 512 {
|
||||
writeProblem(w, r, http.StatusBadRequest, "invalid_replay", "invalid replay request", false)
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("Idempotency-Key")
|
||||
if key == "" {
|
||||
writeProblem(w, r, http.StatusBadRequest, "missing_idempotency_key", "Idempotency-Key is required", false)
|
||||
return
|
||||
}
|
||||
if err := h.Store.ReplayCommand(key, r.Header.Get("X-Tenant-ID"), sourceCommandID, req.Reason); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeProblem(w, r, http.StatusNotFound, "not_found", "source command not found", false)
|
||||
return
|
||||
}
|
||||
writeProblem(w, r, http.StatusInternalServerError, "replay_failed", err.Error(), true)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"command_id": req.CommandID, "status": "accepted", "snapshot_cutoff": h.now().UTC().Format(time.RFC3339Nano)})
|
||||
}
|
||||
|
||||
func decodeStrict(r *http.Request, dst any) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return fmt.Errorf("request has multiple JSON values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, r *http.Request, status int, code, detail string, retryable bool) {
|
||||
writeJSON(w, status, map[string]any{"type": "about:blank", "title": http.StatusText(status), "status": status, "code": code, "detail": detail, "request_id": r.Header.Get("X-Request-ID"), "retryable": retryable})
|
||||
}
|
||||
|
||||
func (h Handler) now() time.Time {
|
||||
if h.Now != nil {
|
||||
return h.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
)
|
||||
|
||||
func setupHandler(t *testing.T) (*Handler, *store.Store) {
|
||||
t.Helper()
|
||||
s, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{Store: s, BearerToken: "test-token"}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return h, s
|
||||
}
|
||||
|
||||
func request(h http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer test-token")
|
||||
r.Header.Set("X-Tenant-ID", "tenant-demo")
|
||||
r.Header.Set("X-Request-ID", "req-1")
|
||||
r.Header.Set("Idempotency-Key", "idem-1")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestControlEndpointUsesCASAndStrictJSON(t *testing.T) {
|
||||
h, _ := setupHandler(t)
|
||||
w := request(h, http.MethodPost, "/internal/v1/outbound/tasks/task-demo/controls", `{"command_id":"ctrl-1","action":"pause","expected_task_revision":1,"reason":"operator"}`)
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
w = request(h, http.MethodPost, "/internal/v1/outbound/tasks/task-demo/controls", `{"command_id":"ctrl-2","action":"resume","expected_task_revision":99,"reason":"stale"}`)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
w = request(h, http.MethodPost, "/internal/v1/outbound/tasks/task-demo/controls", `{"command_id":"ctrl-3","action":"pause","expected_task_revision":1,"reason":"operator","extra":true}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("strict JSON status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandQueryAndReplayAreTenantScopedAndDurable(t *testing.T) {
|
||||
h, s := setupHandler(t)
|
||||
w := request(h, http.MethodGet, "/internal/v1/outbound/commands/cmd_demo_001", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("query status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body["command_id"] != "cmd_demo_001" || body["task_id"] != "task-demo" || body["execution_id"] != "exec_demo_001" || body["aggregate_version"] != float64(1) {
|
||||
t.Fatalf("query body=%s err=%v", w.Body.String(), err)
|
||||
}
|
||||
w = request(h, http.MethodPost, "/internal/v1/outbound/commands/cmd_demo_001/replays", `{"command_id":"replay-1","reason":"repair"}`)
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("replay status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
rows, err := s.DB().Query(`SELECT status FROM outbox WHERE event_id = 'replay-idem-1'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("replay was not persisted to outbox")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user