172 lines
5.6 KiB
Go
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
|
|
}
|