สร้าง Device Management API ด้วย Go Fiber

สร้าง Device Management API ด้วย Go Fiber

Showkhun · Workshop ·

Branch: workshop/dev-03-device-api Phase: Development — API Layer Repository: https://github.com/kangana1024/iot-workshop


เกริ่นก่อนลุย (╯°□°)╯

เคยไหมครับ เวลาเราต่อ IoT device เข้ามาในระบบ แล้วไม่รู้ว่ามันยังอยู่ไหม เปิดอยู่ไหม หรือพังไปแล้ว? วันนี้พี่โชว์จะพาน้องๆ มาสร้าง Device Management API ที่จัดการ device ได้ครบทุก operation ตั้งแต่ลงทะเบียนไปจนถึงสั่งงานจากระยะไกล

เหมือนเราทำระบบ “ทะเบียนบ้าน” ให้ IoT device นั่นแหละครับ — ลงทะเบียน, ดูสถานะ, แก้ข้อมูล, หรือจะลบออกก็ได้ มาลุยกัน!


น้องๆ จะได้เรียนรู้อะไรบ้าง

  • ทำไมถึงต้องมี Device Management API (WHY ก่อน HOW เสมอ!)
  • สร้าง CRUD Handler ครบชุดด้วย Go Fiber
  • ทำ Pagination + Filtering + Sorting แบบ flexible
  • Device Registration Flow ที่ออก Auth Token ตอน register เท่านั้น
  • Bulk Operations — จัดการ device หลายตัวพร้อมกัน
  • Status Monitor ทำงาน background ตรวจจับ device ที่หายไป
  • ตั้งค่า Swagger Documentation อัตโนมัติ

ทำไมต้องมี Device Management API?

ลองนึกภาพนะครับ ถ้าเรามี sensor อยู่ 500 ตัวในโรงงาน แล้วอยากรู้ว่าตัวไหน offline อยู่บ้าง — จะไปเดินตรวจทีละตัวคงไม่ไหว ระบบ API ตรงนี้เลยทำหน้าที่เป็น “แผนกทะเบียน” ของ device ทั้งหมด

เปรียบเหมือนระบบ HR ของบริษัท:

  • POST /devices = สมัครงาน (register device ใหม่)
  • GET /devices = ดูรายชื่อพนักงาน (list ทั้งหมด)
  • PUT /devices/:id = แก้ข้อมูลพนักงาน (update)
  • DELETE /devices/:id = ออกจากงาน แต่ยังเก็บ record ไว้ (soft delete)
  • POST /devices/:id/commands = ส่ง email สั่งงาน (send command ผ่าน MQTT)

ภาพรวม REST API Flow

ก่อนเขียน code เรามาดู flow รวมกันก่อนนะครับ — WHY ก่อน HOW เสมอ!

Mermaid Diagram

เห็นไหมครับ ทุก request ผ่าน Handler → Usecase → MongoDB เป็น layered architecture เหมือนแฮมเบอร์เกอร์ — แต่ละชั้นมีหน้าที่ของตัวเองชัดเจน


API Endpoints ทั้งหมด

MethodPathทำอะไร
GET/api/v1/devicesList devices พร้อม pagination
POST/api/v1/devicesRegister device ใหม่
GET/api/v1/devices/:idดู device ตาม ID
PUT/api/v1/devices/:idUpdate device
DELETE/api/v1/devices/:idSoft delete device
POST/api/v1/devices/:id/commandsส่ง command ไปยัง device
GET/api/v1/devices/:id/statusดูสถานะ device
POST/api/v1/devices/bulkBulk operations หลาย device พร้อมกัน

Device Handler — หัวใจของทั้งหมด

internal/handler/device_handler.go

Handler คือ “พนักงานต้อนรับ” ครับ รับ HTTP request มา, ตรวจสอบข้อมูล, แล้วส่งต่อให้ Usecase ไปจัดการจริงๆ

package handler

import (
	"github.com/gofiber/fiber/v2"
	"go.mongodb.org/mongo-driver/bson/primitive"
	"go.uber.org/zap"

	"github.com/kangana1024/iot-workshop/backend/internal/domain"
	"github.com/kangana1024/iot-workshop/backend/internal/usecase"
	"github.com/kangana1024/iot-workshop/backend/pkg/response"
	"github.com/kangana1024/iot-workshop/backend/pkg/validator"
)

