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
235 lines
7.1 KiB
Go
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
|
|
}
|