require.Error(t, parse(Root+"/devices/"+deviceID+"/unknown", []byte(`{}`), false))
}
}
+
+func TestTypedParsersKeepValidationAfterTopicDispatch(t *testing.T) {
+ deadline := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ metadata := Metadata{
+ MessageID: messageID,
+ ExpiresAt: &deadline,
+ CorrelationID: commandID,
+ }
+ testCases := []struct {
+ name string
+ kind Kind
+ target string
+ payload any
+ parse func(string, []byte, bool) (any, error)
+ }{
+ {
+ name: "descriptor keeps its machine metadata",
+ kind: Descriptor,
+ payload: testDescriptor(),
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseDescriptor(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ {
+ name: "availability keeps its reachability",
+ kind: Availability,
+ payload: AvailabilityPayload{Status: "online"},
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseAvailability(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ {
+ name: "reported keeps its complete value",
+ kind: Reported,
+ target: "relay",
+ payload: ValuePayload{Value: json.RawMessage(`true`)},
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseReported(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ {
+ name: "telemetry keeps its sample",
+ kind: Telemetry,
+ target: "power",
+ payload: ValuePayload{Value: json.RawMessage(`12`)},
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseTelemetry(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ {
+ name: "event keeps its open fields",
+ kind: Event,
+ target: "press",
+ payload: EventPayload{"action": json.RawMessage(`"press"`)},
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseEvent(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ {
+ name: "command keeps its requested value",
+ kind: Command,
+ target: "relay",
+ payload: ValuePayload{Value: json.RawMessage(`true`)},
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseCommand(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ {
+ name: "command batch keeps its member order",
+ kind: CommandBatch,
+ payload: testBatch(deadline),
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseCommandBatch(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ {
+ name: "result keeps its correlated outcome",
+ kind: Result,
+ target: commandID,
+ payload: ResultPayload{Status: "completed"},
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseResult(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ {
+ name: "hub status keeps its snapshot",
+ kind: HubStatus,
+ payload: HubStatusPayload{
+ ID: deviceID,
+ Address: "example.com",
+ Status: "ready",
+ },
+ parse: func(topic string, data []byte, retained bool) (any, error) {
+ parsed, err := ParseHubStatus(topic, data, retained)
+ return parsed.Payload, err
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ topic, err := (Address{
+ Kind: tc.kind,
+ ID: deviceID,
+ Target: tc.target,
+ }).topic()
+ require.NoError(t, err)
+
+ data, err := encodeEnvelope(metadata, tc.payload)
+ require.NoError(t, err)
+
+ parsed, err := tc.parse(topic, data, false)
+ require.NoError(t, err)
+ require.Equal(t, tc.payload, parsed)
+
+ _, err = tc.parse(topic, []byte(`{}`), false)
+ require.Error(t, err)
+
+ wrongTopic := Root + "/devices/" + deviceID + "/availability"
+ if tc.kind == Availability {
+ wrongTopic = Root + "/devices/" + deviceID + "/descriptor"
+ }
+
+ _, err = tc.parse(wrongTopic, data, false)
+ require.Error(t, err)
+ })
+ }
+}
switch address.Kind {
case Descriptor:
- return eraseMessage(ParseDescriptor(topic, data, retained))
+ return eraseMessage(parsePayload[DescriptorPayload](address, data, retained))
case Availability:
- return eraseMessage(ParseAvailability(topic, data, retained))
+ return eraseMessage(parsePayload[AvailabilityPayload](address, data, retained))
case Reported:
- return eraseMessage(ParseReported(topic, data, retained))
+ return eraseMessage(parsePayload[ValuePayload](address, data, retained))
case Telemetry:
- return eraseMessage(ParseTelemetry(topic, data, retained))
+ return eraseMessage(parsePayload[ValuePayload](address, data, retained))
case Event:
- return eraseMessage(ParseEvent(topic, data, retained))
+ return eraseMessage(parsePayload[EventPayload](address, data, retained))
case Command:
- return eraseMessage(ParseCommand(topic, data, retained))
+ return eraseMessage(parsePayload[ValuePayload](address, data, retained))
case CommandBatch:
- return eraseMessage(ParseCommandBatch(topic, data, retained))
+ return eraseMessage(parsePayload[BatchPayload](address, data, retained))
case Result:
- return eraseMessage(ParseResult(topic, data, retained))
+ return eraseMessage(parsePayload[ResultPayload](address, data, retained))
case HubStatus:
- return eraseMessage(ParseHubStatus(topic, data, retained))
+ return eraseMessage(parsePayload[HubStatusPayload](address, data, retained))
default:
return Message[any]{}, fmt.Errorf("unsupported message kind")
}
}
-func eraseMessage[T any](message Message[T], err error) (Message[any], error) {
- if err != nil {
- return Message[any]{}, fmt.Errorf("parse message: %w", err)
- }
-
- return Message[any]{
- Address: message.Address,
- Metadata: message.Metadata,
- Payload: message.Payload,
- Retained: message.Retained,
- }, nil
-}
-
func buildMessage[T any](address Address, metadata Metadata, payload T) (Publication, error) {
topic, err := address.topic()
if err != nil {
return message, nil
}
+func eraseMessage[T any](message Message[T], err error) (Message[any], error) {
+ if err != nil {
+ return Message[any]{}, fmt.Errorf("parse message: %w", err)
+ }
+
+ return Message[any]{
+ Address: message.Address,
+ Metadata: message.Metadata,
+ Payload: message.Payload,
+ Retained: message.Retained,
+ }, nil
+}
+
func parsePayload[T any](address Address, data []byte, retained bool) (Message[T], error) {
kind := address.Kind
return nil
}
-func validateDescriptor(payload DescriptorPayload) error {
- if payload.Manufacturer == "" || payload.Model == "" || payload.Firmware == "" || len(string([]rune(payload.Attribution))) > 2000 || len(payload.Capabilities) == 0 {
- return fmt.Errorf("descriptor metadata or capabilities are invalid")
- }
-
- seen := make(map[string]bool, len(payload.Capabilities))
-
- for _, capability := range payload.Capabilities {
- if !ValidIdentifier(capability.ID) || !ValidIdentifier(capability.Type) || seen[capability.ID] {
- return fmt.Errorf("capability IDs and types must be valid and unique")
- }
-
- if !capability.ReportsState && !capability.AcceptsCommands {
- return fmt.Errorf("capability must have at least one access mode")
- }
-
- seen[capability.ID] = true
- }
-
- return nil
-}
-
func validateDescriptorFlags(raw json.RawMessage) error {
var fields struct {
Capabilities []map[string]json.RawMessage `json:"capabilities"`
return nil
}
+func validateDescriptor(payload DescriptorPayload) error {
+ if payload.Manufacturer == "" || payload.Model == "" || payload.Firmware == "" || len(string([]rune(payload.Attribution))) > 2000 || len(payload.Capabilities) == 0 {
+ return fmt.Errorf("descriptor metadata or capabilities are invalid")
+ }
+
+ seen := make(map[string]bool, len(payload.Capabilities))
+
+ for _, capability := range payload.Capabilities {
+ if !ValidIdentifier(capability.ID) || !ValidIdentifier(capability.Type) || seen[capability.ID] {
+ return fmt.Errorf("capability IDs and types must be valid and unique")
+ }
+
+ if !capability.ReportsState && !capability.AcceptsCommands {
+ return fmt.Errorf("capability must have at least one access mode")
+ }
+
+ seen[capability.ID] = true
+ }
+
+ return nil
+}
+
func validateAvailability(payload AvailabilityPayload) error {
if payload.Status != "online" && payload.Status != "offline" {
return fmt.Errorf("availability must be online or offline")