package main import ( "crypto/hmac" "encoding/json" "fmt" "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"` 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, } // 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) } } // 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) // 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, 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) < 4 { http.Error(w, "invalid route ID", http.StatusBadRequest) return } // 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 { // Check for admin API key in header expectedAPIKey := os.Getenv("TRIP_PLANNER_ADMIN_API_KEY") if expectedAPIKey == "" { // Admin API key must be configured http.Error(w, "unauthorized: admin API key not configured", http.StatusUnauthorized) return false } providedAPIKey := r.Header.Get("X-Admin-Api-Key") if !hmac.Equal([]byte(providedAPIKey), []byte(expectedAPIKey)) { http.Error(w, "unauthorized: invalid admin API key", 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" } cities, err := hc.Preferences.GetSavedCities(r.Context(), userID) if err != nil { http.Error(w, "failed to get saved cities: "+err.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" } 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 { http.Error(w, "failed to add saved city: "+err.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" } 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 { http.Error(w, "failed to remove saved city: "+err.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" } history, err := hc.Preferences.GetSearchHistory(r.Context(), userID) if err != nil { http.Error(w, "failed to get search history: "+err.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" } 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 { http.Error(w, "failed to add search history: "+err.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(), } }