Files
Ryan fb30e8e884 Refactor MQTT course and bound backend message processing
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.
2026-10-22 10:19:56 +03:30

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
}