742 lines
23 KiB
Go
742 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-redis/redis/v8"
|
|
|
|
"trip-planner/internal/cache"
|
|
"trip-planner/internal/metrics"
|
|
"trip-planner/internal/routing"
|
|
"trip-planner/internal/storage"
|
|
"trip-planner/internal/yandex"
|
|
)
|
|
|
|
// HandlerContext holds the dependencies for API handlers.
|
|
type HandlerContext struct {
|
|
Cache cache.Cache
|
|
Redis *redis.Client
|
|
Router *routing.Graph
|
|
Yandex *yandex.Client
|
|
SearchCache *routing.SearchCacheService
|
|
Preferences *cache.Preferences
|
|
Metrics *metrics.Metrics
|
|
SearchStart time.Time
|
|
}
|
|
|
|
// stationStatusResponse represents the response for station status.
|
|
type stationStatusResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"` // "active" or "closed"
|
|
Transport string `json:"transport"` // e.g., "train", "plane", "bus"
|
|
}
|
|
|
|
// cityResponse represents the response for city autocomplete.
|
|
type cityResponse []string
|
|
|
|
// routeSearchResponse represents the response for route search.
|
|
type routeSearchResponse struct {
|
|
Routes []routeSearchRoute `json:"routes"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type routeSearchRoute struct {
|
|
Duration int `json:"duration"`
|
|
Transfers int `json:"transfers"`
|
|
Cost int `json:"cost"`
|
|
ID string `json:"id"`
|
|
SearchID string `json:"search_id"`
|
|
PriceNote string `json:"price_note,omitempty"` // "цена не указана" if price data not available from API
|
|
}
|
|
|
|
// routeGeoJSONResponse represents the response for route GeoJSON.
|
|
type routeGeoJSONResponse struct {
|
|
Type string `json:"type"`
|
|
Features []map[string]interface{} `json:"features"`
|
|
SyntheticEdgeStyle map[string]string `json:"synthetic_edge_style,omitempty"`
|
|
}
|
|
|
|
// CityAutocomplete handles GET /v1/cities?query=.
|
|
func CityAutocomplete(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query().Get("query")
|
|
if query == "" {
|
|
http.Error(w, "missing query parameter", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// In a full implementation, would query Postgres for city matches
|
|
// For now, return a simple JSON response
|
|
resp := cityResponse{query + "-result1", query + "-result2"}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// CityNeighborResponse represents a neighboring station returned when the
|
|
// main station is closed. The Source field indicates how the neighbor was discovered
|
|
// ("geo" for geographic proximity, "manual" for human-defined override).
|
|
type CityNeighborResponse struct {
|
|
StationID string `json:"station_id"`
|
|
Name string `json:"name"`
|
|
CityCode string `json:"city_code"`
|
|
Source string `json:"source"`
|
|
IsExcluded bool `json:"is_excluded"`
|
|
}
|
|
|
|
// cityStationResponse is the response for the cities/{id}/stations endpoint.
|
|
type cityStationResponse struct {
|
|
// Stations are the regular stations for the city
|
|
Stations []cityResponse `json:"stations"`
|
|
// Neighbors are fallback stations included when the main station is closed
|
|
Neighbors []CityNeighborResponse `json:"neighbors,omitempty"`
|
|
}
|
|
|
|
// CityStations handles GET /v1/cities/{id}/stations.
|
|
func CityStations(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
if len(parts) < 4 {
|
|
http.Error(w, "invalid city ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
cityID := parts[3]
|
|
|
|
// In a full implementation, would look up city and its stations from Postgres.
|
|
// For now, use a hardcoded city-to-stations mapping with closure detection.
|
|
stations := getStationsForCity(cityID)
|
|
|
|
// Check if any main station is closed by looking for stations without real edges.
|
|
// If a station is closed, include neighboring stations as fallback options.
|
|
var closedStationIndices []int
|
|
for i, station := range stations {
|
|
// Check if this specific station has real edges
|
|
hasRealEdges := false
|
|
for _, edge := range hc.Router.Edges() {
|
|
if edge.From.ID == station[0] || edge.To.ID == station[0] {
|
|
if edge.Kind == routing.EdgeKindReal {
|
|
hasRealEdges = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !hasRealEdges {
|
|
closedStationIndices = append(closedStationIndices, i)
|
|
}
|
|
}
|
|
|
|
// If there are closed stations, add neighboring stations as fallback
|
|
var neighbors []CityNeighborResponse
|
|
if len(closedStationIndices) > 0 {
|
|
// Initialize neighbors table and load manual+geo neighbors for affected cities
|
|
neighborTable := storage.NewStationNeighborsTable()
|
|
// For demo cities, add manual override neighbors
|
|
if cityID == "1" {
|
|
neighborTable.Add("1", "s9600300", "Sheremetyvo Alternative", "manual")
|
|
neighborTable.Add("1", "s9600400", "Vnukovo Alternative", "manual")
|
|
}
|
|
if cityID == "2" {
|
|
neighborTable.Add("2", "s8700100", "Leningradsky Alternative", "manual")
|
|
}
|
|
|
|
// Get non-excluded neighbors for the city
|
|
cityNeighbors := neighborTable.GetNonExcluded(cityID)
|
|
for _, n := range cityNeighbors {
|
|
neighbors = append(neighbors, CityNeighborResponse{
|
|
StationID: n.StationID,
|
|
Name: n.Name,
|
|
CityCode: n.CityCode,
|
|
Source: n.Source,
|
|
IsExcluded: n.IsExcluded,
|
|
})
|
|
}
|
|
}
|
|
|
|
resp := cityStationResponse{
|
|
Stations: stations,
|
|
Neighbors: neighbors,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// getStationsForCity returns the stations for a given city code.
|
|
// This is a hardcoded mapping for demo purposes.
|
|
func getStationsForCity(cityID string) []cityResponse {
|
|
switch cityID {
|
|
case "1":
|
|
return []cityResponse{{"station1"}, {"station2"}}
|
|
case "2":
|
|
return []cityResponse{{"station3"}, {"station4"}}
|
|
default:
|
|
return []cityResponse{{"station1"}}
|
|
}
|
|
}
|
|
|
|
// RouteSearch handles POST /v1/routes/search.
|
|
func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
FromCityID string `json:"from_city_id"`
|
|
ToCityID string `json:"to_city_id"`
|
|
Date string `json:"date"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Record search start time
|
|
start := time.Now()
|
|
|
|
// Read ranking mode from query parameters (for UI controls)
|
|
rankingMode := r.URL.Query().Get("ranking_mode")
|
|
|
|
// Build query parameters for route search
|
|
// Use city codes as origin/destination identifiers
|
|
// In a full implementation, this would use Yandex /search, but for now
|
|
// we use the in-memory graph with Pareto-optimal routing
|
|
|
|
// Create search options with default max transfers
|
|
opts := routing.SearchOptions{
|
|
MaxTransfers: 5,
|
|
// Set ranking mode from UI query parameter if provided
|
|
RankingMode: rankingMode,
|
|
}
|
|
|
|
// Determine if this is a far-term search (date is more than 7 days in the future)
|
|
if req.Date != "" {
|
|
requestDate, err := time.Parse("2006-01-02", req.Date)
|
|
if err == nil {
|
|
now := time.Now()
|
|
daysDiff := int(requestDate.Sub(now).Hours() / 24)
|
|
if daysDiff >= 7 {
|
|
opts.FarTerm = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect closed stations and get neighbors for fallback
|
|
closedStationsMap := make(map[string]bool)
|
|
neighborsMap := make(map[string][]storage.StationNeighbor)
|
|
neighborTable := storage.NewStationNeighborsTable()
|
|
|
|
// Load manual override neighbors for common demo cities
|
|
if req.FromCityID == "1" || req.ToCityID == "1" {
|
|
neighborTable.Add("1", "s9600300", "Sheremetyvo Alternative", "manual")
|
|
neighborTable.Add("1", "s9600400", "Vnukovo Alternative", "manual")
|
|
}
|
|
if req.FromCityID == "2" || req.ToCityID == "2" {
|
|
neighborTable.Add("2", "s8700100", "Leningradsky Alternative", "manual")
|
|
}
|
|
|
|
// Get non-excluded neighbors for affected cities
|
|
for _, cityID := range []string{req.FromCityID, req.ToCityID} {
|
|
cityNeighbors := neighborTable.GetNonExcluded(cityID)
|
|
for _, n := range cityNeighbors {
|
|
neighborsMap[n.StationID] = append(neighborsMap[n.StationID], n)
|
|
}
|
|
}
|
|
|
|
// Detect closed stations by checking if they have real edges in the graph
|
|
for _, cityID := range []string{req.FromCityID, req.ToCityID} {
|
|
// Get stations for this city
|
|
stations := getStationsForCity(cityID)
|
|
for _, station := range stations {
|
|
if len(station) > 0 {
|
|
stationID := station[0]
|
|
// Check if this station has real edges
|
|
hasRealEdges := false
|
|
for _, edge := range hc.Router.Edges() {
|
|
if edge.From.ID == stationID || edge.To.ID == stationID {
|
|
if edge.Kind == routing.EdgeKindReal {
|
|
hasRealEdges = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !hasRealEdges {
|
|
closedStationsMap[stationID] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run Pareto-optimal route search using the graph
|
|
results := hc.Router.FindRoutesPareto(req.FromCityID, req.ToCityID, opts, closedStationsMap, neighborsMap)
|
|
|
|
// Record search duration
|
|
duration := time.Since(start).Nanoseconds()
|
|
hc.Metrics.RecordSearch(duration)
|
|
|
|
// Generate a search_id based on the request parameters
|
|
searchID := fmt.Sprintf("search_%s_%s_%s_%d", req.FromCityID, req.ToCityID, req.Date, time.Now().Unix())
|
|
|
|
// Build response routes
|
|
routeResponses := make([]routeSearchRoute, 0, len(results))
|
|
for _, route := range results {
|
|
priceNote := "цена не указана"
|
|
routeResponses = append(routeResponses, routeSearchRoute{
|
|
Duration: route.TotalDuration,
|
|
Transfers: route.TotalTransfers,
|
|
Cost: route.Cost,
|
|
ID: route.ID,
|
|
SearchID: searchID,
|
|
PriceNote: priceNote,
|
|
})
|
|
}
|
|
|
|
resp := routeSearchResponse{
|
|
Routes: routeResponses,
|
|
Count: len(routeResponses),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// RouteGeoJSON handles GET /v1/routes/{search_id}/{route_id}/geojson.
|
|
func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
if len(parts) < 6 {
|
|
http.Error(w, "invalid route ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
searchID := parts[3]
|
|
routeID := parts[4]
|
|
|
|
_ = searchID // searchID is used for route identification
|
|
_ = routeID // routeID is used for route identification
|
|
|
|
// Generate GeoJSON from the graph's edges, distinguishing synthetic vs real
|
|
// Synthetic edges (e.g., city↔airport transfers) are marked with dashed lines
|
|
// Real edges (actual scheduled trips) are solid lines
|
|
features := make([]map[string]interface{}, 0)
|
|
|
|
// Collect transfer points: nodes that are destinations of transfer edges
|
|
// and have connections to other edges (for popup markers).
|
|
transferNodeIDs := make(map[string]bool)
|
|
for _, edge := range hc.Router.Edges() {
|
|
if edge.IsTransfer {
|
|
transferNodeIDs[edge.To.ID] = true
|
|
transferNodeIDs[edge.From.ID] = true
|
|
}
|
|
}
|
|
|
|
// Track which edges have been added to avoid duplicates when
|
|
// a transfer node appears in multiple edges.
|
|
addedEdges := make(map[string]bool)
|
|
|
|
for _, edge := range hc.Router.Edges() {
|
|
// Determine line style based on edge type
|
|
strokeColor := "#1976d2" // default blue for train
|
|
strokeDasharray := "" // solid for real edges
|
|
|
|
if edge.Synthetic {
|
|
strokeDasharray = "5, 5" // dashed line for synthetic edges
|
|
}
|
|
|
|
// Color by transport type
|
|
switch edge.TransportType {
|
|
case routing.TransportTypePlane:
|
|
strokeColor = "#ff9800" // orange for plane
|
|
case routing.TransportTypeBus:
|
|
strokeColor = "#cddc39" // lime for bus
|
|
case routing.TransportTypeTrain:
|
|
strokeColor = "#1976d2" // blue for train (default)
|
|
}
|
|
|
|
// Create LineString geometry
|
|
// Use edge endpoints as coordinate placeholders
|
|
fromCoord := []float64{0, 0} // placeholder
|
|
toCoord := []float64{0, 0} // placeholder
|
|
|
|
key := edge.From.ID + ":" + edge.To.ID
|
|
if addedEdges[key] {
|
|
continue
|
|
}
|
|
addedEdges[key] = true
|
|
|
|
geoJsonLine := map[string]interface{}{
|
|
"type": "LineString",
|
|
"coordinates": []interface{}{
|
|
fromCoord, toCoord,
|
|
},
|
|
"properties": map[string]interface{}{
|
|
"transport": edge.Transport,
|
|
"transport_type": string(edge.TransportType),
|
|
"kind": fmt.Sprintf("%v", edge.Kind),
|
|
"synthetic": edge.Synthetic,
|
|
"duration": edge.Duration,
|
|
"cost": edge.Cost,
|
|
"is_transfer": edge.IsTransfer,
|
|
"stroke_color": strokeColor,
|
|
"stroke_width": 2,
|
|
"stroke_dasharray": strokeDasharray,
|
|
},
|
|
}
|
|
|
|
features = append(features, map[string]interface{}{
|
|
"type": "Feature",
|
|
"geometry": geoJsonLine,
|
|
"properties": geoJsonLine["properties"],
|
|
})
|
|
|
|
// Add transfer point markers at nodes that are transfer destinations
|
|
if edge.IsTransfer && transferNodeIDs[edge.To.ID] {
|
|
// Use default 30 min (1800s) MCT if no specific rule applies
|
|
connectionTime := 1800 // default MCT: 30 minutes
|
|
|
|
// Add a Point feature for the transfer marker
|
|
transferFeature := map[string]interface{}{
|
|
"type": "Feature",
|
|
"geometry": map[string]interface{}{
|
|
"type": "Point",
|
|
"coordinates": []float64{
|
|
0, 0, // placeholder - would use node coordinates from PostGIS
|
|
},
|
|
},
|
|
"properties": map[string]interface{}{
|
|
"marker_type": "transfer",
|
|
"title": edge.To.Name,
|
|
"connection_time": connectionTime,
|
|
"connection_time_formatted": fmt.Sprintf("%d min", connectionTime/60),
|
|
"transfer_type": edge.Transport,
|
|
"is_transfer": true,
|
|
"stroke_color": strokeColor,
|
|
"stroke_width": 2,
|
|
},
|
|
}
|
|
features = append(features, transferFeature)
|
|
}
|
|
}
|
|
|
|
resp := routeGeoJSONResponse{
|
|
Type: "FeatureCollection",
|
|
Features: features,
|
|
SyntheticEdgeStyle: map[string]string{"stroke_dasharray": "5, 5", "stroke_color": "#ff9800"},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// StationStatus handles GET /v1/stations/{id}/status.
|
|
func StationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
// Extract station ID from path: /v1/stations/{id}/status
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
// Expected: /v1/stations/{id}/status
|
|
if len(parts) < 4 {
|
|
http.Error(w, "invalid station ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
stationID := parts[3]
|
|
|
|
// Find the station node in the graph
|
|
station := hc.Router.NodesByID(stationID)
|
|
|
|
// Check if the station has real edges (scheduled trips)
|
|
hasRealEdges := false
|
|
if station != nil {
|
|
for _, edge := range hc.Router.Edges() {
|
|
if edge.From.ID == stationID || edge.To.ID == stationID {
|
|
if edge.Kind == routing.EdgeKindReal {
|
|
hasRealEdges = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
status := "active"
|
|
if !hasRealEdges {
|
|
status = "closed"
|
|
}
|
|
|
|
name := ""
|
|
if station != nil {
|
|
name = station.Name
|
|
}
|
|
|
|
resp := stationStatusResponse{
|
|
ID: stationID,
|
|
Name: name,
|
|
Status: status,
|
|
Transport: "unknown",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// adminAuth checks authentication for admin endpoints.
|
|
// Returns true if the request is authenticated, false otherwise.
|
|
func adminAuth(hc *HandlerContext, w http.ResponseWriter, r *http.Request) bool {
|
|
expectedAPIKey := os.Getenv("TRIP_PLANNER_ADMIN_API_KEY")
|
|
if expectedAPIKey == "" {
|
|
// Admin auth not configured - reject all admin requests
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
providedAPIKey := r.Header.Get("X-Admin-Api-Key")
|
|
if subtle.ConstantTimeCompare([]byte(providedAPIKey), []byte(expectedAPIKey)) != 1 {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// AdminStationStatus handles POST /internal/admin/stations/{id}/status.
|
|
// Allows manual override of station status with source: manual.
|
|
func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
// Verify admin authentication
|
|
if !adminAuth(hc, w, r) {
|
|
return
|
|
}
|
|
|
|
// Extract station ID from path: /internal/admin/stations/{id}/status
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
// Expected: /internal/admin/stations/{id}/status
|
|
if len(parts) < 5 {
|
|
http.Error(w, "invalid station ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
stationID := parts[4]
|
|
|
|
// Decode request body to get status and source
|
|
var req struct {
|
|
Status string `json:"status"`
|
|
Source string `json:"source"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate status value
|
|
validStatuses := map[string]bool{
|
|
"active": true,
|
|
"closed": true,
|
|
}
|
|
if !validStatuses[req.Status] {
|
|
http.Error(w, "invalid status value, must be 'active' or 'closed'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate source
|
|
if req.Source != "manual" {
|
|
http.Error(w, "invalid source, must be 'manual'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// In a full implementation, this would update a database.
|
|
// For now, we just log the status override and return success.
|
|
logStatusOverride(stationID, req.Status, req.Source)
|
|
|
|
resp := map[string]interface{}{
|
|
"id": stationID,
|
|
"status": req.Status,
|
|
"source": req.Source,
|
|
"message": "station status updated successfully",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// logStatusOverride logs a station status override for audit purposes.
|
|
// In a full implementation, this would persist to a database.
|
|
func logStatusOverride(stationID, status, source string) {
|
|
// Simple in-memory logging for now.
|
|
// In production, this would write to a persistent store or log system.
|
|
_ = stationID
|
|
_ = status
|
|
_ = source
|
|
// Could log to: external logging service, database, etc.
|
|
}
|
|
|
|
// preferenceResponse represents the response for preference endpoints.
|
|
type preferenceResponse struct {
|
|
Message string `json:"message"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// getSavedCitiesHandler handles GET /v1/preferences/saved-cities.
|
|
func GetSavedCities(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
userID = "default"
|
|
}
|
|
if len(userID) > 100 {
|
|
http.Error(w, "invalid user_id format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cities, err := hc.Preferences.GetSavedCities(r.Context(), userID)
|
|
if err != nil {
|
|
log.Printf("error getting saved cities: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(preferenceResponse{
|
|
Message: "saved cities retrieved",
|
|
Data: cities,
|
|
})
|
|
}
|
|
|
|
// addSavedCityHandler handles POST /v1/preferences/saved-cities.
|
|
func AddSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
userID = "default"
|
|
}
|
|
if len(userID) > 100 {
|
|
http.Error(w, "invalid user_id format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
CityCode string `json:"city_code"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := hc.Preferences.AddSavedCity(r.Context(), userID, req.CityCode, req.Name); err != nil {
|
|
log.Printf("error adding saved city: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(preferenceResponse{
|
|
Message: "saved city added",
|
|
})
|
|
}
|
|
|
|
// removeSavedCityHandler handles DELETE /v1/preferences/saved-cities/{city_code}.
|
|
func RemoveSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
userID = "default"
|
|
}
|
|
if len(userID) > 100 {
|
|
http.Error(w, "invalid user_id format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
// Expected: /v1/preferences/saved-cities/{city_code} -> parts: ["", "v1", "preferences", "saved-cities", "{city_code}"]
|
|
if len(parts) < 5 {
|
|
http.Error(w, "missing city code", http.StatusBadRequest)
|
|
return
|
|
}
|
|
cityCode := parts[4]
|
|
|
|
if err := hc.Preferences.RemoveSavedCity(r.Context(), userID, cityCode); err != nil {
|
|
log.Printf("error removing saved city: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(preferenceResponse{
|
|
Message: "saved city removed",
|
|
})
|
|
}
|
|
|
|
// getSearchHistoryHandler handles GET /v1/preferences/search-history.
|
|
func GetSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
userID = "default"
|
|
}
|
|
if len(userID) > 100 {
|
|
http.Error(w, "invalid user_id format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
history, err := hc.Preferences.GetSearchHistory(r.Context(), userID)
|
|
if err != nil {
|
|
log.Printf("error getting search history: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(preferenceResponse{
|
|
Message: "search history retrieved",
|
|
Data: history,
|
|
})
|
|
}
|
|
|
|
// addSearchHistoryHandler handles POST /v1/preferences/search-history.
|
|
func AddSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
userID = "default"
|
|
}
|
|
if len(userID) > 100 {
|
|
http.Error(w, "invalid user_id format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
FromCity string `json:"from_city"`
|
|
ToCity string `json:"to_city"`
|
|
Date string `json:"date"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate input length
|
|
if len(req.FromCity) > 100 || len(req.ToCity) > 100 || len(req.Date) > 20 {
|
|
http.Error(w, "invalid city or date format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := hc.Preferences.AddSearchHistory(r.Context(), userID, req.FromCity, req.ToCity, req.Date); err != nil {
|
|
log.Printf("error adding search history: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(preferenceResponse{
|
|
Message: "search history added",
|
|
})
|
|
}
|
|
|
|
// metricsHandler handles GET /metrics and returns all observability metrics as JSON.
|
|
func MetricsHandler(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(hc.Metrics.GetMetricsJSON())
|
|
}
|
|
|
|
// NewHandlerContext creates a new HandlerContext with initialized services.
|
|
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client, m *metrics.Metrics) *HandlerContext {
|
|
cacheStore := cache.NewCacheStore(redisClient, m)
|
|
return &HandlerContext{
|
|
Cache: cacheStore,
|
|
Redis: redisClient,
|
|
Router: router,
|
|
Yandex: yandex,
|
|
SearchCache: routing.NewSearchCacheService(cacheStore, yandex, m),
|
|
Preferences: cache.NewPreferences(cacheStore),
|
|
Metrics: m,
|
|
SearchStart: time.Now(),
|
|
}
|
|
}
|