Files
go-sip/internal/contract/contract.go
T

172 lines
5.6 KiB
Go

package contract
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"time"
"unicode/utf8"
"git.ipao.vip/rogee/go-sip/contracts"
"github.com/santhosh-tekuri/jsonschema/v6"
)
type CommandEnvelope 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"`
}
type ExecutePayload struct {
ExecutionID string `json:"execution_id"`
TaskID string `json:"task_id"`
TaskItemID string `json:"task_item_id"`
TaskRevision int64 `json:"task_revision"`
Callee string `json:"callee"`
RoutePolicyID string `json:"route_policy_id"`
CallerProfileID string `json:"caller_profile_id"`
AgentVersionID string `json:"agent_version_id"`
Variables map[string]any `json:"variables"`
RingTimeoutMS int64 `json:"ring_timeout_ms"`
MaxCallDurationMS int64 `json:"max_call_duration_ms"`
}
type EventEnvelope struct {
SchemaVersion string `json:"schema_version"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TenantID string `json:"tenant_id"`
TenantKey string `json:"tenant_key"`
TraceID string `json:"trace_id"`
OccurredAt string `json:"occurred_at"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
AggregateVersion int64 `json:"aggregate_version"`
Payload map[string]any `json:"payload"`
}
var ErrInvalidTenantKey = errors.New("invalid tenant_key")
// ValidateJSON applies the imported JSON Schema. It intentionally validates
// the source contract instead of maintaining a second hand-written schema.
func ValidateJSON(raw []byte) error {
return ValidateSourceSchema("mq.schema.json", raw)
}
// ValidateEvent applies the event-specific payload contract in addition to the
// generic MQ envelope contract.
func ValidateEvent(raw []byte) error {
return ValidateSourceSchema("event-payloads.schema.json", raw)
}
func ValidateSourceSchema(schemaName string, raw []byte) error {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return fmt.Errorf("decode json: %w", err)
}
var schemaDoc any
if err := contracts.ReadJSON(schemaName, &schemaDoc); err != nil {
return err
}
resource := "https://agent-call.invalid/contracts/" + schemaName
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource(resource, schemaDoc); err != nil {
return fmt.Errorf("register %s: %w", schemaName, err)
}
schema, err := compiler.Compile(resource)
if err != nil {
return fmt.Errorf("compile %s: %w", schemaName, err)
}
if err := schema.Validate(value); err != nil {
return fmt.Errorf("%s validation: %w", schemaName, err)
}
return nil
}
func DecodeExecute(raw []byte) (CommandEnvelope, ExecutePayload, error) {
if err := ValidateJSON(raw); err != nil {
return CommandEnvelope{}, ExecutePayload{}, err
}
var envelope CommandEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("decode command envelope: %w", err)
}
if envelope.CommandType != "call.execute" {
return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("unsupported command_type %q", envelope.CommandType)
}
if err := ValidateTenantKey(envelope.TenantKey); err != nil {
return CommandEnvelope{}, ExecutePayload{}, err
}
var payload ExecutePayload
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("decode call.execute payload: %w", err)
}
return envelope, payload, nil
}
func ValidateTenantKey(key string) error {
if key == "" || !utf8.ValidString(key) {
return fmt.Errorf("%w: must be non-empty valid UTF-8", ErrInvalidTenantKey)
}
if len([]byte(key)) > 224 {
return fmt.Errorf("%w: %d UTF-8 bytes exceeds 224-byte routing budget", ErrInvalidTenantKey, len([]byte(key)))
}
return nil
}
func NotAfterExpired(raw string, now time.Time) (bool, error) {
deadline, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return false, fmt.Errorf("parse not_after: %w", err)
}
return !now.Before(deadline), nil
}
func CloneJSON(raw []byte) json.RawMessage {
return bytes.Clone(raw)
}
type EventBuilder struct {
TenantID string
TenantKey string
TraceID string
EventType string
Aggregate string
AggregateID string
Version int64
Payload map[string]any
}
func (b EventBuilder) Marshal(now time.Time, eventID string) ([]byte, error) {
if err := ValidateTenantKey(b.TenantKey); err != nil {
return nil, err
}
if b.Version < 1 {
return nil, errors.New("aggregate version must be positive")
}
e := EventEnvelope{
SchemaVersion: "1.0", EventID: eventID, EventType: b.EventType,
TenantID: b.TenantID, TenantKey: b.TenantKey, TraceID: b.TraceID,
OccurredAt: now.UTC().Format(time.RFC3339Nano), AggregateType: b.Aggregate,
AggregateID: b.AggregateID, AggregateVersion: b.Version, Payload: b.Payload,
}
raw, err := json.Marshal(e)
if err != nil {
return nil, err
}
if err := ValidateJSON(raw); err != nil {
return nil, err
}
if err := ValidateEvent(raw); err != nil {
return nil, err
}
return raw, nil
}