package contracts import ( "fmt" "time" ) const SchemaVersionOne = 1 // SchemaVersion keeps the teaching examples' original package-level name. const SchemaVersion = SchemaVersionOne // MQTTMessage is implemented by each versioned message contract. // Validation uses the parsed route and caller-supplied time for deterministic checks. type MQTTMessage interface { Validate(topic Topic, now time.Time) error } // These types define the JSON fields shared by backend and simulator. type Heartbeat struct { SchemaVersion int `json:"schema_version"` MessageID string `json:"message_id"` DeviceID string `json:"device_id"` OccurredAt string `json:"occurred_at"` Temperature *float64 `json:"temperature,omitempty"` } type Command struct { SchemaVersion int `json:"schema_version"` MessageID string `json:"message_id"` DeviceID string `json:"device_id"` OccurredAt string `json:"occurred_at"` CommandID string `json:"command_id"` Command string `json:"command"` } type CommandResult struct { SchemaVersion int `json:"schema_version"` MessageID string `json:"message_id"` DeviceID string `json:"device_id"` OccurredAt string `json:"occurred_at"` CommandID string `json:"command_id"` Status string `json:"status"` Detail string `json:"detail,omitempty"` } type SalesLine struct { SKU string `json:"sku"` Quantity int64 `json:"quantity"` AmountMinor int64 `json:"amount_minor"` } type SalesReport struct { SchemaVersion int `json:"schema_version"` ReportID string `json:"report_id"` DeviceID string `json:"device_id"` OccurredAt string `json:"occurred_at"` Items []SalesLine `json:"items"` } // validateEnvelope checks fields shared by every versioned message. func validateEnvelope(topic Topic, version int, messageID, deviceID, occurredAt string, now time.Time) error { if version != SchemaVersionOne { return fmt.Errorf("unsupported schema_version %d", version) } if !validID(messageID) || !validID(deviceID) { return fmt.Errorf("message_id and device_id must be valid IDs") } if topic.DeviceID != deviceID { return fmt.Errorf("topic device %q does not match payload device_id %q", topic.DeviceID, deviceID) } stamp, err := time.Parse(time.RFC3339, occurredAt) if err != nil { return fmt.Errorf("occurred_at must be RFC3339: %w", err) } if stamp.After(now.Add(30 * time.Second)) { return fmt.Errorf("occurred_at is more than 30 seconds in the future") } return nil } // Each Validate method adds checks specific to one message kind. func (m Heartbeat) Validate(topic Topic, now time.Time) error { if topic.Kind != KindTelemetry { return fmt.Errorf("heartbeat requires telemetry topic") } if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil { return err } if m.Temperature != nil && (*m.Temperature < -50 || *m.Temperature > 100) { return fmt.Errorf("temperature is outside the supported range") } return nil } func (m Command) Validate(topic Topic, now time.Time) error { if topic.Kind != KindCommands { return fmt.Errorf("command requires commands topic") } if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil { return err } if !validID(m.CommandID) || m.Command != "ping" { return fmt.Errorf("command_id is invalid or command is unsupported") } return nil } func (m CommandResult) Validate(topic Topic, now time.Time) error { if topic.Kind != KindResponses { return fmt.Errorf("command result requires responses topic") } if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil { return err } if !validID(m.CommandID) || (m.Status != "accepted" && m.Status != "completed" && m.Status != "rejected") { return fmt.Errorf("command result has invalid command_id or status") } return nil } func (m SalesReport) Validate(topic Topic, now time.Time) error { if topic.Kind != KindSales { return fmt.Errorf("sales report requires sales topic") } if err := validateEnvelope(topic, m.SchemaVersion, m.ReportID, m.DeviceID, m.OccurredAt, now); err != nil { return err } if len(m.Items) == 0 || len(m.Items) > 100 { return fmt.Errorf("sales report must contain 1 to 100 items") } for i, item := range m.Items { if !validID(item.SKU) || item.Quantity <= 0 || item.AmountMinor < 0 { return fmt.Errorf("item %d has invalid SKU, quantity, or amount_minor", i) } } return nil }