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.
511 lines
27 KiB
HTML
511 lines
27 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="theme-color" content="#fbfaf6">
|
||
<title>Lesson 8 — HTTP Acceptance Is Not Device Completion</title>
|
||
<link rel="stylesheet" href="../assets/lesson.css">
|
||
<script src="../assets/quiz.js" defer></script>
|
||
</head>
|
||
<body>
|
||
<main class="page">
|
||
<p class="eyebrow">Go MQTT Vending Backend · Lesson 8 · 6 minutes</p>
|
||
<h1>HTTP acceptance is not device completion</h1>
|
||
<p class="dek">Build an honest response for a vending command that travels from an HTTP caller through your Go service and Mosquitto to a device.</p>
|
||
<div class="meta"><span class="pill">🎯 One skill: name each acknowledgement</span><span class="pill">🧠 Recall before reveal</span><span class="pill">🧪 Disposable broker lab</span></div>
|
||
|
||
<section class="card callout">
|
||
<p class="question">❓ Start with the system story</p>
|
||
<p>A dashboard user asks the backend to ping a vending machine. Your API admitted a command record to process-local memory and published the MQTT request. The broker returned PUBACK, but the machine has not replied yet. What can you tell the caller right now?</p>
|
||
<p><strong>Pause and answer aloud before reading on.</strong></p>
|
||
</section>
|
||
|
||
<section class="card"><h2>📍 Checkout status</h2><p><code>internal/httpapi/handlers.go</code> and <code>server.go</code> are Section 8 files to create; they do not yet exist in the current codebase. The complete target files and edited backend entrypoint are included below. They depend on the command service introduced in Section 7.</p><p><strong>Lab boundary:</strong> the API binds to loopback and has no authentication. Do not expose it to another network; Section 11 adds API authentication and per-device authorization. HTTP 202 admits work into RAM only. A process restart loses command and retry-key state until Sections 13–14 add durable storage and a recoverable outbox.</p></section>
|
||
|
||
<h2>🗺️ First, see the whole path</h2>
|
||
<p>The API request, MQTT exchange, and device operation are separate stages. Each can succeed or fail independently.</p>
|
||
<div class="flow" aria-label="HTTP-to-device command flow">
|
||
<div class="step">Caller sends<br><code>POST</code></div><span class="arrow">→</span>
|
||
<div class="step">Backend records command<br><strong>API admission</strong></div><span class="arrow">→</span>
|
||
<div class="step">Broker replies<br><strong>PUBACK</strong></div><span class="arrow">→</span>
|
||
<div class="step">Device reports later<br><strong>application result</strong></div>
|
||
</div>
|
||
<p>HTTP 202 means the server accepted the request for processing; the processing is not complete. A QoS 1 PUBACK is evidence for its MQTT publication exchange. The device's later result is application evidence. The API should return a command ID now and let the caller query status later. <a href="../reference/http-mqtt-evidence.html">Keep the evidence map nearby ↗</a></p>
|
||
|
||
<h2>🔎 Then, read the function through its purpose</h2>
|
||
<div class="two-col">
|
||
<article class="card">
|
||
<h3>📄 What the file does</h3>
|
||
<p>The new <code>internal/httpapi/handlers.go</code> will translate HTTP input into command-service calls and command records into HTTP responses. It will not publish MQTT packets itself.</p>
|
||
</article>
|
||
<article class="card">
|
||
<h3>🚀 `createCommand` — short summary</h3>
|
||
<p>Validates the request and idempotency key, asks the command service to admit one command, then returns its ID and current evidence.</p>
|
||
</article>
|
||
</div>
|
||
<article class="card">
|
||
<h3>🧩 Details that affect behavior</h3>
|
||
<ul>
|
||
<li><code>POST /devices/{deviceID}/commands</code> identifies the operation and device. The handler validates the path ID and command before creating work.</li>
|
||
<li><code>Idempotency-Key</code> lets a caller retry after a lost HTTP response without intentionally creating a second command. The key is not an MQTT packet ID or the command ID.</li>
|
||
<li>The command service owns the command record and MQTT publish. Keeping MQTT out of the handler makes the HTTP boundary easier to inspect and replace.</li>
|
||
<li>HTTP 202 can include the command ID and a <code>Location</code> pointing to <code>GET /commands/{id}</code>. That status resource reports the latest evidence known by the backend.</li>
|
||
<li>In this lab, retry keys and command records are in memory. A process restart loses them; later persistence lessons add a durable boundary.</li>
|
||
</ul>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<h3>📦 `writeCommand` — shape the status response</h3>
|
||
<p><strong>Summary:</strong> Sets response metadata, writes the chosen HTTP status, then encodes a command record as JSON.</p>
|
||
<pre><code>type commandResponse struct {
|
||
CommandID string `json:"command_id"`
|
||
DeviceID string `json:"device_id"`
|
||
Status string `json:"status"`
|
||
BrokerAcked bool `json:"broker_acked"`
|
||
}
|
||
|
||
func writeCommand(w http.ResponseWriter, record commands.Record, status int) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Location", "/commands/"+record.CommandID)
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(commandResponse{
|
||
CommandID: record.CommandID,
|
||
DeviceID: record.DeviceID,
|
||
Status: string(record.Status),
|
||
BrokerAcked: record.BrokerAcked,
|
||
})
|
||
}
|
||
|
||
</code></pre>
|
||
<p><strong>Details:</strong> <code>Location</code> tells the client where to read status. The HTTP status is an argument because the same mapper serves both POST admission and GET status. <code>record.Status</code> and <code>BrokerAcked</code> remain separate fields: the first is application progress, while the second records MQTT publish acknowledgement. The caller should query the returned resource for later device evidence.</p>
|
||
<p class="small">This function excerpt focuses on response construction. The full handler file, including imports, request handling, and supporting functions, is included below.</p>
|
||
</article>
|
||
|
||
<p class="small">The route patterns shown in this course use Go's method-aware <code>ServeMux</code> syntax and <code>Request.PathValue</code>, available from Go 1.22. This repository's <code>go.mod</code> targets Go 1.27.1. <a href="https://go.dev/blog/routing-enhancements">Go team: routing enhancements ↗</a></p>
|
||
|
||
<h2>🗺️ File and function map</h2>
|
||
<p><strong>File overview:</strong> <code>handlers.go</code> owns HTTP routes, request validation, retry-key handling, and JSON responses. <code>server.go</code> builds a loopback-only server with I/O deadlines. The backend <code>main.go</code> wires HTTP to the command service while continuing to receive MQTT results and expire old commands.</p>
|
||
<div class="two-col">
|
||
<article class="card">
|
||
<h3>📄 <code>internal/httpapi/handlers.go</code></h3>
|
||
<p><strong>Function summaries:</strong> <code>NewAPI</code> stores dependencies; <code>Handler</code> registers the two routes; <code>createCommand</code> validates and admits a request; <code>getCommand</code> reads current status; <code>decodeRequest</code> bounds and strictly decodes JSON; <code>writeCommand</code> shapes the JSON response; <code>validPathID</code> checks safe path IDs; <code>newCommandID</code> generates a random stable ID.</p>
|
||
<h4>🔎 Details</h4>
|
||
<ul>
|
||
<li><code>createCommand</code> validates device, idempotency key, body size/shape, and allowed command before calling the service. It hashes device plus command as a request fingerprint. Reusing the same key and same input returns the saved record; reusing it with different input returns conflict.</li>
|
||
<li>The retry map is protected by a mutex, and this simple implementation keeps the lock during service admission. That makes same-key retries atomic in one process, but serializes concurrent creates and loses entries on restart.</li>
|
||
<li>If the service returns a record plus a publish error, the handler still stores that record and returns <code>202</code>: the command exists, but its publish outcome may be uncertain. The status body keeps <code>status</code> and <code>broker_acked</code> separate.</li>
|
||
<li><code>decodeRequest</code> caps the body, rejects unknown JSON fields, and requires exactly one JSON value. <code>writeCommand</code> sets <code>Location</code> before writing the status and body; after <code>WriteHeader</code>, headers cannot be changed.</li>
|
||
</ul>
|
||
</article>
|
||
<article class="card">
|
||
<h3>🌐 <code>internal/httpapi/server.go</code></h3>
|
||
<p><strong><code>NewServer</code> summary:</strong> accepts only a loopback address with an explicit port and attaches the API handler plus finite read/write/idle timeouts.</p>
|
||
<h4>🔎 Details</h4><p>Loopback keeps this teaching endpoint local; it is not an internet-facing production server. Header, body, response, and idle timeouts limit resource use from slow clients. Production exposure would need an intentional network binding, authentication/authorization, TLS termination, and operational configuration.</p>
|
||
</article>
|
||
<article class="card">
|
||
<h3>🔌 <code>cmd/backend/main.go</code></h3>
|
||
<p><strong><code>run</code> summary:</strong> starts MQTT, wires store → command service → HTTP API, runs HTTP in a goroutine, processes MQTT results and expiry ticks, then shuts down HTTP on cancellation.</p>
|
||
<p><strong><code>main</code> summary:</strong> reports a startup/runtime error and exits nonzero.</p>
|
||
<h4>🔎 Details</h4><p>MQTT starts before the API is exposed, so a request cannot be accepted when the command publisher has no initialized connection manager. The main select loop continues processing response messages while HTTP serves requests; otherwise commands could never advance to device-reported results. The ticker changes pending/accepted commands to unknown after deadlines. On a signal, <code>Server.Shutdown</code> stops accepting new HTTP requests and waits up to five seconds for in-flight handlers. The in-memory store still loses all status on process restart.</p>
|
||
</article>
|
||
</div>
|
||
|
||
<h2>🧪 Check your model</h2>
|
||
<p>Choose the evidence each event actually provides. Options in each question have the same word count; the correct answer moves between positions.</p>
|
||
|
||
<form class="card quiz" data-quiz data-correct="a" data-explanation="Correct. HTTP 202 says this API accepted the request for processing; it does not say MQTT or device work completed." data-retry="Think about which component sends the HTTP response and which component has not replied yet.">
|
||
<fieldset class="question-block">
|
||
<legend><strong>1. What does HTTP 202 support?</strong></legend>
|
||
<label class="option"><input type="radio" name="q1" value="a"> The API accepted work for processing</label>
|
||
<label class="option"><input type="radio" name="q1" value="b"> The broker acknowledged this MQTT publication</label>
|
||
<label class="option"><input type="radio" name="q1" value="c"> Device completed its requested vending operation</label>
|
||
</fieldset>
|
||
<button type="submit">Check answer</button>
|
||
<p class="feedback" data-feedback aria-live="polite"></p>
|
||
</form>
|
||
|
||
<form class="card quiz" data-quiz data-correct="b" data-explanation="Correct. PUBACK is the QoS 1 acknowledgement for this MQTT hop. It does not prove device delivery or business completion." data-retry="Trace the MQTT sender and receiver for this publication. Which component sends PUBACK?">
|
||
<fieldset class="question-block">
|
||
<legend><strong>2. What does a successful QoS 1 PUBACK establish?</strong></legend>
|
||
<label class="option"><input type="radio" name="q2" value="a"> Device completed the vending operation after publication</label>
|
||
<label class="option"><input type="radio" name="q2" value="b"> The broker acknowledged this MQTT client publication</label>
|
||
<label class="option"><input type="radio" name="q2" value="c"> HTTP caller received a command status resource</label>
|
||
</fieldset>
|
||
<button type="submit">Check answer</button>
|
||
<p class="feedback" data-feedback aria-live="polite"></p>
|
||
</form>
|
||
|
||
<form class="card quiz" data-quiz data-correct="c" data-explanation="Correct. The GET endpoint exposes the latest application evidence the backend has recorded; it does not query or guarantee a live device connection." data-retry="A status endpoint reads backend state. Does it necessarily contact the device now?">
|
||
<fieldset class="question-block">
|
||
<legend><strong>3. What can GET /commands/{id} return later?</strong></legend>
|
||
<label class="option"><input type="radio" name="q3" value="a"> The device's current network connection status only</label>
|
||
<label class="option"><input type="radio" name="q3" value="b"> The broker's acknowledgement of the HTTP request</label>
|
||
<label class="option"><input type="radio" name="q3" value="c"> The latest application evidence backend has stored</label>
|
||
</fieldset>
|
||
<button type="submit">Check answer</button>
|
||
<p class="feedback" data-feedback aria-live="polite"></p>
|
||
</form>
|
||
|
||
<h2>🛠️ Your tiny design task</h2>
|
||
<div class="card">
|
||
<p>Open the Section 8 implementation and find <code>writeCommand</code>. Without changing code yet, write the response a caller should see immediately after command admission:</p>
|
||
<ol>
|
||
<li>Which HTTP status should it return?</li>
|
||
<li>Which stable identifier should the caller save?</li>
|
||
<li>What URL should the <code>Location</code> header name?</li>
|
||
<li>Which status field must not be described as “completed” just because PUBACK arrived?</li>
|
||
</ol>
|
||
<p>Then compare your answer to the existing implementation in <a href="../course-source/08-http-commands.md">Section 8 — Expose device commands through HTTP ↗</a>. If it matches, explain why in your own words. If it does not, identify which component owns the evidence.</p>
|
||
</div>
|
||
|
||
|
||
<details class="card" open><summary><strong>📦 Section 8 complete HTTP files</strong></summary><p>Complete source files for this lesson are included here. The code blocks earlier in the lesson focus on the behavior being taught; use these full files when implementing it.</p><article class="card"><h3>📄 internal/httpapi/handlers.go</h3><pre><code>package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
|
||
"example.com/mqtt5-learning/internal/commands"
|
||
)
|
||
|
||
const maxRequestBytes = 4 * 1024
|
||
|
||
// The API depends on command operations, not on the MQTT client itself.
|
||
type commandService interface {
|
||
CreateAndPublish(ctx context.Context, deviceID, commandID string) (commands.Record, error)
|
||
Get(commandID string) (commands.Record, bool)
|
||
}
|
||
|
||
type retryEntry struct {
|
||
fingerprint [32]byte
|
||
record commands.Record
|
||
}
|
||
|
||
type API struct {
|
||
commands commandService
|
||
serviceCtx context.Context
|
||
mu sync.Mutex
|
||
retries map[string]retryEntry
|
||
}
|
||
|
||
// NewAPI keeps retries in memory and ties accepted work to the app lifecycle.
|
||
func NewAPI(serviceCtx context.Context, service commandService) *API {
|
||
return &API{commands: service, serviceCtx: serviceCtx, retries: make(map[string]retryEntry)}
|
||
}
|
||
|
||
type createRequest struct {
|
||
Command string `json:"command"`
|
||
}
|
||
type commandResponse struct {
|
||
CommandID string `json:"command_id"`
|
||
DeviceID string `json:"device_id"`
|
||
Status string `json:"status"`
|
||
BrokerAcked bool `json:"broker_acked"`
|
||
Detail string `json:"detail,omitempty"`
|
||
}
|
||
|
||
func (a *API) Handler() http.Handler {
|
||
// Keep the public surface small: create commands and read their status.
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("POST /devices/{deviceID}/commands", a.createCommand)
|
||
mux.HandleFunc("GET /commands/{commandID}", a.getCommand)
|
||
return mux
|
||
}
|
||
|
||
func (a *API) createCommand(w http.ResponseWriter, r *http.Request) {
|
||
// Validate caller input before reserving an idempotency key or publishing.
|
||
deviceID := r.PathValue("deviceID")
|
||
if !validPathID(deviceID) {
|
||
http.Error(w, "invalid device ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||
if key == "" || len(key) > 128 {
|
||
http.Error(w, "valid Idempotency-Key required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
var input createRequest
|
||
if err := decodeRequest(w, r, &input); err != nil {
|
||
var tooLarge *http.MaxBytesError
|
||
if errors.As(err, &tooLarge) {
|
||
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
|
||
return
|
||
}
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if input.Command != "ping" {
|
||
http.Error(w, "unsupported command", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// The fingerprint detects reuse of one key for different request content.
|
||
fingerprint := sha256.Sum256([]byte(deviceID + "\x00" + input.Command))
|
||
|
||
// This in-memory lesson serializes admissions to make same-key retries atomic.
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if previous, ok := a.retries[key]; ok {
|
||
if previous.fingerprint != fingerprint {
|
||
http.Error(w, "idempotency key reused with different request", http.StatusConflict)
|
||
return
|
||
}
|
||
writeCommand(w, previous.record, http.StatusAccepted)
|
||
return
|
||
}
|
||
commandID, err := newCommandID()
|
||
if err != nil {
|
||
http.Error(w, "could not create command ID", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
record, publishErr := a.commands.CreateAndPublish(a.serviceCtx, deviceID, commandID)
|
||
if record.CommandID == "" {
|
||
if publishErr != nil {
|
||
http.Error(w, "command admission unavailable", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
http.Error(w, "command admission failed", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
a.retries[key] = retryEntry{fingerprint: fingerprint, record: record}
|
||
// The command record exists even if the publish outcome is uncertain.
|
||
writeCommand(w, record, http.StatusAccepted)
|
||
}
|
||
|
||
func (a *API) getCommand(w http.ResponseWriter, r *http.Request) {
|
||
record, ok := a.commands.Get(r.PathValue("commandID"))
|
||
if !ok {
|
||
http.Error(w, "command not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
writeCommand(w, record, http.StatusOK)
|
||
}
|
||
|
||
// decodeRequest enforces a byte limit, strict fields, and exactly one JSON value.
|
||
func decodeRequest(w http.ResponseWriter, r *http.Request, target any) error {
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
|
||
decoder := json.NewDecoder(r.Body)
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(target); err != nil {
|
||
return fmt.Errorf("invalid JSON request: %w", err)
|
||
}
|
||
var trailing any
|
||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||
if err == nil {
|
||
return errors.New("request must contain one JSON value")
|
||
}
|
||
return fmt.Errorf("trailing request data: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func writeCommand(w http.ResponseWriter, record commands.Record, status int) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Location", "/commands/"+record.CommandID)
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(commandResponse{CommandID: record.CommandID, DeviceID: record.DeviceID, Status: string(record.Status), BrokerAcked: record.BrokerAcked, Detail: record.Detail})
|
||
}
|
||
|
||
// Path IDs use the same restricted alphabet as contract and topic IDs.
|
||
func validPathID(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
|
||
}
|
||
|
||
func newCommandID() (string, error) {
|
||
var raw [16]byte
|
||
if _, err := rand.Read(raw[:]); err != nil {
|
||
return "", err
|
||
}
|
||
return "cmd-" + hex.EncodeToString(raw[:]), nil
|
||
}
|
||
</code></pre></article>
|
||
<article class="card"><h3>📄 internal/httpapi/server.go</h3><pre><code>package httpapi
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
func NewServer(address string, api *API) (*http.Server, error) {
|
||
// The teaching API listens only on loopback and uses finite I/O timeouts.
|
||
host, port, err := net.SplitHostPort(address)
|
||
if err != nil || (host != "127.0.0.1" && host != "localhost") || port == "" {
|
||
return nil, fmt.Errorf("HTTP lab address must be loopback with an explicit port")
|
||
}
|
||
return &http.Server{
|
||
Addr: address, Handler: api.Handler(),
|
||
ReadHeaderTimeout: 5 * time.Second,
|
||
ReadTimeout: 10 * time.Second,
|
||
WriteTimeout: 10 * time.Second,
|
||
IdleTimeout: 60 * time.Second,
|
||
}, nil
|
||
}
|
||
</code></pre></article>
|
||
<article class="card"><h3>📄 cmd/backend/main.go</h3><pre><code>package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"syscall"
|
||
"time"
|
||
|
||
"example.com/mqtt5-learning/internal/commands"
|
||
"example.com/mqtt5-learning/internal/config"
|
||
"example.com/mqtt5-learning/internal/contracts"
|
||
"example.com/mqtt5-learning/internal/httpapi"
|
||
"example.com/mqtt5-learning/internal/mqtt"
|
||
)
|
||
|
||
func main() {
|
||
if err := run(); err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func run() error {
|
||
cfg, err := config.Load(os.Args[1:])
|
||
if err != nil {
|
||
return fmt.Errorf("load config: %w", err)
|
||
}
|
||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
defer stop()
|
||
inbox := make(chan mqtt.Message, 16)
|
||
telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
|
||
if err != nil { return err }
|
||
responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
|
||
if err != nil { return err }
|
||
salesFilter, err := contracts.DeviceFilter(contracts.KindSales)
|
||
if err != nil { return err }
|
||
topics := []contracts.TopicFilter{telemetryFilter, responsesFilter, salesFilter}
|
||
manager, err := mqtt.Start(ctx, cfg, topics, inbox)
|
||
if err != nil {
|
||
return fmt.Errorf("start MQTT: %w", err)
|
||
}
|
||
defer mqtt.Stop(manager)
|
||
|
||
// Wire the command service before exposing its HTTP endpoints.
|
||
store := commands.NewStore()
|
||
publisher := mqtt.CommandPublisher{Manager: manager}
|
||
service := commands.NewService(store, publisher, 15*time.Second)
|
||
api := httpapi.NewAPI(ctx, service)
|
||
server, err := httpapi.NewServer("127.0.0.1:18080", api)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
serverErrors := make(chan error, 1)
|
||
go func() { serverErrors <- server.ListenAndServe() }()
|
||
log.Printf("HTTP API listening on %s", server.Addr)
|
||
expirationTicker := time.NewTicker(time.Second)
|
||
defer expirationTicker.Stop()
|
||
|
||
// Keep response processing and expiration active for the server lifetime.
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if err := server.Shutdown(shutdown); err != nil {
|
||
return fmt.Errorf("HTTP shutdown: %w", err)
|
||
}
|
||
return nil
|
||
case err := <-serverErrors:
|
||
if !errors.Is(err, http.ErrServerClosed) {
|
||
return fmt.Errorf("HTTP server: %w", err)
|
||
}
|
||
return nil
|
||
case <-expirationTicker.C:
|
||
service.Expire(time.Now().UTC())
|
||
case message := <-inbox:
|
||
topic, err := contracts.ParseTopic(message.Topic)
|
||
if err != nil {
|
||
log.Printf("rejected topic=%q reason=%v", message.Topic, err)
|
||
continue
|
||
}
|
||
switch topic.Kind {
|
||
case contracts.KindResponses:
|
||
if err := commands.HandleMessage(service, message); err != nil {
|
||
log.Printf("rejected response topic=%q reason=%v", message.Topic, err)
|
||
}
|
||
case contracts.KindTelemetry:
|
||
var heartbeat contracts.Heartbeat
|
||
heartbeat, err = contracts.Decode[contracts.Heartbeat](message.Payload, topic, time.Now().UTC())
|
||
if err != nil {
|
||
log.Printf("rejected telemetry topic=%q reason=%v", message.Topic, err)
|
||
continue
|
||
}
|
||
log.Printf("valid heartbeat device=%s message=%s", heartbeat.DeviceID, heartbeat.MessageID)
|
||
case contracts.KindSales:
|
||
var report contracts.SalesReport
|
||
report, err = contracts.Decode[contracts.SalesReport](message.Payload, topic, time.Now().UTC())
|
||
if err != nil {
|
||
log.Printf("rejected sales topic=%q reason=%v", message.Topic, err)
|
||
continue
|
||
}
|
||
log.Printf("valid sales report device=%s report=%s", report.DeviceID, report.ReportID)
|
||
default:
|
||
log.Printf("ignored topic kind=%s", topic.Kind)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</code></pre></article></details>
|
||
|
||
<h2>🏗️ Build and run the HTTP-to-device path</h2>
|
||
<p>After saving the Section 7 command files plus the Section 8 HTTP files and backend wiring, build and start the lab from the repository root:</p>
|
||
<pre><code>go test ./...
|
||
go build -o /tmp/mqtt5-lab-backend ./cmd/backend
|
||
go build -o /tmp/mqtt5-lab-simulator ./cmd/simulator
|
||
docker compose -p mqtt5-learning up -d broker
|
||
docker compose -p mqtt5-learning ps</code></pre>
|
||
<p>The tests cover only the tests present in the checkout; the builds verify the complete API and simulator programs compile. Run the simulator and backend in separate terminals:</p>
|
||
<pre><code>/tmp/mqtt5-lab-simulator -username simulator -client-id lab-simulator-device-001</code></pre>
|
||
<pre><code>/tmp/mqtt5-lab-backend -username backend -client-id lab-backend</code></pre>
|
||
<p>Once the backend reports that the HTTP API is listening, submit a harmless ping. Copy the returned <code>command_id</code> into the GET request:</p>
|
||
<pre><code>curl -i -X POST http://127.0.0.1:18080/devices/device-001/commands \
|
||
-H 'Content-Type: application/json' \
|
||
-H 'Idempotency-Key: lesson8-ping-001' \
|
||
--data '{"command":"ping"}'
|
||
|
||
# Replace cmd-example with the command_id returned by POST.
|
||
curl -i http://127.0.0.1:18080/commands/cmd-example</code></pre>
|
||
<p>Expect HTTP <code>202</code> from POST, then later application status from GET after the simulator responds. Repeat the same POST with the same idempotency key and body; it should return the same command ID. PUBACK alone is not device completion. Stop both clients with Ctrl+C, then stop the broker with <code>docker compose -p mqtt5-learning stop broker</code>. Ask me a follow-up if the observed status or retry result differs from your prediction.</p>
|
||
|
||
<section class="card callout">
|
||
<h2>✅ Takeaway</h2>
|
||
<p><strong>202 = API admission. PUBACK = MQTT publication acknowledgement. Device result = application-reported outcome.</strong></p>
|
||
<p>Keep those facts separate in logs, API fields, dashboards, and incident reports. Ask me a follow-up question if any boundary still feels fuzzy.</p>
|
||
</section>
|
||
|
||
<footer>
|
||
<p>↩️ Previous: <a href="../course-source/07-command-response.md">Section 7 — command/response</a> · 📚 Reference: <a href="../reference/http-mqtt-evidence.html">HTTP and MQTT evidence</a></p>
|
||
<p>Previous: <a href="0007-command-state.html">Lesson 7 — command identity and state</a> · Next: <a href="0009-session-recovery.html">Lesson 9 — session recovery</a> · Full implementation: <a href="../course-source/08-http-commands.md">Section 8 source lesson</a>.</p>
|
||
<p>Primary sources: <a href="https://datatracker.ietf.org/doc/rfc9110/">RFC 9110 §15.3.3 — 202 Accepted</a>; <a href="https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html">OASIS MQTT 5.0 Standard — PUBACK</a>; <a href="https://pkg.go.dev/net/http">Go `net/http` documentation</a>. See <a href="../RESOURCES.md">all workspace resources</a>.</p>
|
||
</footer>
|
||
</main>
|
||
</body>
|
||
</html>
|