// DeviceHandler handles HTTP requests for device management
type DeviceHandler struct {
	deviceUsecase *usecase.DeviceUsecase
	validator     *validator.Validator
	log           *zap.Logger
}

// NewDeviceHandler creates a new DeviceHandler
func NewDeviceHandler(du *usecase.DeviceUsecase, v *validator.Validator, log *zap.Logger) *DeviceHandler {
	return &DeviceHandler{
		deviceUsecase: du,
		validator:     v,
		log:           log,
	}
}

เราใช้ dependency injection ครับ — แทนที่จะสร้าง usecase ข้างใน, เราส่งเข้ามาจากข้างนอก เหมือนร้านอาหารที่ไม่ได้เลี้ยงวัวเอง แต่สั่งเนื้อจากซัพพลายเออร์ แยกหน้าที่กันชัดเจน


List Devices — ดึงรายการพร้อม Pagination

ทำไมต้อง pagination? ลองนึกดูว่าถ้า device 10,000 ตัว แล้วดึงทีเดียวหมด — server พังแน่ครับ เหมือนห้องสมุดที่ต้องค่อยๆ หยิบทีละหน้าจากชั้น ไม่ใช่ยกทั้งชั้นมาพร้อมกัน

// List godoc
// @Summary      List devices
// @Description  Returns a paginated list of devices with optional filters
// @Tags         devices
// @Accept       json
// @Produce      json
// @Param        page     query  int     false  "Page number (default: 1)"
// @Param        limit    query  int     false  "Items per page (default: 20, max: 100)"
// @Param        status   query  string  false  "Filter by status: online|offline|inactive|error"
// @Param        type     query  string  false  "Filter by type: sensor|actuator|gateway|camera"
// @Param        search   query  string  false  "Full-text search"
// @Param        sort_by  query  string  false  "Sort field (default: created_at)"
// @Param        sort_dir query  string  false  "Sort direction: asc|desc (default: desc)"
// @Success      200  {object}  response.Response
// @Security     BearerAuth
// @Router       /api/v1/devices [get]
func (h *DeviceHandler) List(c *fiber.Ctx) error {
	var filter domain.DeviceFilter
	if err := c.QueryParser(&filter); err != nil {
		return response.BadRequest(c, "INVALID_QUERY", "Invalid query parameters", err.Error())
	}

	// Validate filter
	if errs := h.validator.Validate(filter); errs != nil {
		return response.BadRequest(c, "VALIDATION_ERROR", "Invalid filter parameters", errs)
	}

	// Get owner from JWT context (set by auth middleware)
	ownerID := c.Locals("user_id").(primitive.ObjectID)

	devices, total, err := h.deviceUsecase.List(c.Context(), ownerID, filter)
	if err != nil {
		h.log.Error("Failed to list devices", zap.Error(err))
		return response.InternalError(c, "Failed to retrieve devices")
	}

	// Build pagination meta
	page := filter.Page
	if page < 1 {
		page = 1
	}
	limit := filter.Limit
	if limit < 1 {
		limit = 20
	}
	totalPages := int((total + int64(limit) - 1) / int64(limit))

	return response.Paginated(c, devices, &response.Meta{
		Page:       page,
		Limit:      limit,
		Total:      total,
		TotalPages: totalPages,
	})
}

สังเกตนะครับ ownerID := c.Locals("user_id") — ตรงนี้คือการดึง user ID จาก JWT token ที่ middleware parse ไว้ให้แล้ว เราไม่ต้อง parse เองอีกรอบ เหมือนพนักงานต้อนรับที่รู้แขกคนไหนมาจากบัตรที่ security เช็คให้แล้ว


Create — Register Device ใหม่

