CUB-113: implement core CRUD API endpoints
Some checks failed
Dev Build / build-test (pull_request) Failing after 2m4s

- 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
This commit is contained in:
2026-05-06 14:24:58 -04:00
parent 1b86d617cd
commit fca2ef5b84
19 changed files with 1496 additions and 3 deletions

View File

@@ -0,0 +1,273 @@
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
}

View File

@@ -0,0 +1,51 @@
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/services"
)
// writeJSON serializes v as JSON to the response writer with the given status code.
// Logs an error if encoding fails.
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("failed to encode JSON response", "error", err)
}
}
// parsePagination reads limit and offset query parameters with defaults of 20 and 0.
func parsePagination(r *http.Request) (limit, offset int) {
limit = 20
offset = 0
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
return
}
// ValidateCreateFilamentRequest validates a CreateFilamentRequest DTO.
// Re-exports the service-layer validator for handler use.
func ValidateCreateFilamentRequest(req dtos.CreateFilamentRequest) error {
return services.ValidateCreateFilamentRequest(req)
}
// ValidateUpdateFilamentRequest validates an UpdateFilamentRequest DTO.
// Re-exports the service-layer validator for handler use.
func ValidateUpdateFilamentRequest(req dtos.UpdateFilamentRequest) error {
return services.ValidateUpdateFilamentRequest(req)
}

View File

@@ -0,0 +1,34 @@
package handlers
import (
"log/slog"
"net/http"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/dtos"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories"
)
// MaterialHandler handles requests for material lookup data.
type MaterialHandler struct {
repo *repositories.MaterialRepository
}
// NewMaterialHandler creates a MaterialHandler with the given repository.
func NewMaterialHandler(repo *repositories.MaterialRepository) *MaterialHandler {
return &MaterialHandler{repo: repo}
}
// List handles GET /api/materials — returns all material bases.
func (h *MaterialHandler) List(w http.ResponseWriter, r *http.Request) {
materials, err := h.repo.GetAll(r.Context())
if err != nil {
slog.Error("failed to list materials", "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
writeJSON(w, http.StatusOK, dtos.SingleResponse{Data: materials})
}

View File

@@ -0,0 +1,60 @@
package handlers
import (
"log/slog"
"net/http"
"strconv"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/dtos"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/services"
)
// PrintJobHandler handles HTTP requests for print job operations.
type PrintJobHandler struct {
service *services.PrintJobService
}
// NewPrintJobHandler creates a PrintJobHandler with the given service.
func NewPrintJobHandler(service *services.PrintJobService) *PrintJobHandler {
return &PrintJobHandler{service: service}
}
// List handles GET /api/print-jobs — returns paginated, filtered print jobs.
func (h *PrintJobHandler) List(w http.ResponseWriter, r *http.Request) {
limit, offset := parsePagination(r)
filter := repositories.PrintJobFilter{
Status: r.URL.Query().Get("status"),
Limit: limit,
Offset: offset,
}
if pidStr := r.URL.Query().Get("printer_id"); pidStr != "" {
pid, err := strconv.Atoi(pidStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "invalid printer_id",
Code: http.StatusBadRequest,
})
return
}
filter.PrinterID = &pid
}
jobs, total, err := h.service.List(r.Context(), filter)
if err != nil {
slog.Error("failed to list print jobs", "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
writeJSON(w, http.StatusOK, dtos.ListResponse{
Data: jobs,
Total: total,
Limit: limit,
Offset: offset,
})
}

View File

@@ -0,0 +1,34 @@
package handlers
import (
"log/slog"
"net/http"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/dtos"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/services"
)
// PrinterHandler handles HTTP requests for printer listings.
type PrinterHandler struct {
service *services.PrinterService
}
// NewPrinterHandler creates a PrinterHandler with the given service.
func NewPrinterHandler(service *services.PrinterService) *PrinterHandler {
return &PrinterHandler{service: service}
}
// List handles GET /api/printers — returns all printers with printer_type info.
func (h *PrinterHandler) List(w http.ResponseWriter, r *http.Request) {
printers, err := h.service.List(r.Context())
if err != nil {
slog.Error("failed to list printers", "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
writeJSON(w, http.StatusOK, dtos.SingleResponse{Data: printers})
}

View File

@@ -0,0 +1,70 @@
package handlers
import (
"log/slog"
"net/http"
"strconv"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/dtos"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories"
)
// UsageLogHandler handles HTTP requests for usage log operations.
type UsageLogHandler struct {
repo *repositories.UsageLogRepository
}
// NewUsageLogHandler creates a UsageLogHandler with the given repository.
func NewUsageLogHandler(repo *repositories.UsageLogRepository) *UsageLogHandler {
return &UsageLogHandler{repo: repo}
}
// List handles GET /api/usage-logs — returns paginated, filtered usage logs.
func (h *UsageLogHandler) List(w http.ResponseWriter, r *http.Request) {
limit, offset := parsePagination(r)
filter := repositories.UsageLogFilter{
Limit: limit,
Offset: offset,
}
if sidStr := r.URL.Query().Get("spool_id"); sidStr != "" {
sid, err := strconv.Atoi(sidStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "invalid spool_id",
Code: http.StatusBadRequest,
})
return
}
filter.SpoolID = &sid
}
if jidStr := r.URL.Query().Get("job_id"); jidStr != "" {
jid, err := strconv.Atoi(jidStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, dtos.ErrorResponse{
Error: "invalid job_id",
Code: http.StatusBadRequest,
})
return
}
filter.JobID = &jid
}
logs, total, err := h.repo.GetAll(r.Context(), filter)
if err != nil {
slog.Error("failed to list usage logs", "error", err)
writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{
Error: "internal server error",
Code: http.StatusInternalServerError,
})
return
}
writeJSON(w, http.StatusOK, dtos.ListResponse{
Data: logs,
Total: total,
Limit: limit,
Offset: offset,
})
}