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:
@@ -69,7 +69,7 @@ func TestCommandRejectsTopicIdentityMismatch(t *testing.T) {
|
||||
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"}
|
||||
command := Command{SchemaVersion: SchemaVersionOne, 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,27 +2,13 @@ package contracts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SchemaVersion = 1
|
||||
const SchemaVersionOne = 1
|
||||
|
||||
// Kind values match the final topic segment used to route each message.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindTelemetry Kind = "telemetry"
|
||||
KindCommands Kind = "commands"
|
||||
KindResponses Kind = "responses"
|
||||
KindSales Kind = "sales"
|
||||
)
|
||||
|
||||
// Topic is the parsed device scope and message kind from an MQTT topic.
|
||||
type Topic struct {
|
||||
DeviceID string
|
||||
Kind Kind
|
||||
}
|
||||
// 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.
|
||||
@@ -72,22 +58,9 @@ type SalesReport struct {
|
||||
Items []SalesLine `json:"items"`
|
||||
}
|
||||
|
||||
// validID limits identifiers to safe, single-segment topic values.
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if version != SchemaVersionOne {
|
||||
return fmt.Errorf("unsupported schema_version %d", version)
|
||||
}
|
||||
if !validID(messageID) || !validID(deviceID) {
|
||||
@@ -163,18 +136,3 @@ func (m SalesReport) Validate(topic Topic, now time.Time) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseTopic turns broker routing metadata into values handlers can compare.
|
||||
func ParseTopic(value string) (Topic, error) {
|
||||
parts := strings.Split(value, "/")
|
||||
if len(parts) != 5 || parts[0] != "lab" || parts[1] != "v1" || parts[2] != "devices" || !validID(parts[3]) {
|
||||
return Topic{}, fmt.Errorf("topic must be lab/v1/devices/{deviceID}/{kind}")
|
||||
}
|
||||
kind := Kind(parts[4])
|
||||
switch kind {
|
||||
case KindTelemetry, KindCommands, KindResponses, KindSales:
|
||||
return Topic{DeviceID: parts[3], Kind: kind}, nil
|
||||
default:
|
||||
return Topic{}, fmt.Errorf("unsupported topic kind %q", kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TopicName is a concrete MQTT topic used by PUBLISH. It never contains MQTT
|
||||
// subscription wildcards.
|
||||
type TopicName string
|
||||
|
||||
// TopicFilter is an MQTT subscription pattern. This lab deliberately supports
|
||||
// one device-level wildcard rather than accepting arbitrary filters.
|
||||
type TopicFilter string
|
||||
|
||||
// Kind identifies the message flow in the final topic level.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindTelemetry Kind = "telemetry"
|
||||
KindCommands Kind = "commands"
|
||||
KindResponses Kind = "responses"
|
||||
KindSales Kind = "sales"
|
||||
)
|
||||
|
||||
const topicPrefix = "lab/v1/devices/"
|
||||
|
||||
// Topic is the parsed device identity and flow from a concrete topic name.
|
||||
type Topic struct {
|
||||
DeviceID string
|
||||
Kind Kind
|
||||
}
|
||||
|
||||
func (t TopicName) String() string { return string(t) }
|
||||
func (f TopicFilter) String() string { return string(f) }
|
||||
|
||||
// DeviceTopic builds the concrete topic name for one device and supported flow.
|
||||
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
|
||||
}
|
||||
|
||||
// DeviceFilter builds a subscription filter for one flow across lab devices.
|
||||
func DeviceFilter(kind Kind) (TopicFilter, error) {
|
||||
if !supportedKind(kind) {
|
||||
return "", fmt.Errorf("unsupported topic kind %q", kind)
|
||||
}
|
||||
return TopicFilter(topicPrefix + "+/" + string(kind)), nil
|
||||
}
|
||||
|
||||
// DeviceSubscription builds an exact-topic filter for one device and flow.
|
||||
func DeviceSubscription(deviceID string, kind Kind) (TopicFilter, error) {
|
||||
topic, err := DeviceTopic(deviceID, kind)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return TopicFilter(topic.String()), nil
|
||||
}
|
||||
|
||||
// ParseTopic turns broker routing metadata into values handlers can compare.
|
||||
func ParseTopic(value string) (Topic, error) {
|
||||
// Builders and parser share topicPrefix, so changing the route prefix does
|
||||
// not require changing the levels that identify a device and message flow.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// validID limits identifiers to safe, single-segment topic values.
|
||||
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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package contracts
|
||||
Reference in New Issue
Block a user