// Create godoc
// @Summary      Register a new device
// @Description  Registers a new IoT device and returns its credentials
// @Tags         devices
// @Accept       json
// @Produce      json
// @Param        body  body  domain.CreateDeviceRequest  true  "Device registration data"
// @Success      201   {object}  response.Response
// @Security     BearerAuth
// @Router       /api/v1/devices [post]
func (h *DeviceHandler) Create(c *fiber.Ctx) error {
	var req domain.CreateDeviceRequest
	if err := c.BodyParser(&req); err != nil {
		return response.BadRequest(c, "INVALID_BODY", "Cannot parse request body", err.Error())
	}

	if errs := h.validator.Validate(req); errs != nil {
		return response.BadRequest(c, "VALIDATION_ERROR", "Validation failed", errs)
	}

	ownerID := c.Locals("user_id").(primitive.ObjectID)

	device, err := h.deviceUsecase.Register(c.Context(), ownerID, &req)
	if err != nil {
		if isConflictError(err) {
			return response.BadRequest(c, "DEVICE_ID_TAKEN", err.Error(), nil)
		}
		h.log.Error("Failed to register device", zap.Error(err))
		return response.InternalError(c, "Failed to register device")
	}

	// Return device WITH token on creation (only time token is visible)
	return response.Created(c, deviceWithToken(device))
}

จุดสำคัญตรงนี้คือ deviceWithToken(device) ครับ — token จะแสดงแค่ตอน register ครั้งเดียวเท่านั้น! เหมือนรหัส OTP ที่ใช้ได้แค่ครั้งแรก ถ้าทำหาย device ต้องมา register ใหม่


GetByID, Update, Delete — ทีมCRUD ที่เหลือ

// GetByID godoc
// @Summary      Get device by ID
// @Description  Returns a single device by its MongoDB ObjectID
// @Tags         devices
// @Produce      json
// @Param        id  path  string  true  "Device MongoDB ObjectID"
// @Success      200  {object}  response.Response
// @Security     BearerAuth
// @Router       /api/v1/devices/{id} [get]
func (h *DeviceHandler) GetByID(c *fiber.Ctx) error {
	id := c.Params("id")
	if _, err := primitive.ObjectIDFromHex(id); err != nil {
		return response.BadRequest(c, "INVALID_ID", "Invalid device ID format", nil)
	}

	ownerID := c.Locals("user_id").(primitive.ObjectID)

	device, err := h.deviceUsecase.GetByID(c.Context(), id, ownerID)
	if err != nil {
		if err.Error() == "access denied" {
			return response.NotFound(c, "Device")
		}
		h.log.Error("Failed to get device", zap.Error(err), zap.String("id", id))
		return response.InternalError(c, "Failed to retrieve device")
	}
	if device == nil {
		return response.NotFound(c, "Device")
	}

	return response.OK(c, device)
}

// Update godoc
// @Summary      Update device
// @Description  Updates device fields. Only provided fields are updated (PATCH semantics via PUT)
// @Tags         devices
// @Accept       json
// @Produce      json
// @Param        id    path  string                     true  "Device MongoDB ObjectID"
// @Param        body  body  domain.UpdateDeviceRequest  true  "Fields to update"
// @Success      200   {object}  response.Response
// @Security     BearerAuth
// @Router       /api/v1/devices/{id} [put]
func (h *DeviceHandler) Update(c *fiber.Ctx) error {
	id := c.Params("id")
	oid, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		return response.BadRequest(c, "INVALID_ID", "Invalid device ID format", nil)
	}

	var req domain.UpdateDeviceRequest
	if err := c.BodyParser(&req); err != nil {
		return response.BadRequest(c, "INVALID_BODY", "Cannot parse request body", err.Error())
	}

	if errs := h.validator.Validate(req); errs != nil {
		return response.BadRequest(c, "VALIDATION_ERROR", "Validation failed", errs)
	}

	ownerID := c.Locals("user_id").(primitive.ObjectID)

	device, err := h.deviceUsecase.Update(c.Context(), oid, ownerID, &req)
	if err != nil {
		h.log.Error("Failed to update device", zap.Error(err))
		return response.InternalError(c, "Failed to update device")
	}
	if device == nil {
		return response.NotFound(c, "Device")
	}

	return response.OKWithMessage(c, "Device updated successfully", device)
}

