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 }