feat: expand MQTT contract and reliability lessons
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxPayloadBytes = 16 * 1024
|
||||
|
||||
// Decode bounds input, rejects unknown fields, requires exactly one JSON value,
|
||||
// and validates the decoded MQTT contract against its parsed topic.
|
||||
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
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var testTopic = Topic{DeviceID: "device-001", Kind: KindTelemetry}
|
||||
var testNow = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// 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) {
|
||||
if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"unknown":true}`), testTopic, testNow); 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"}`), testTopic, testNow); 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, testTopic, testNow); err == nil {
|
||||
t.Fatal("oversized payload was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Decode validates the message against both its parsed route and supplied clock.
|
||||
func TestDecodeValidatesContract(t *testing.T) {
|
||||
payload := []byte(`{"schema_version":1,"message_id":"m-1","device_id":"device-002","occurred_at":"2026-10-13T12:00:00Z"}`)
|
||||
if _, err := Decode[Heartbeat](payload, testTopic, testNow); err == nil {
|
||||
t.Fatal("topic/body identity mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The type parameter must implement MQTTMessage; successful decoding also validates it.
|
||||
func TestDecodeAcceptsOnlyValidatedMQTTMessages(t *testing.T) {
|
||||
payload := []byte(`{"schema_version":1,"message_id":"m-1","device_id":"device-001","occurred_at":"2026-10-13T12:00:00Z"}`)
|
||||
message, err := Decode[Heartbeat](payload, testTopic, testNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.DeviceID != testTopic.DeviceID {
|
||||
t.Fatalf("device_id = %q, want %q", message.DeviceID, testTopic.DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SchemaVersion = 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
|
||||
}
|
||||
|
||||
// MQTTMessage is implemented by each versioned message contract.
|
||||
// Validation uses the parsed route and caller-supplied time for deterministic checks.
|
||||
type MQTTMessage interface {
|
||||
Validate(topic Topic, now time.Time) error
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 @@
|
||||
package contracts
|
||||
Reference in New Issue
Block a user