// Delete godoc
// @Summary      Delete device
// @Description  Soft-deletes a device. The device data is retained but hidden from queries.
// @Tags         devices
// @Produce      json
// @Param        id  path  string  true  "Device MongoDB ObjectID"
// @Success      200  {object}  response.Response
// @Security     BearerAuth
// @Router       /api/v1/devices/{id} [delete]
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
	id := c.Params("id")
	oid, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		return response.BadRequest(c, "INVALID_ID", "Invalid device ID format", nil)
	}

	ownerID := c.Locals("user_id").(primitive.ObjectID)

	if err := h.deviceUsecase.Delete(c.Context(), oid, ownerID); err != nil {
		if err.Error() == "not found" {
			return response.NotFound(c, "Device")
		}
		h.log.Error("Failed to delete device", zap.Error(err))
		return response.InternalError(c, "Failed to delete device")
	}

	return response.OKWithMessage(c, "Device deleted successfully", nil)
}

Soft Delete คืออะไร? แทนที่จะลบข้อมูลออกจาก DB จริงๆ เราแค่ set flag deleted_at ไว้ — เหมือนพนักงานที่ “resign” แต่ยังเก็บประวัติไว้ในระบบ HR ครับ ถ้าวันนึงต้องการย้อนดูข้อมูลก็ยังหาเจอ


Send Command — สั่งงาน Device จากระยะไกล

// SendCommand godoc
// @Summary      Send command to device
// @Description  Publishes a command message to the device via MQTT
// @Tags         devices
// @Accept       json
// @Produce      json
// @Param        id    path  string          true  "Device MongoDB ObjectID"
// @Param        body  body  DeviceCommandRequest  true  "Command payload"
// @Success      200   {object}  response.Response
// @Security     BearerAuth
// @Router       /api/v1/devices/{id}/commands [post]
func (h *DeviceHandler) SendCommand(c *fiber.Ctx) error {
	id := c.Params("id")
	if _, err := primitive.ObjectIDFromHex(id); err != nil {
		return response.BadRequest(c, "INVALID_ID", "Invalid device ID format", nil)
	}

	var req DeviceCommandRequest
	if err := c.BodyParser(&req); err != nil {
		return response.BadRequest(c, "INVALID_BODY", "Cannot parse request body", err.Error())
	}

	if errs := h.validator.Validate(req); errs != nil {
		return response.BadRequest(c, "VALIDATION_ERROR", "Validation failed", errs)
	}

	ownerID := c.Locals("user_id").(primitive.ObjectID)

	if err := h.deviceUsecase.SendCommand(c.Context(), id, ownerID, req.Command, req.Payload); err != nil {
		h.log.Error("Failed to send command", zap.Error(err))
		return response.InternalError(c, "Failed to send command")
	}

	return response.OKWithMessage(c, "Command sent successfully", fiber.Map{
		"device_id": id,
		"command":   req.Command,
	})
}

// DeviceCommandRequest is the body for sending a command to a device
type DeviceCommandRequest struct {
	Command string                 `json:"command" validate:"required,oneof=reboot reset update_firmware set_interval"`
	Payload map[string]interface{} `json:"payload"`
}

// deviceWithToken wraps a device response including the token (registration only)
type deviceWithTokenResponse struct {
	*domain.Device
	Token string `json:"token"`
}

func deviceWithToken(d *domain.Device) *deviceWithTokenResponse {
	return &deviceWithTokenResponse{Device: d, Token: d.Token}
}

func isConflictError(err error) bool {
	return err != nil && (contains(err.Error(), "already exists") || contains(err.Error(), "already registered"))
}

func contains(s, substr string) bool {
	return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
}

func containsStr(s, substr string) bool {
	for i := 0; i <= len(s)-len(substr); i++ {
		if s[i:i+len(substr)] == substr {
			return true
		}
	}
	return false
}

command ที่ส่งได้มี: reboot, reset, update_firmware, set_interval — ผ่าน MQTT ไปยัง device โดยตรงครับ เหมือนส่ง SMS สั่งงานให้พนักงานภาคสนาม


Bulk Operations — จัดการหมู่คณะ

internal/handler/device_bulk_handler.go

ทำไมต้อง Bulk? ลองนึกว่ามี device 200 ตัวที่ต้องย้าย group พร้อมกัน ถ้าต้องเรียก API ทีละตัว = 200 requests = ช้าและเปลือง bandwidth ครับ Bulk ทำให้จบใน 1 call

 +-----------+     +----------+     +----------+
 | Client    | --> | Bulk API | --> | device_1 |
 +-----------+     |          | --> | device_2 |
   1 request       |          | --> | ...      |
                   +----------+     | device_N |
                                    +----------+
               แทนที่จะส่ง N requests แยกกัน!
