Files
Extrudex/backend/internal/handlers/filament_handler.go
Joshua fca2ef5b84
Some checks failed
Dev Build / build-test (pull_request) Failing after 2m4s
CUB-113: implement core CRUD API endpoints
- Add dtos package with request/response structs
- Add repositories: Material, Filament, Printer, PrintJob, UsageLog
- Add services: FilamentService, PrinterService, PrintJobService
- Add handlers for all 5 resources with consistent error responses
- Wire all endpoints into Chi router under /api
- Validation on POST/PUT filament endpoints
- Filter/pagination support on list endpoints
- Soft-delete for filaments (DELETE /api/filaments/{id})
- go build ./... && go vet ./... → PASS
2026-05-06 14:24:58 -04:00

274 lines
7.7 KiB
Go

package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/dtos"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/models"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/services"
"github.com/go-chi/chi/v5"
)
// FilamentHandler handles HTTP requests for filament spool CRUD operations.
type FilamentHandler struct {
service *services.FilamentService
}
// NewFilamentHandler creates a FilamentHandler with the given service.
func NewFilamentHandler(service *services.FilamentService) *FilamentHandler {
return &FilamentHandler{service: service}
}
// List handles GET /api/filaments — returns paginated, filtered spools.
func (h *FilamentHandler) List(w http.ResponseWriter, r *http.Request) {
limit, offset := parsePagination(r)
filter := repositories.FilamentFilter{
Material: r.URL.Query().Get("material"),
Finish: r.URL.Query().Get("finish"),
Color: r.URL.Query().Get("color"),
LowStock: r.URL.Query().Get("low_stock") == "true",
Limit: limit,
Offset: offset,
}
spools, total, err := h.service.List(r.Context(), filter)
if err != nil {
slog.Error("failed to list filaments", "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
writeJSON(w, http.StatusOK, dtos.ListResponse{
Data: spools,
Total: total,
Limit: limit,
Offset: offset,
})
}
// Get handles GET /api/filaments/{id} — returns a single spool.
func (h *FilamentHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "invalid filament ID",
Code: http.StatusBadRequest,
})
return
}
spool, err := h.service.GetByID(r.Context(), id)
if err != nil {
slog.Error("failed to get filament", "id", id, "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
if spool == nil {
writeJSON(w, http.StatusNotFound, dtos.ErrorResponse{
Error: "filament not found",
Code: http.StatusNotFound,
})
return
}
writeJSON(w, http.StatusOK, dtos.SingleResponse{Data: spool})
}
// Create handles POST /api/filaments — creates a new filament spool.
func (h *FilamentHandler) Create(w http.ResponseWriter, r *http.Request) {
var req dtos.CreateFilamentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "invalid request body",
Code: http.StatusBadRequest,
})
return
}
// Validate required fields.
if err := services.ValidateCreateFilamentRequest(req); err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "validation failed: " + err.Error(),
Code: http.StatusBadRequest,
})
return
}
// Build domain model.
spool := models.FilamentSpool{
Name: req.Name,
MaterialBaseID: req.MaterialBaseID,
MaterialFinishID: req.MaterialFinishID,
MaterialModifierID: req.MaterialModifierID,
ColorHex: req.ColorHex,
Brand: req.Brand,
DiameterMM: 1.75, // default
InitialGrams: req.InitialGrams,
RemainingGrams: req.RemainingGrams,
SpoolWeightGrams: req.SpoolWeightGrams,
CostUSD: req.CostUSD,
LowStockThresholdGrams: 50, // default
Notes: req.Notes,
Barcode: req.Barcode,
}
if req.DiameterMM != nil {
spool.DiameterMM = *req.DiameterMM
}
if req.LowStockThresholdGrams != nil {
spool.LowStockThresholdGrams = *req.LowStockThresholdGrams
}
created, err := h.service.Create(r.Context(), &spool)
if err != nil {
slog.Error("failed to create filament", "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
writeJSON(w, http.StatusCreated, dtos.SingleResponse{Data: created})
}
// Update handles PUT /api/filaments/{id} — partially updates a spool.
func (h *FilamentHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "invalid filament ID",
Code: http.StatusBadRequest,
})
return
}
var req dtos.UpdateFilamentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "invalid request body",
Code: http.StatusBadRequest,
})
return
}
// Validate update fields.
if err := services.ValidateUpdateFilamentRequest(req); err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "validation failed: " + err.Error(),
Code: http.StatusBadRequest,
})
return
}
// Build updates map (only non-nil fields).
updates := buildFilamentUpdates(req)
updated, err := h.service.Update(r.Context(), id, updates)
if err != nil {
slog.Error("failed to update filament", "id", id, "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
if updated == nil {
writeJSON(w, http.StatusNotFound, dtos.ErrorResponse{
Error: "filament not found",
Code: http.StatusNotFound,
})
return
}
writeJSON(w, http.StatusOK, dtos.SingleResponse{Data: updated})
}
// Delete handles DELETE /api/filaments/{id} — soft-deletes a spool.
func (h *FilamentHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "invalid filament ID",
Code: http.StatusBadRequest,
})
return
}
deleted, err := h.service.SoftDelete(r.Context(), id)
if err != nil {
slog.Error("failed to delete filament", "id", id, "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
if !deleted {
writeJSON(w, http.StatusNotFound, dtos.ErrorResponse{
Error: "filament not found",
Code: http.StatusNotFound,
})
return
}
w.WriteHeader(http.StatusNoContent)
}
// buildFilamentUpdates converts an UpdateFilamentRequest to a map of column→value.
func buildFilamentUpdates(req dtos.UpdateFilamentRequest) map[string]interface{} {
updates := make(map[string]interface{})
if req.Name != nil {
updates["name"] = *req.Name
}
if req.MaterialBaseID != nil {
updates["material_base_id"] = *req.MaterialBaseID
}
if req.MaterialFinishID != nil {
updates["material_finish_id"] = *req.MaterialFinishID
}
if req.MaterialModifierID != nil {
updates["material_modifier_id"] = *req.MaterialModifierID
}
if req.ColorHex != nil {
updates["color_hex"] = *req.ColorHex
}
if req.Brand != nil {
updates["brand"] = *req.Brand
}
if req.DiameterMM != nil {
updates["diameter_mm"] = *req.DiameterMM
}
if req.InitialGrams != nil {
updates["initial_grams"] = *req.InitialGrams
}
if req.RemainingGrams != nil {
updates["remaining_grams"] = *req.RemainingGrams
}
if req.SpoolWeightGrams != nil {
updates["spool_weight_grams"] = *req.SpoolWeightGrams
}
if req.CostUSD != nil {
updates["cost_usd"] = *req.CostUSD
}
if req.LowStockThresholdGrams != nil {
updates["low_stock_threshold_grams"] = *req.LowStockThresholdGrams
}
if req.Notes != nil {
updates["notes"] = *req.Notes
}
if req.Barcode != nil {
updates["barcode"] = *req.Barcode
}
return updates
}