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.
This commit is contained in:
2026-10-22 10:19:56 +03:30
parent f9ab91ed60
commit fb30e8e884
70 changed files with 7492 additions and 312 deletions
@@ -0,0 +1,714 @@
# 📋 Section 5 — Design contracts and validate inbound messages
## 🎬 Start with the system story
> **❓ Question:** **The broker can route a message because its topic matches. How does the Go backend decide that the topic and payload describe a valid message from the expected device?**
**💬 Answer:** The topic provides the route, but it does not verify the payload. The backend parses the topic, validates the JSON, and compares the claimed device ID before business handling. Broker authentication and ACLs separately control who may publish to a topic.
```mermaid
flowchart LR
B[Broker routes matching topic] --> P[Paho delivers topic + bytes]
P --> T[Parse topic and device scope]
T --> J[Decode versioned JSON]
J --> V[Validate identity, IDs, size, time]
V -->|valid| H[Business handler]
V -->|invalid| E[Safe rejection / metric]
```
**Before each implementation step, ask:** What can be trusted at this boundary, and what must be checked before the application acts? Follow [Lesson 0](00-system-lifecycle.md) for the complete message path.
Prerequisite: Section 4 exchanges heartbeats and ping/pong. The clients share the disposable password in `configs/lab.toml`; select the simulator account with `-username simulator` when starting it.
Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Save the complete code blocks at their project-relative paths, not under `teaching/course-source/`. This lesson provides a full working implementation with the pinned Paho v0.23.0 dependency; use the exercises to inspect and verify its behavior.
## 📨 Follow one message through the trust boundaries
**❓ Question:** **The backend receives a sales report on device-001's topic, but its JSON says `device_id: device-002`. What could go wrong if the backend accepts it?**
**💬 Answer:** The report could be applied to the wrong device. The broker may allow the publish, but it does not compare the topic with the JSON. The backend compares both IDs and rejects a mismatch before changing data.
```text
PUBLISH → size limit → topic parser → JSON decoder → schema checks
→ topic/body identity check → business validation → handler
```
The backend applies several checks because each catches a different problem. A size limit bounds the payload it decodes. The topic parser identifies the device and message type. JSON decoding checks the data's shape, while business validation checks its values. For example, a sales report can be valid JSON but contain a negative quantity. The backend rejects that value. These checks do not replace broker authentication or ACLs.
**❓ Question:** **Does the 16 KiB application limit cap the whole MQTT packet?**
**💬 Answer:** No. It caps the payload the application decodes. The topic and properties add packet bytes, and Paho has already received the packet before this check runs. Lesson 16 covers an earlier packet-size limit.
### 📨 Map encoding metadata into Paho
This fragment belongs where you build a `paho.Publish`; `payload` is the JSON produced by your contract encoder:
```go
utf8Payload := byte(1)
// Describe the payload's encoding and media type for MQTT 5 consumers.
properties := &paho.PublishProperties{
ContentType: "application/json",
PayloadFormat: &utf8Payload,
}
// Attach these properties to the PUBLISH packet built by your transport helper.
_ = properties
```
**❓ Question:** **A message declares JSON, but its payload is `hello`. Is it valid JSON?**
**💬 Answer:** No. Content Type describes the media type; Payload Format Indicator 1 says the bytes are UTF-8 text. Neither validates the content or proves its schema. Decode the bytes, and check an incoming property pointer for nil before reading it; nil means that property was omitted.
Keep MQTT properties with the received transport message when the application needs them. Fields such as `schema_version` and `command_id` belong in the JSON contract type. This lets handlers use the needed data without depending on a packet object that another part of the program may change.
### 🧪 Predict, modify, explain
Send a valid heartbeat, then change only its body device ID, then change only its schema version. Predict which validation stage rejects each. Finally publish valid JSON with an extra trailing object: one successful decoder call is insufficient unless you also require EOF.
<details>
<summary>Why can an invalid message still get a successful MQTT acknowledgement?</summary>
**❓ Question:** **The broker delivers a sales report with quantity `-3`. Can the MQTT client acknowledge it even if the application rejects it?**
**💬 Answer:** Yes. In this helper, the callback can return and the MQTT client may acknowledge delivery. The backend then records the validation failure and rejects the sale. PUBACK confirms the MQTT delivery step; it does not mean the sale succeeded.
</details>
## 📋 1. Create the contract package
> **❓ Question:** **What shared vocabulary lets the backend and device agree on the shape and meaning of each message?**
The contract package gives both programs the same field names and meanings. Its `topic.go` file owns the hierarchy and distinguishes a concrete publish name from a subscription filter; `message.go` owns JSON payload types and their checks. For example, if the simulator sends `device` while the backend expects `device_id`, the message may be rejected or misunderstood.
```sh
mkdir -p internal/contracts
```
The complete implementation for this step is shown below. Save each block at its stated path, then update the backend and simulator to use these contracts before they handle or publish messages.
Save this as `internal/contracts/topic.go`. Section 4 introduced its basic purpose; here it is included in full so this lesson can be used on its own.
### 🔍 `topic.go` — file overview
This file owns the lab hierarchy `lab/v1/devices/{deviceID}/{kind}`. It builds concrete `TopicName` values for PUBLISH, fleet-wide and exact-device `TopicFilter` values for SUBSCRIBE, and parses an incoming name into a device ID and flow kind. Composition code still chooses the role's subscriptions, and Mosquitto ACLs still authorize them.
```go
package contracts
import (
"fmt"
"strings"
)
type TopicName string
type TopicFilter string
type Kind string
const (
KindTelemetry Kind = "telemetry"
KindCommands Kind = "commands"
KindResponses Kind = "responses"
KindSales Kind = "sales"
)
const topicPrefix = "lab/v1/devices/"
type Topic struct {
DeviceID string
Kind Kind
}
func (t TopicName) String() string { return string(t) }
func (f TopicFilter) String() string { return string(f) }
func DeviceTopic(deviceID string, kind Kind) (TopicName, error) {
if !validID(deviceID) {
return "", fmt.Errorf("invalid device ID %q", deviceID)
}
if !supportedKind(kind) {
return "", fmt.Errorf("unsupported topic kind %q", kind)
}
return TopicName(topicPrefix + deviceID + "/" + string(kind)), nil
}
func DeviceFilter(kind Kind) (TopicFilter, error) {
if !supportedKind(kind) {
return "", fmt.Errorf("unsupported topic kind %q", kind)
}
return TopicFilter(topicPrefix + "+/" + string(kind)), nil
}
func DeviceSubscription(deviceID string, kind Kind) (TopicFilter, error) {
topic, err := DeviceTopic(deviceID, kind)
if err != nil {
return "", err
}
return TopicFilter(topic.String()), nil
}
func ParseTopic(value string) (Topic, error) {
if !strings.HasPrefix(value, topicPrefix) {
return Topic{}, fmt.Errorf("topic must be %s{deviceID}/{kind}", topicPrefix)
}
parts := strings.Split(strings.TrimPrefix(value, topicPrefix), "/")
if len(parts) != 2 || !validID(parts[0]) {
return Topic{}, fmt.Errorf("topic must be %s{deviceID}/{kind}", topicPrefix)
}
kind := Kind(parts[1])
if !supportedKind(kind) {
return Topic{}, fmt.Errorf("unsupported topic kind %q", kind)
}
return Topic{DeviceID: parts[0], Kind: kind}, nil
}
func supportedKind(kind Kind) bool {
switch kind {
case KindTelemetry, KindCommands, KindResponses, KindSales:
return true
default:
return false
}
}
func validID(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, r := range value {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) {
return false
}
}
return true
}
```
#### 🧱 `DeviceTopic`, `DeviceFilter`, and `DeviceSubscription` — build the right kind of path
**Summary:** Build a concrete publish name, a fleet-wide flow filter, or an exact per-device subscription filter.
**Details:** `DeviceTopic` never inserts wildcard characters. `DeviceFilter` places one `+` wildcard at the device level for a known flow; `DeviceSubscription` reuses the validated name to produce an exact filter. The backend uses fleet-wide filters, while the simulator subscribes only to its configured device. These Go types reduce accidental mixing; broker ACLs remain the authorization boundary.
#### 🧭 `ParseTopic` — recover routing data
**Summary:** Check the shared `topicPrefix`, then validate the remaining device ID and flow kind.
**Details:** `ParseTopic` first requires the incoming name to start with `topicPrefix`, then splits only the suffix into exactly two levels. The parser does not repeat the prefix's individual segments, so changing the constant updates builders and parsing together. Parsing establishes which contract to decode and lets the application compare topic identity with payload identity. It does not prove which client published the message.
Save the next block as `internal/contracts/message.go`. This file defines the versioned payloads and value checks. Topic hierarchy and parsing live in `topic.go`. A command ID remains stable across retries; an MQTT packet identifier identifies only one protocol exchange.
### 🔍 `message.go` — file overview
This file defines JSON payload types and their validation rules. Its validation functions check the relationship between parsed topic data and the body before a decoded value can reach a handler.
```go
package contracts
import (
"fmt"
"time"
)
const SchemaVersion = 1
// 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 != SchemaVersion {
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
}
```
#### 🪪 `validateEnvelope` — check shared payload fields
**Summary:** Apply identifier syntax, schema-version, topic/body device identity, and timestamp checks used by several message types.
**Details:** `validateEnvelope` checks fields shared by all payload contracts and takes `now` as an argument, allowing deterministic time validation. Topic-segment ID syntax is owned by `topic.go`. Neither helper authenticates the MQTT publisher; broker credentials and ACLs perform a separate job.
#### ✅ Each `Validate` method — check one contract's rules
**Summary:** Enforce that the JSON type matches the topic kind and that message-specific values are supported.
**Details:** `Heartbeat.Validate` checks optional temperature bounds; `Command.Validate` accepts only the current harmless `ping`; `CommandResult.Validate` permits known result states; `SalesReport.Validate` checks item count and each SKU, quantity, and amount. The methods are called by `Decode`; handlers should not bypass them.
`internal/contracts/decode.go` bounds the payload before decoding, rejects unknown fields, requires the JSON value to consume the entire input, and calls the decoded contract's `Validate` method before returning it. `MQTTMessage` restricts the generic decoder to types with that method. The parsed topic and clock are explicit inputs so validation can check route/body identity and remain deterministic in tests.
```go
package contracts
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
)
const MaxPayloadBytes = 16 * 1024
// Decode bounds input, rejects unknown fields, requires one JSON value,
// and validates it against the topic before returning it.
func Decode[T MQTTMessage](payload []byte, topic Topic, now time.Time) (T, error) {
var value T
if len(payload) > MaxPayloadBytes {
return value, fmt.Errorf("payload exceeds %d bytes", MaxPayloadBytes)
}
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, fmt.Errorf("decode JSON: %w", err)
}
var trailing any
// Decode reads one JSON value at a time. A second call distinguishes
// whitespace (EOF) from a second value or malformed trailing bytes.
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return value, fmt.Errorf("payload contains more than one JSON value")
}
return value, fmt.Errorf("trailing data after JSON value: %w", err)
}
if err := value.Validate(topic, now); err != nil {
return value, fmt.Errorf("validate message: %w", err)
}
return value, nil
}
```
### 🔍 `decode.go` — file overview and function guide
This file performs bounded JSON decoding and contract validation. `MQTTMessage` is the type constraint: only message types with a `Validate` method can be passed to `Decode`.
#### 📥 `Decode` — accept one valid contract value
**Summary:** Enforces the byte limit, decodes exactly one JSON object, rejects unknown fields, then calls the selected type's validator.
**Details:** The second decoder call distinguishes trailing whitespace from a second JSON value or malformed trailing bytes. The function returns no validated value until every structural and business check succeeds. This protects application state from malformed data, but a valid value still needs an application service to decide what to do with it; Section 5A adds that missing step.
`internal/contracts/decode_test.go` checks malformed boundaries without a broker:
```go
package contracts
import (
"strings"
"testing"
"time"
)
// Topic tests reject malformed routes and unsupported message kinds.
func TestParseTopic(t *testing.T) {
topic, err := ParseTopic("lab/v1/devices/device-001/sales")
if err != nil { t.Fatal(err) }
if topic.DeviceID != "device-001" || topic.Kind != KindSales {
t.Fatalf("unexpected topic: %#v", topic)
}
for _, invalid := range []string{"lab/v1/devices/+/sales", "lab/v1/devices/device-001/unknown", "lab/v1/devices/device-001/sales/extra"} {
if _, err := ParseTopic(invalid); err == nil { t.Errorf("ParseTopic(%q) succeeded", invalid) }
}
}
// These cases cover strict JSON shape and single-value decoding.
func TestDecodeRejectsUnknownAndTrailingValues(t *testing.T) {
topic := Topic{DeviceID: "device-001", Kind: KindTelemetry}
now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"unknown":true}`), topic, now); err == nil {
t.Fatal("unknown field was accepted")
}
if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"message_id":"m-1","device_id":"device-001","occurred_at":"2026-10-13T12:00:00Z"} {"message_id":"m-2"}`), topic, now); err == nil {
t.Fatal("trailing JSON value was accepted")
}
}
// This case ensures the application limit is applied before decoding.
func TestDecodeRejectsOversizedPayload(t *testing.T) {
payload := []byte(`"` + strings.Repeat("x", MaxPayloadBytes) + `"`)
if _, err := Decode[Heartbeat](payload, topic, now); err == nil {
t.Fatal("oversized payload was accepted")
}
}
// A body cannot override the device selected by the topic.
func TestCommandRejectsTopicIdentityMismatch(t *testing.T) {
topic, err := ParseTopic("lab/v1/devices/device-001/commands")
if err != nil { t.Fatal(err) }
command := Command{SchemaVersion: SchemaVersion, MessageID: "m-1", DeviceID: "device-002", OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: "c-1", Command: "ping"}
if err := command.Validate(topic, time.Now().UTC()); err == nil {
t.Fatal("topic/body identity mismatch was accepted")
}
}
```
## 🏷️ 2. Parse topics before routing
> **❓ Question:** **A message arrives with both a topic and a payload. Why decide which device and message kind the topic represents before running a handler?**
`contracts.ParseTopic` in `topic.go` accepts exactly `lab/v1/devices/{deviceID}/{kind}`. It rejects unsafe IDs, extra segments, wildcard characters, and unknown message kinds. Use it before decoding so the validator can compare the route's device ID with the payload claim. Add the sales ACL rules only when you publish sales reports.
## ✅ 3. Decode and validate
> **❓ Question:** **JSON decoding succeeded. What else must be true before this message is safe for application state?**
**💬 Answer:** Decoding checks syntax, not whether the message is safe to use. The backend also checks size, schema, required IDs, timestamps, values, and agreement between the topic and claimed device before business handling.
`contracts.Decode[T]` in `decode.go` applies the 16 KiB payload limit, rejects unknown JSON fields, requires EOF after one value, and calls `Validate(topic, now)` before returning. `MQTTMessage` restricts `T` to contract types that implement validation. Validation checks schema version 1, required IDs, supported commands, topic/body device agreement, RFC3339 timestamps, and message-specific values. Passing `now` explicitly lets callers use a fixed clock in tests. Historical sales are allowed; the validator does not apply heartbeat freshness rules to reports.
Metadata describes the payload, and the ID in it is only a claim. The backend validates the bytes and compares that ID with the topic. Broker credentials and ACLs separately authorize which sender may publish to the topic.
## 🧪 4. Integrate and verify
> **❓ Question:** **Where should validation sit so malformed messages cannot trigger a response or change state?**
Run validation after receiving the message but before business handling. Otherwise, a malformed command could already change state or trigger a reply. Test accepted and rejected messages, then test broker ACL permissions separately; application validation does not decide who may publish to a topic.
For one sales message, the processing order is:
```go
topic, err := contracts.ParseTopic(message.Topic)
if err != nil {
return err
}
report, err := contracts.Decode[contracts.SalesReport](message.Payload, topic, time.Now().UTC())
if err != nil {
return err
}
// Only a validated report reaches the business handler.
```
`Decode` reads one JSON value per call. After decoding the expected report, it calls `Decode` again: `io.EOF` means only whitespace remained, another successful decode means there was a second JSON value, and a different error means malformed trailing bytes. Only after that structural check does it call the report's `Validate` method.
Replace Section 4's anonymous JSON structs in both programs with these contracts. The complete executable files are below; each validates inbound data before logging, changing state, or sending a result. Rejections log a bounded error, not the full payload.
`cmd/backend/main.go`:
```go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"example.com/mqtt5-learning/internal/config"
"example.com/mqtt5-learning/internal/contracts"
"example.com/mqtt5-learning/internal/mqtt"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
cfg, err := config.Load(os.Args[1:])
if err != nil {
return fmt.Errorf("load config: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
if err != nil { return fmt.Errorf("build telemetry filter: %w", err) }
responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
if err != nil { return fmt.Errorf("build responses filter: %w", err) }
salesFilter, err := contracts.DeviceFilter(contracts.KindSales)
if err != nil { return fmt.Errorf("build sales filter: %w", err) }
commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands)
if err != nil { return fmt.Errorf("build command topic: %w", err) }
// Start subscriptions before handling any delivered message.
inbox := make(chan mqtt.Message, 16)
manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter, salesFilter}, inbox)
if err != nil {
return fmt.Errorf("start MQTT: %w", err)
}
defer mqtt.Stop(manager)
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
sequence := 0
for {
select {
case <-ctx.Done():
return nil
case message := <-inbox:
if err := handleMessage(message); err != nil {
log.Printf("rejected topic=%q reason=%v", message.Topic, err)
}
case <-ticker.C:
sequence++
id := fmt.Sprintf("ping-%d-%d", time.Now().UTC().Unix(), sequence)
command := contracts.Command{SchemaVersion: contracts.SchemaVersion, MessageID: id, DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: id, Command: "ping"}
payload, err := json.Marshal(command)
if err != nil {
return fmt.Errorf("encode command: %w", err)
}
if err := mqtt.Publish(ctx, manager, commandTopic, payload); err != nil {
log.Printf("command publish failed or uncertain id=%s: %v", id, err)
}
}
}
}
func handleMessage(message mqtt.Message) error {
// Parse and validate before a message can reach application handling.
topic, err := contracts.ParseTopic(message.Topic)
if err != nil {
return err
}
now := time.Now().UTC()
switch topic.Kind {
case contracts.KindTelemetry:
value, err := contracts.Decode[contracts.Heartbeat](message.Payload, topic, now)
if err != nil { return err }
log.Printf("valid heartbeat device=%s message=%s", value.DeviceID, value.MessageID)
case contracts.KindResponses:
value, err := contracts.Decode[contracts.CommandResult](message.Payload, topic, now)
if err != nil { return err }
log.Printf("command result device=%s command=%s status=%s", value.DeviceID, value.CommandID, value.Status)
case contracts.KindSales:
value, err := contracts.Decode[contracts.SalesReport](message.Payload, topic, now)
if err != nil { return err }
log.Printf("valid sales report device=%s report=%s items=%d", value.DeviceID, value.ReportID, len(value.Items))
default:
return fmt.Errorf("backend does not consume topic kind %q", topic.Kind)
}
return nil
}
```
#### 🧭 `main`, `run`, and `handleMessage` — validate backend traffic
**Summary:** `main` reports startup failure, `run` owns the connection and receive loop, and `handleMessage` routes each topic kind to its typed decoder.
**Details:** The receive loop logs rejected messages without crashing the process. `handleMessage` parses the topic before decoding; that parsed kind chooses the expected payload type, and `Decode` checks the body against the route. The default branch refuses kinds the backend does not consume. At this stage successful sales and results are only logged; Section 5A creates application state, while Sections 7 and 13 add matching and durable sales storage.
`cmd/simulator/main.go`:
```go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"example.com/mqtt5-learning/internal/config"
"example.com/mqtt5-learning/internal/contracts"
"example.com/mqtt5-learning/internal/mqtt"
"github.com/eclipse/paho.golang/autopaho"
)
func main() {
if err := run(); err != nil { log.Fatal(err) }
}
func run() error {
cfg, err := config.Load(os.Args[1:])
if err != nil { return fmt.Errorf("load config: %w", err) }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Construct routes before connecting so invalid identity fails at startup.
commandFilter, err := contracts.DeviceSubscription(cfg.MQTT.DeviceID, contracts.KindCommands)
if err != nil { return err }
telemetryTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindTelemetry)
if err != nil { return err }
responseTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindResponses)
if err != nil { return err }
inbox := make(chan mqtt.Message, 16)
manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{commandFilter}, inbox)
if err != nil { return fmt.Errorf("start MQTT: %w", err) }
defer mqtt.Stop(manager)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
sequence := 0
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
sequence++
heartbeat := contracts.Heartbeat{SchemaVersion: contracts.SchemaVersion, MessageID: fmt.Sprintf("heartbeat-%d-%d", time.Now().Unix(), sequence), DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339)}
payload, err := json.Marshal(heartbeat)
if err != nil { return fmt.Errorf("encode heartbeat: %w", err) }
if err := mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil { log.Printf("heartbeat publish: %v", err) }
case message := <-inbox:
if message.Retain { log.Printf("reject retained command topic=%q", message.Topic); continue }
if err := respond(ctx, manager, cfg.MQTT.DeviceID, message); err != nil { log.Printf("reject command topic=%q reason=%v", message.Topic, err) }
}
}
}
func respond(ctx context.Context, manager *autopaho.ConnectionManager, deviceID string, message mqtt.Message) error {
// Check route and body identity before constructing a command result.
topic, err := contracts.ParseTopic(message.Topic)
if err != nil { return err }
command, err := contracts.Decode[contracts.Command](message.Payload, topic, time.Now().UTC())
if err != nil { return err }
if topic.DeviceID != deviceID { return fmt.Errorf("command is for another device") }
result := contracts.CommandResult{SchemaVersion: contracts.SchemaVersion, MessageID: "result-"+command.CommandID, DeviceID: deviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: command.CommandID, Status: "completed"}
payload, err := json.Marshal(result)
if err != nil { return fmt.Errorf("encode result: %w", err) }
responseTopic, err := contracts.DeviceTopic(deviceID, contracts.KindResponses)
if err != nil { return fmt.Errorf("build response topic: %w", err) }
return mqtt.Publish(ctx, manager, responseTopic, payload)
}
```
#### 📟 `respond` — validate a command and publish its result
**Summary:** Parses the command topic, validates its payload, confirms the target device, then publishes a completed result.
**Details:** Each check returns before result construction if the topic or body is invalid. The simulator only demonstrates a harmless ping here; it does not execute a real vending-machine operation. Section 7 adds retained-message rejection, response-topic authorization, Correlation Data, and duplicate suppression.
#### 🧭 `main` and `run` — operate the simulator
**Summary:** `main` turns failures into a nonzero exit; `run` loads the simulator identity, subscribes to commands, publishes heartbeats, and calls `respond` for each inbound packet.
**Details:** `run` owns the signal context, manager, ticker, and receive loop. It keeps network callback work separate from contract validation and result publication. The heartbeat ticker is independent of command reception, so an idle device still reports recent contact. Section 6 moves potentially slow message work into bounded workers.
```sh
gofmt -w internal/contracts cmd
go test ./internal/contracts
go test ./...
```
`mkdir -p` creates the package directory. `gofmt -w` formats the complete files above. `go test ./internal/contracts` runs the supplied pure parser/validator tests without a broker; `go test ./...` checks the rest of the application too.
When adding sales, edit `lab/mosquitto/acl`: add `topic read lab/v1/devices/+/sales` under backend and `topic write lab/v1/devices/device-001/sales` under simulator. Restart the lab broker to read the changed ACL, check logs, and test allowed and cross-device-denied publications. Permissions enable a topic direction; they do not replace sales validation.
The supplied tests cover topic parsing, malformed JSON boundaries, payload size, and topic/body identity. Add a case when you change a contract so the test captures the new rule. Keep the simulator and backend contracts in sync when changing the wire format.
**Done when:** Both programs still exchange ping/pong, every rejection has a predictable reason, and invalid sales or commands leave state unchanged. Explain why schema validation does not authenticate a device.
## ✅ Result — the story so far
The backend and simulator now have a shared vocabulary for heartbeats, commands, results, and sales reports. When a message arrives, the receiver checks its topic, size, JSON shape, schema version, identifiers, timestamp, and topic/body device match before changing application state. A broker-routed message can still be rejected by the application; successful MQTT delivery alone does not make its contents valid or trusted.
Previous: [04-publish-subscribe-simulator.md](04-publish-subscribe-simulator.md). Next: [05A — Backend message processing](05a-backend-message-processing.md).