package handler

import (
	"github.com/gofiber/fiber/v2"
	"go.mongodb.org/mongo-driver/bson/primitive"

	"github.com/kangana1024/iot-workshop/backend/pkg/response"
)

// BulkOperation defines what to do with selected devices
type BulkOperation string

const (
	BulkOpDelete      BulkOperation = "delete"
	BulkOpUpdateGroup BulkOperation = "update_group"
	BulkOpAddTags     BulkOperation = "add_tags"
	BulkOpRemoveTags  BulkOperation = "remove_tags"
)

// BulkRequest is the body for bulk operations
type BulkRequest struct {
	Operation BulkOperation          `json:"operation" validate:"required,oneof=delete update_group add_tags remove_tags"`
	DeviceIDs []string               `json:"device_ids" validate:"required,min=1,max=100,dive,mongodb_id"`
	Params    map[string]interface{} `json:"params"`
}

// BulkResult reports the outcome of each device in the bulk operation
type BulkResult struct {
	Succeeded []string            `json:"succeeded"`
	Failed    map[string]string   `json:"failed"`
	Total     int                 `json:"total"`
}

// Bulk godoc
// @Summary      Bulk device operations
// @Description  Perform an operation on multiple devices at once
// @Tags         devices
// @Accept       json
// @Produce      json
// @Param        body  body  BulkRequest  true  "Bulk operation"
// @Success      200   {object}  response.Response
// @Security     BearerAuth
// @Router       /api/v1/devices/bulk [post]
func (h *DeviceHandler) Bulk(c *fiber.Ctx) error {
	var req BulkRequest
	if err := c.BodyParser(&req); err != nil {
		return response.BadRequest(c, "INVALID_BODY", "Cannot parse request body", err.Error())
	}

	if errs := h.validator.Validate(req); errs != nil {
		return response.BadRequest(c, "VALIDATION_ERROR", "Validation failed", errs)
	}

	ownerID := c.Locals("user_id").(primitive.ObjectID)

	result := &BulkResult{
		Total:     len(req.DeviceIDs),
		Succeeded: make([]string, 0),
		Failed:    make(map[string]string),
	}

	for _, idStr := range req.DeviceIDs {
		oid, err := primitive.ObjectIDFromHex(idStr)
		if err != nil {
			result.Failed[idStr] = "invalid ID format"
			continue
		}

		var opErr error
		switch req.Operation {
		case BulkOpDelete:
			opErr = h.deviceUsecase.Delete(c.Context(), oid, ownerID)

		case BulkOpUpdateGroup:
			groupID, _ := req.Params["group_id"].(string)
			opErr = h.deviceUsecase.UpdateGroup(c.Context(), oid, ownerID, groupID)

		case BulkOpAddTags:
			tags := toStringSlice(req.Params["tags"])
			opErr = h.deviceUsecase.AddTags(c.Context(), oid, ownerID, tags)

		case BulkOpRemoveTags:
			tags := toStringSlice(req.Params["tags"])
			opErr = h.deviceUsecase.RemoveTags(c.Context(), oid, ownerID, tags)
		}

		if opErr != nil {
			result.Failed[idStr] = opErr.Error()
		} else {
			result.Succeeded = append(result.Succeeded, idStr)
		}
	}

	return response.OK(c, result)
}

func toStringSlice(v interface{}) []string {
	raw, ok := v.([]interface{})
	if !ok {
		return nil
	}
	out := make([]string, 0, len(raw))
	for _, item := range raw {
		if s, ok := item.(string); ok {
			out = append(out, s)
		}
	}
	return out
}

สังเกตว่า BulkResult มีทั้ง Succeeded และ Failed แยกกัน — เพราะ partial success เป็นเรื่องปกติในโลก IoT ครับ device บางตัวอาจถูกลบไปแล้ว หรือไม่ใช่เจ้าของก็ได้


Device Status Monitor — เฝ้าระวัง 24/7

internal/usecase/device_status_usecase.go

