Move lesson source into the teaching structure, align the course path with the learner's Go and DevOps experience, and add the interactive lesson guides. Add advanced DevOps lifecycle study as a separate section. Bound MQTT message work and drain accepted jobs during shutdown; align the backend implementation with the revised course through Section 6.
139 lines
4.5 KiB
Go
139 lines
4.5 KiB
Go
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
|
|
}
|