chore: initialize go-sip repository

This commit is contained in:
2026-09-21 08:56:04 +08:00
commit 643b11b21f
309 changed files with 40521 additions and 0 deletions
+3
View File
@@ -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
+199
View File
@@ -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()
}
+81
View File
@@ -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")
}
}