Files
go-sip/internal/control/http.go
T

200 lines
8.4 KiB
Go

// 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()
}