Files
silo/internal/api/location_handlers.go
Forbes 4ef912cf4b feat: location hierarchy CRUD API
Add LocationRepository with CRUD operations, hierarchy traversal
(children, subtree by path prefix), and inventory-safe deletion.

Endpoints:
  GET    /api/locations          — list all or ?tree={path} for subtree
  POST   /api/locations          — create (auto-resolves parent_id, depth)
  GET    /api/locations/{path..} — get by hierarchical path
  PUT    /api/locations/{path..} — update name, type, metadata
  DELETE /api/locations/{path..} — delete (rejects if inventory exists)

Uses chi wildcard routes to support multi-segment paths like
/api/locations/lab/shelf-a/bin-3.

Includes 10 handler integration tests covering CRUD, nesting,
validation, duplicates, tree queries, and delete-not-found.

Closes #81
2026-02-15 03:15:54 -06:00

235 lines
7.1 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/db"
)
// LocationResponse is the API representation of a location.
type LocationResponse struct {
ID string `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
ParentID *string `json:"parent_id,omitempty"`
LocationType string `json:"location_type"`
Depth int `json:"depth"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
}
// CreateLocationRequest represents a request to create a location.
type CreateLocationRequest struct {
Path string `json:"path"`
Name string `json:"name"`
LocationType string `json:"location_type"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// UpdateLocationRequest represents a request to update a location.
type UpdateLocationRequest struct {
Name string `json:"name"`
LocationType string `json:"location_type"`
Metadata map[string]any `json:"metadata,omitempty"`
}
func locationToResponse(loc *db.Location) LocationResponse {
return LocationResponse{
ID: loc.ID,
Path: loc.Path,
Name: loc.Name,
ParentID: loc.ParentID,
LocationType: loc.LocationType,
Depth: loc.Depth,
Metadata: loc.Metadata,
CreatedAt: loc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
// HandleListLocations lists all locations. If ?tree={path} is set, returns that
// subtree. If ?root=true, returns only root-level locations (depth 0).
func (s *Server) HandleListLocations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
treePath := r.URL.Query().Get("tree")
if treePath != "" {
locs, err := s.locations.GetTree(ctx, treePath)
if err != nil {
s.logger.Error().Err(err).Str("tree", treePath).Msg("failed to get location tree")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get location tree")
return
}
writeJSON(w, http.StatusOK, locationsToResponse(locs))
return
}
locs, err := s.locations.List(ctx)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list locations")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list locations")
return
}
writeJSON(w, http.StatusOK, locationsToResponse(locs))
}
// HandleCreateLocation creates a new location.
func (s *Server) HandleCreateLocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreateLocationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Path is required")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Name is required")
return
}
if req.LocationType == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location type is required")
return
}
// Normalize: trim slashes
req.Path = strings.Trim(req.Path, "/")
loc := &db.Location{
Path: req.Path,
Name: req.Name,
LocationType: req.LocationType,
Metadata: req.Metadata,
}
if loc.Metadata == nil {
loc.Metadata = map[string]any{}
}
if err := s.locations.Create(ctx, loc); err != nil {
if strings.Contains(err.Error(), "parent location") || strings.Contains(err.Error(), "does not exist") {
writeError(w, http.StatusBadRequest, "invalid_parent", err.Error())
return
}
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
writeError(w, http.StatusConflict, "already_exists", "Location path already exists")
return
}
s.logger.Error().Err(err).Str("path", req.Path).Msg("failed to create location")
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
return
}
writeJSON(w, http.StatusCreated, locationToResponse(loc))
}
// HandleGetLocation retrieves a location by path. The path is the rest of the
// URL after /api/locations/, which chi captures as a wildcard.
func (s *Server) HandleGetLocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
path := strings.Trim(chi.URLParam(r, "*"), "/")
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
return
}
loc, err := s.locations.GetByPath(ctx, path)
if err != nil {
s.logger.Error().Err(err).Str("path", path).Msg("failed to get location")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get location")
return
}
if loc == nil {
writeError(w, http.StatusNotFound, "not_found", "Location not found")
return
}
writeJSON(w, http.StatusOK, locationToResponse(loc))
}
// HandleUpdateLocation updates a location by path.
func (s *Server) HandleUpdateLocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
path := strings.Trim(chi.URLParam(r, "*"), "/")
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
return
}
var req UpdateLocationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Name is required")
return
}
if req.LocationType == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location type is required")
return
}
meta := req.Metadata
if meta == nil {
meta = map[string]any{}
}
if err := s.locations.Update(ctx, path, req.Name, req.LocationType, meta); err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "not_found", "Location not found")
return
}
s.logger.Error().Err(err).Str("path", path).Msg("failed to update location")
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
return
}
loc, _ := s.locations.GetByPath(ctx, path)
writeJSON(w, http.StatusOK, locationToResponse(loc))
}
// HandleDeleteLocation deletes a location by path. Rejects if inventory exists.
func (s *Server) HandleDeleteLocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
path := strings.Trim(chi.URLParam(r, "*"), "/")
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
return
}
if err := s.locations.Delete(ctx, path); err != nil {
if strings.Contains(err.Error(), "inventory record") {
writeError(w, http.StatusConflict, "has_inventory", err.Error())
return
}
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "not_found", "Location not found")
return
}
s.logger.Error().Err(err).Str("path", path).Msg("failed to delete location")
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func locationsToResponse(locs []*db.Location) []LocationResponse {
result := make([]LocationResponse, len(locs))
for i, l := range locs {
result[i] = locationToResponse(l)
}
return result
}