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 }