ทำไมต้องมี Status Monitor? เพราะ IoT device มันอาจ “หาย” ได้โดยไม่แจ้งลา — ไฟดับ, network ขาด, หรือ firmware crash ระบบ monitor นี้จะ check ทุก 30 วินาที ว่า device ไหน “เงียบ” ผิดปกติ แล้ว mark ว่า offline

เหมือนพี่ supervisor ที่เดินตรวจพนักงาน ถ้าใครหายไปจากโต๊ะนานเกินไป ก็จดว่า “absent” ให้เลย (ノ◕ヮ◕)ノ*:・゚✧

Mermaid Diagram

package usecase

import (
	"context"
	"time"

	"go.uber.org/zap"

	"github.com/kangana1024/iot-workshop/backend/internal/domain"
	"github.com/kangana1024/iot-workshop/backend/internal/repository"
)

// DeviceStatusMonitor runs a background goroutine to mark stale devices as offline
type DeviceStatusMonitor struct {
	deviceRepo repository.DeviceRepository
	log        *zap.Logger
	interval   time.Duration
	stopCh     chan struct{}
}

// NewDeviceStatusMonitor creates a new monitor
func NewDeviceStatusMonitor(repo repository.DeviceRepository, log *zap.Logger) *DeviceStatusMonitor {
	return &DeviceStatusMonitor{
		deviceRepo: repo,
		log:        log,
		interval:   30 * time.Second,
		stopCh:     make(chan struct{}),
	}
}

// Start begins the background monitoring loop
func (m *DeviceStatusMonitor) Start(ctx context.Context) {
	m.log.Info("Device status monitor started", zap.Duration("interval", m.interval))
	ticker := time.NewTicker(m.interval)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			m.checkStaleDevices(ctx)
		case <-m.stopCh:
			m.log.Info("Device status monitor stopped")
			return
		case <-ctx.Done():
			return
		}
	}
}

// Stop signals the monitor to stop
func (m *DeviceStatusMonitor) Stop() {
	close(m.stopCh)
}

func (m *DeviceStatusMonitor) checkStaleDevices(ctx context.Context) {
	filter := domain.DeviceFilter{Page: 1, Limit: 100}
	staleDevices, err := m.deviceRepo.FindStaleDevices(ctx, filter)
	if err != nil {
		m.log.Error("Failed to find stale devices", zap.Error(err))
		return
	}

	for _, device := range staleDevices {
		if err := m.deviceRepo.UpdateStatus(ctx, device.DeviceID, domain.DeviceStatusOffline); err != nil {
			m.log.Error("Failed to mark device offline",
				zap.String("device_id", device.DeviceID),
				zap.Error(err),
			)
		} else {
			m.log.Debug("Device marked offline", zap.String("device_id", device.DeviceID))
		}
	}

	if len(staleDevices) > 0 {
		m.log.Info("Stale device check complete",
			zap.Int("marked_offline", len(staleDevices)),
		)
	}
}

select ใน Go นี่เจ๋งมากครับ มันรอหลาย channel พร้อมกัน แล้ว “แย่งกัน” respond — เหมือนพนักงาน call center ที่รับโทรศัพท์หลายสายในเวลาเดียวกัน ใครโทรมาก่อนก็รับก่อน แต่ถ้ามีสายพิเศษ “หยุดการทำงาน” ก็วางสายทั้งหมดแล้วออก


Swagger Documentation Setup

ทำไม Swagger ถึงสำคัญ?

ลองคิดดูว่า front-end developer จะรู้ได้ยังไงว่า API รับ parameter อะไร, return อะไร? ถ้าต้องมานั่งถามทีม back-end ทุกครั้ง — เสียเวลามาก Swagger สร้าง “manual อัตโนมัติ” จาก annotation ใน code เลยครับ

ติดตั้ง swaggo

go install github.com/swaggo/swag/cmd/swag@latest
go get github.com/swaggo/fiber-swagger
go get github.com/swaggo/swag

cmd/server/main.go — เพิ่ม Swagger annotation

// @title          IoT Workshop API
// @version        1.0
// @description    IoT Platform Backend API for device management, sensor data, and real-time monitoring
// @termsOfService http://swagger.io/terms/

// @contact.name   IoT Workshop Team
// @contact.url    https://github.com/kangana1024/iot-workshop

