CUB-113: implement core CRUD API endpoints
Some checks failed
Dev Build / build-test (pull_request) Failing after 2m4s
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:
273
backend/internal/handlers/filament_handler.go
Normal file
273
backend/internal/handlers/filament_handler.go
Normal 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
|
||||
}
|
||||
51
backend/internal/handlers/helpers.go
Normal file
51
backend/internal/handlers/helpers.go
Normal 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)
|
||||
}
|
||||
34
backend/internal/handlers/material_handler.go
Normal file
34
backend/internal/handlers/material_handler.go
Normal 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})
|
||||
}
|
||||
60
backend/internal/handlers/print_job_handler.go
Normal file
60
backend/internal/handlers/print_job_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
34
backend/internal/handlers/printer_handler.go
Normal file
34
backend/internal/handlers/printer_handler.go
Normal 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})
|
||||
}
|
||||
70
backend/internal/handlers/usage_log_handler.go
Normal file
70
backend/internal/handlers/usage_log_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user