176 lines
5.6 KiB
Go
176 lines
5.6 KiB
Go
package contract
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.ipao.vip/rogee/go-sip/contracts"
|
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
|
)
|
|
|
|
type CommandEnvelope struct {
|
|
DispatcherID string `json:"dispatcher_id,omitempty"`
|
|
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 ValidateLocalConfigRead(raw []byte) error {
|
|
return validateLocalSchema("config-read-v0.1.schema.json", raw)
|
|
}
|
|
|
|
func ValidateLocalTaskDiscovery(raw []byte) error {
|
|
return validateLocalSchema("task-discovery-v0.2-proposal.schema.json", raw)
|
|
}
|
|
|
|
func ValidateLocalCommandNext(raw []byte) error {
|
|
return validateLocalSchema("command-next-v0.1-proposal.schema.json", raw)
|
|
}
|
|
|
|
func ValidateLocalCallResult(raw []byte) error {
|
|
return validateLocalSchema("call-result-v0.1-proposal.schema.json", raw)
|
|
}
|
|
|
|
func validateLocalSchema(schemaName string, raw []byte) error {
|
|
value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return fmt.Errorf("decode %s response: %w", schemaName, err)
|
|
}
|
|
schema, err := localSchemaFromBundle(schemaName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := schema.Validate(value); err != nil {
|
|
var validation *jsonschema.ValidationError
|
|
if errors.As(err, &validation) {
|
|
return fmt.Errorf("%s validation failed at /%s", schemaName, strings.Join(validation.InstanceLocation, "/"))
|
|
}
|
|
return fmt.Errorf("%s validation failed", schemaName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateSourceSchema(schemaName string, raw []byte) error {
|
|
value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return fmt.Errorf("decode json: %w", err)
|
|
}
|
|
schema, err := schemaFromBundle(contracts.SourceCommit, schemaName)
|
|
if err != nil {
|
|
return 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) {
|
|
envelope, err := DecodeMQCommand(raw)
|
|
if err != nil {
|
|
return CommandEnvelope{}, ExecutePayload{}, 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 {
|
|
DispatcherID string
|
|
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) {
|
|
return b.MarshalMQ(b.DispatcherID, now, eventID)
|
|
}
|