// @license.name  MIT

// @host      localhost:8080
// @BasePath  /api/v1

// @securityDefinitions.apikey  BearerAuth
// @in                          header
// @name                        Authorization
// @description                 Type "Bearer" followed by a space and the JWT token.

package main

สร้าง Swagger docs

swag init -g cmd/server/main.go -o docs/

# เพิ่ม route ใน router.go
import fiberSwagger "github.com/swaggo/fiber-swagger"
import _ "github.com/kangana1024/iot-workshop/backend/docs"

app.Get("/swagger/*", fiberSwagger.WrapHandler)

หลังจาก run swag init แล้ว เปิด browser ไปที่ http://localhost:8080/swagger/index.html จะเห็น interactive API docs ทันที ลอง call API จากหน้าเว็บได้เลยโดยไม่ต้องใช้ curl!


ทดสอบ API กัน!

Register Device

curl -X POST http://localhost:8080/api/v1/devices \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "device_id": "sensor-temp-001",
    "name": "Temperature Sensor - Building A",
    "description": "DHT22 sensor on floor 3",
    "type": "sensor",
    "location": {
      "latitude": 13.7563,
      "longitude": 100.5018,
      "label": "Bangkok Office"
    },
    "tags": ["temperature", "humidity", "building-a"],
    "metadata": {
      "model": "DHT22",
      "firmware": "1.2.0"
    }
  }'

List Devices with Filters

# Get online sensors, newest first
curl "http://localhost:8080/api/v1/devices?status=online&type=sensor&sort_by=last_seen_at&sort_dir=desc&page=1&limit=20" \
  -H "Authorization: Bearer $TOKEN"

# Search devices
curl "http://localhost:8080/api/v1/devices?search=temperature&page=1&limit=10" \
  -H "Authorization: Bearer $TOKEN"

Bulk Delete

curl -X POST http://localhost:8080/api/v1/devices/bulk \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "operation": "delete",
    "device_ids": [
      "65f1234567890abcdef12345",
      "65f1234567890abcdef12346"
    ]
  }'

Send Command

curl -X POST http://localhost:8080/api/v1/devices/65f1234567890abcdef12345/commands \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "command": "set_interval",
    "payload": {
      "interval_seconds": 30
    }
  }'

Response Examples — หน้าตา Response ที่ได้กลับมา

Successful List Response

{
  "success": true,
  "data": [
    {
      "id": "65f1234567890abcdef12345",
      "device_id": "sensor-temp-001",
      "name": "Temperature Sensor - Building A",
      "type": "sensor",
      "status": "online",
      "last_seen_at": "2026-03-26T10:30:00Z",
      "created_at": "2026-03-01T08:00:00Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 42,
    "total_pages": 3
  }
}

Validation Error Response

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "device_id": "device_id is required",
      "type": "type must be one of: sensor actuator gateway camera"
    }
  }
}

Error response ที่ดีต้องบอกชัดว่า field ไหนผิด ผิดยังไง — เหมือนครูที่แก้การบ้านแล้วบอกว่าข้อไหนผิดและเพราะอะไร ไม่ใช่แค่เขียน “ผิด” ไว้ครับ


สรุปสิ่งที่เราได้ทำวันนี้

(ง •̀_•́)ง ทำได้ครบทุกอย่าง!
  • สร้าง CRUD REST API ครบชุดสำหรับ Device Management
  • ใช้ Pagination, Filtering, Sorting แบบ flexible ด้วย QueryParser
  • สร้าง Device Registration Flow ที่ออก Auth Token ครั้งเดียวตอน register
  • ติดตั้ง Device Status Monitor ที่ทำงาน background ตรวจ stale devices ทุก 30 วินาที
  • สร้าง Bulk Operations สำหรับจัดการหลาย device พร้อมกัน
  • ตั้งค่า Swagger Documentation อัตโนมัติจาก annotation ใน code

ขั้นตอนต่อไป

ตอนหน้าเราจะมาทำ Sensor Data Ingestion — รับข้อมูล sensor ที่ไหลเข้ามาตลอดเวลาแบบ real-time กัน มาลุยต่อกันนะครับน้องๆ! ٩(◕‿◕。)۶