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.
104 lines
3.0 KiB
Go
104 lines
3.0 KiB
Go
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
|
|
}
|