diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index a595e75..460c795 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -15,473 +15,21 @@ import ( // HandlerContext holds the dependencies for API handlers. type HandlerContext struct { - Cache cache.Cache - Redis *redis.Client - Router *routing.Graph - Yandex *yandex.Client + Cache cache.Cache + Redis *redis.Client + Router *routing.Graph + Yandex *yandex.Client + SearchCache *routing.SearchCacheService } // NewHandlerContext creates a new HandlerContext with initialized services. func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext { + cacheStore := cache.NewCacheStore(redisClient) return &HandlerContext{ - Cache: cache.NewCacheStore(redisClient), - Redis: redisClient, - Router: router, - Yandex: yandex, + Cache: cacheStore, + Redis: redisClient, + Router: router, + Yandex: yandex, + SearchCache: routing.NewSearchCacheService(cache.NewCacheAside(cacheStore), yandex), } } - -// cityResponse represents a city in the autocomplete response. -type cityResponse struct { - Code string `json:"code"` - Name string `json:"name"` -} - -// CitiesQuery represents the query parameters for city autocomplete. -type CitiesQuery struct { - Query string `json:"query"` -} - -// CityAutocomplete handles GET /v1/cities?query= -// Returns matching cities from cache/directory. -func CityAutocomplete(h *HandlerContext, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("query") - if query == "" { - http.Error(w, "query parameter is required", http.StatusBadRequest) - return - } - - // Try to get cities from cache first - ctx := r.Context() - cacheKey := cache.GetCityKey(query) - - data, err := h.Cache.Get(ctx, cacheKey) - if err == nil && data != nil { - // Return cached city data - parse from bytes - cityCode := string(data) - // Use the city code as code; name would come from directory lookup - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]cityResponse{ - {Code: cityCode, Name: cityCode}, - }) - return - } - - // Cache miss - in full implementation would query Postgres directory - // For now, return empty list and populate cache for future requests - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]cityResponse{}) -} - -// cityStationsResponse represents a station in the response. -type cityStationsResponse struct { - ID string `json:"id"` - Name string `json:"name"` - CityCode string `json:"city_code"` -} - -// CityStations handles GET /v1/cities/{id}/stations -// Returns list of stations for a city, including neighbors if main station is closed. -func CityStations(h *HandlerContext, w http.ResponseWriter, r *http.Request) { - // Parse city ID from path: /v1/cities/{id}/stations - path := r.URL.Path - // Expected format: /v1/cities/{id}/stations - parts := splitPath(path) - if len(parts) < 4 || parts[1] != "cities" { - http.Error(w, "city ID is required", http.StatusBadRequest) - return - } - cityID := parts[3] - - if cityID == "" { - http.Error(w, "city ID is required", http.StatusBadRequest) - return - } - - // Check cache for stations in this city - ctx := r.Context() - // Use a station city key distinct from the city autocomplete key - cacheKey := &cache.CacheKey{ - Kind: "city_stations", - Code: cityID, - } - - data, err := h.Cache.Get(ctx, cacheKey) - if err != nil { - http.Error(w, "failed to query cache", http.StatusInternalServerError) - return - } - - if data == nil { - // Cache miss - try to get stations from Yandex API - if h.Yandex != nil { - scheduleResp, err := h.Yandex.Do(ctx, "GET", "/v1/stations_list", map[string]string{ - "city_code": cityID, - }) - if err == nil && scheduleResp != nil && len(scheduleResp.Segments) > 0 { - // Build station list from Yandex response segments - stations := make([]cityStationsResponse, len(scheduleResp.Segments)) - for i, seg := range scheduleResp.Segments { - stations[i] = cityStationsResponse{ - ID: seg.From.Code, - Name: seg.From.Title, - CityCode: cityID, - } - } - // Store in cache for future requests (serialize simply) - stationsJSON := formatStationsForCache(stations) - if err := h.Cache.Set(ctx, cacheKey, []byte(stationsJSON), 24*time.Hour); err != nil { - log.Printf("WARNING: failed to cache stations for city %s: %v", cityID, err) - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stations) - return - } - } - // If Yandex API fails or has no data, return empty list - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]cityStationsResponse{}) - return - } - - // Parse stored station data from cache - // For now, return what we have from cache - // In full implementation, would parse []cache.StationInfo - var stations []cityStationsResponse - if err := json.Unmarshal(data, &stations); err != nil { - // If cache data is stale format, clear and return empty - h.Cache.Delete(ctx, cacheKey) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]cityStationsResponse{}) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stations) -} - -// routeSearchRequest represents the request body for route search. -type routeSearchRequest struct { - FromCityID string `json:"from_city_id"` - ToCityID string `json:"to_city_id"` - Date string `json:"date"` -} - -// routeLegSummary represents a summarized route leg for the response. -type routeLegSummary struct { - From string `json:"from"` - To string `json:"to"` - Duration int `json:"duration"` - Transport string `json:"transport"` - IsTransfer bool `json:"is_transfer"` -} - -// routeSearchResponse represents the response for route search. -type routeSearchResponse struct { - Routes []routeLegSummary `json:"routes"` - Count int `json:"count"` -} - -// RouteSearch handles POST /v1/routes/search -// Searches for routes between cities with Pareto-optimal results (time, transfers). -func RouteSearch(h *HandlerContext, w http.ResponseWriter, r *http.Request) { - var req routeSearchRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } - - if req.FromCityID == "" || req.ToCityID == "" || req.Date == "" { - http.Error(w, "from_city_id, to_city_id, and date are required", http.StatusBadRequest) - return - } - - // Ensure routing graph is built with station data for this city pair - // Build graph from Yandex API if nodes are not already in the graph - if h.Router.NodesByID(req.FromCityID) == nil || h.Router.NodesByID(req.ToCityID) == nil { - // Graph missing nodes - build from Yandex API - if h.Yandex != nil { - scheduleResp, err := h.Yandex.Do(r.Context(), "GET", "/v1/search", map[string]string{ - "from_city": req.FromCityID, - "to_city": req.ToCityID, - "date": req.Date, - }) - if err == nil && scheduleResp != nil { - // Build routing graph from Yandex search results - buildGraphFromYandexSchedule(scheduleResp, h.Router) - } - } - // If graph still missing nodes after Yandex attempt, proceed with empty graph - // and return helpful error rather than silently returning no routes - if h.Router.NodesByID(req.FromCityID) == nil || h.Router.NodesByID(req.ToCityID) == nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(routeSearchResponse{ - Routes: []routeLegSummary{}, - Count: 0, - }) - return - } - } - - // Search with max 1 transfer using Pareto-optimal algorithm - opts := routing.SearchOptions{ - MaxTransfers: 1, - MCT: 300, // 5 minutes default MCT - } - - // Use FindRoutesPareto to find multiple optimal routes (time, transfers, cost) - result := h.Router.FindRoutesPareto(req.FromCityID, req.ToCityID, opts) - - if result == nil || len(result) == 0 { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(routeSearchResponse{ - Routes: []routeLegSummary{}, - Count: 0, - }) - return - } - - // Build route leg summaries for all Pareto-optimal routes - var legs []routeLegSummary - for _, itinerary := range result { - for _, leg := range itinerary.Legs { - legs = append(legs, routeLegSummary{ - From: leg.From.Name, - To: leg.To.Name, - Duration: leg.Duration, - Transport: leg.Transport, - IsTransfer: leg.IsTransfer, - }) - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(routeSearchResponse{ - Routes: legs, - Count: len(result), - }) -} - -// routeGeoJSONResponse represents the GeoJSON geometry response. -type routeGeoJSONResponse struct { - SearchID string `json:"search_id"` - RouteID string `json:"route_id"` - GeoJSON any `json:"geojson"` -} - -// RouteGeoJSON handles GET /v1/routes/{search_id}/{route_id}/geojson -// Returns GeoJSON geometry for a specific route. -func RouteGeoJSON(h *HandlerContext, w http.ResponseWriter, r *http.Request) { - // Parse path: /v1/routes/{search_id}/{route_id}/geojson - path := r.URL.Path - parts := splitPath(path) - if len(parts) < 5 || parts[1] != "routes" { - http.Error(w, "search_id and route_id are required", http.StatusBadRequest) - return - } - searchID := parts[2] - routeID := parts[3] - - if searchID == "" || routeID == "" { - http.Error(w, "search_id and route_id are required", http.StatusBadRequest) - return - } - - // Build GeoJSON for the route using route legs - // Search for the route in the router's stored routes - var geojson map[string]any - - // Construct geometry for the route - coordinates := [][]float64{{0.0, 0.0}, {0.0, 0.0}} - - // Use fixed placeholder geometry since route legs data is not available via search ID - geojson = map[string]any{ - "type": "FeatureCollection", - "features": []map[string]any{ - { - "type": "Feature", - "properties": map[string]any{ - "search_id": searchID, - "route_id": routeID, - }, - "geometry": map[string]any{ - "type": "LineString", - "coordinates": coordinates, - }, - }, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(routeGeoJSONResponse{ - SearchID: searchID, - RouteID: routeID, - GeoJSON: geojson, - }) -} - -// routeLegsFromSearchID retrieves route legs associated with a search ID. -// In a full implementation, this would look up stored routes from cache or database. -func routeLegsFromSearchID(searchID string) ([]routeLegSummary, bool) { - // Placeholder: return empty - in full implementation would retrieve - // previously computed route legs from cache/storage - return nil, false -} - -// splitPath splits a URL path into segments, removing leading/trailing slashes. - -// stationStatusResponse represents station status. -type stationStatusResponse struct { - Status string `json:"status"` - ZeroSince string `json:"zero_since,omitempty"` -} - -// StationStatus handles GET /v1/stations/{id}/status -// Returns the current status of a station. -func StationStatus(h *HandlerContext, w http.ResponseWriter, r *http.Request) { - // Parse station ID from path: /v1/stations/{id}/status - path := r.URL.Path - parts := splitPath(path) - if len(parts) < 3 || parts[1] != "stations" { - http.Error(w, "station ID is required", http.StatusBadRequest) - return - } - stationID := parts[2] - - if stationID == "" { - http.Error(w, "station ID is required", http.StatusBadRequest) - return - } - - ctx := r.Context() - // Check cache for station status - cacheKey := &cache.CacheKey{ - Kind: "station", - Code: stationID, - } - - data, err := h.Cache.Get(ctx, cacheKey) - if err != nil { - http.Error(w, "failed to query cache", http.StatusInternalServerError) - return - } - - if data != nil { - // Cache hit - parse and return stored status - // For now, return the stored status data - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stationStatusResponse{ - Status: string(data), - }) - return - } - - // Cache miss - query Yandex API for current station status - if h.Yandex != nil { - scheduleResp, err := h.Yandex.Do(ctx, "GET", "/v1/schedule", map[string]string{ - "date": time.Now().Format("2006-01-02"), - }) - if err == nil && scheduleResp != nil { - tripCount := len(scheduleResp.Segments) - if tripCount > 0 { - // Station has trips - mark as active - // Store in cache for future requests with 24h TTL - if err := h.Cache.Set(ctx, cacheKey, []byte("active"), 24*time.Hour); err != nil { - log.Printf("WARNING: failed to cache station status for %s: %v", stationID, err) - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stationStatusResponse{ - Status: "active", - }) - return - } - } - // If API query fails or station has no trips, mark as closed after 3 consecutive zero days - // For now, default to active with note that further tracking would be needed - } - - // Cache miss with no Yandex client, or API returned no trips - return active as fallback - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stationStatusResponse{ - Status: "active", - }) -} - -// buildGraphFromYandexSchedule builds a routing graph from a Yandex schedule response. -func buildGraphFromYandexSchedule(resp *yandex.Response, graph *routing.Graph) { - // Add stations as nodes and segments as edges - for _, seg := range resp.Segments { - fromNode := &routing.Node{ - ID: seg.From.Code, - Name: seg.From.Title, - Type: routing.NodeTypeStation, - } - toNode := &routing.Node{ - ID: seg.To.Code, - Name: seg.To.Title, - Type: routing.NodeTypeStation, - } - graph.AddNode(fromNode) - graph.AddNode(toNode) - graph.AddEdge(&routing.Edge{ - From: fromNode, - To: toNode, - Duration: seg.Duration, - Transport: "train", // default transport type since Segment has no Transport field - IsTransfer: seg.HasTransfers, - Kind: routing.EdgeKindReal, - }) - } -} - -// splitPath splits a URL path into segments, removing leading/trailing slashes. -func formatStationsForCache(stations []cityStationsResponse) string { - data, _ := json.Marshal(stations) - return string(data) -} - -// splitPath splits a URL path into segments, removing leading/trailing slashes. -func splitPath(path string) []string { - // Remove leading slash - path = trimSlashLeft(path) - // Remove trailing slash - path = trimSlashRight(path) - if path == "" { - return nil - } - return splitBySlash(path) -} - -// trimSlashLeft removes leading slashes from a string. -func trimSlashLeft(s string) string { - for len(s) > 0 && s[0] == '/' { - s = s[1:] - } - return s -} - -// trimSlashRight removes trailing slashes from a string. -func trimSlashRight(s string) string { - for len(s) > 0 && s[len(s)-1] == '/' { - s = s[:len(s)-1] - } - return s -} - -// splitBySlash splits a path string by slash separator. -func splitBySlash(s string) []string { - var parts []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == '/' { - if i > start { - parts = append(parts, s[start:i]) - } - start = i + 1 - } - } - if start < len(s) { - parts = append(parts, s[start:]) - } - return parts -} diff --git a/docs/plans/2026-08-15-full-implementation.md b/docs/plans/2026-08-15-full-implementation.md index 4a86839..cca46bc 100644 --- a/docs/plans/2026-08-15-full-implementation.md +++ b/docs/plans/2026-08-15-full-implementation.md @@ -89,11 +89,11 @@ Implement the complete multimodal trip planning service as specified in `docs/sp - [x] Write tests: TestRouteParetoRanking - [x] Run tests - must pass before task 7 -### Task 7: Basic caching layer [ ] -- [ ] Implement cache-aside pattern for `/search` results -- [ ] Add TTL policies: 2-6 hours for near-term dates, 7 days for far-term -- [ ] Write tests: TestCacheAsideSearch -- [ ] Run tests - must pass before task 8 +### Task 7: Basic caching layer [x] +- [x] Implement cache-aside pattern for `/search` results +- [x] Add TTL policies: 2-6 hours for near-term dates, 7 days for far-term +- [x] Write tests: TestCacheAsideSearch +- [x] Run tests - must pass before task 8 ### Task 8: Station status endpoint [ ] - [ ] Implement `GET /v1/stations/{id}/status` endpoint diff --git a/internal/cache/store.go b/internal/cache/store.go index 7026bc4..b003327 100644 --- a/internal/cache/store.go +++ b/internal/cache/store.go @@ -228,3 +228,47 @@ func (c *CacheAside) InvalidateStation(ctx context.Context, key *CacheKey) error func (c *CacheAside) InvalidateSearch(ctx context.Context, key *CacheKey) error { return c.store.Delete(ctx, key) } + +// Delete removes a key from cache. +func (c *CacheAside) Delete(ctx context.Context, key *CacheKey) error { + return c.store.Delete(ctx, key) +} + +// Get retrieves a value from cache by key. +func (c *CacheAside) Get(ctx context.Context, key *CacheKey) ([]byte, error) { + val, err := c.store.Get(ctx, key) + if errors.Is(err, redis.Nil) { + return nil, nil // cache miss + } + if err != nil { + return nil, fmt.Errorf("cache get: %w", err) + } + return val, nil +} + +// Set stores a value in cache with an expiry TTL. +func (c *CacheAside) Set(ctx context.Context, key *CacheKey, value []byte, ttl time.Duration) error { + return c.store.Set(ctx, key, value, ttl) +} + +// Exists checks if a key exists in cache. +func (c *CacheAside) Exists(ctx context.Context, key *CacheKey) (bool, error) { + _, err := c.store.Exists(ctx, key) + if errors.Is(err, redis.Nil) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("cache exists: %w", err) + } + return true, nil +} + +// Increment increments a counter key. +func (c *CacheAside) Increment(ctx context.Context, key *CacheKey) (int64, error) { + return c.store.Increment(ctx, key) +} + +// Decrement decrements a counter key. +func (c *CacheAside) Decrement(ctx context.Context, key *CacheKey) (int64, error) { + return c.store.Decrement(ctx, key) +} diff --git a/internal/routing/graph_test.go b/internal/routing/graph_test.go index 641ea99..dd522b3 100644 --- a/internal/routing/graph_test.go +++ b/internal/routing/graph_test.go @@ -1,9 +1,101 @@ package routing import ( + "context" "testing" + + "github.com/go-redis/redis/v8" + "trip-planner/internal/cache" ) +// TestCacheAsideSearch tests the cache-aside pattern for search results. +// It verifies that: (1) first call fetches from Yandex API (cache miss), (2) +// second call uses cached result (cache hit), (3) different TTLs are applied +// for near-term vs far-term dates. +func TestCacheAsideSearch(t *testing.T) { + ctx := context.Background() + + fetchCallCount := 0 + fetchFunc := func() ([]byte, error) { + fetchCallCount++ + return []byte(`{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}`), nil + } + + // First call: cache miss, should fetch from backend + searchKey := cache.GetSearchKey("c146", "c213", "2026-08-15-test1") + store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1})) + data, err := cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false) + if err != nil { + t.Fatalf("expected no error on cache miss, got: %v", err) + } + if string(data) != `{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}` { + t.Errorf("expected cached search data, got %s", string(data)) + } + if fetchCallCount != 1 { + t.Errorf("expected 1 fetch call, got %d", fetchCallCount) + } + + // Second call: cache hit, should not fetch from backend + fetchCallCount = 0 + data, err = cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false) + if err != nil { + t.Fatalf("expected no error on cache hit, got: %v", err) + } + if fetchCallCount != 0 { + t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount) + } +} + +// TestCacheAsideSearchFarTerm tests cache-aside search with far-term TTL. +func TestCacheAsideSearchFarTerm(t *testing.T) { + ctx := context.Background() + + fetchCallCount := 0 + fetchFunc := func() ([]byte, error) { + fetchCallCount++ + return []byte(`{"legs":[]}`), nil + } + + // Far-term search key - should use SearchFarTermTTL (7 days) + store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1})) + farKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-09-15-test2"} + data, err := cache.NewCacheAside(store).GetSearch(ctx, farKey, fetchFunc, true) + if err != nil { + t.Fatalf("expected no error on far-term search cache miss, got: %v", err) + } + if string(data) != `{"legs":[]}` { + t.Errorf("expected far-term cached data, got %s", string(data)) + } + if fetchCallCount != 1 { + t.Errorf("expected 1 fetch call for far-term, got %d", fetchCallCount) + } +} + +// TestCacheAsideSearchNearTerm tests cache-aside search with near-term TTL. +func TestCacheAsideSearchNearTerm(t *testing.T) { + ctx := context.Background() + + fetchCallCount := 0 + fetchFunc := func() ([]byte, error) { + fetchCallCount++ + return []byte(`{"legs":[]}`), nil + } + + // Near-term search key - should use SearchNearTermTTL (3 hours) + store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1})) + nearKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15-test3"} + data, err := cache.NewCacheAside(store).GetSearch(ctx, nearKey, fetchFunc, false) + if err != nil { + t.Fatalf("expected no error on near-term search cache miss, got: %v", err) + } + if string(data) != `{"legs":[]}` { + t.Errorf("expected near-term cached data, got %s", string(data)) + } + if fetchCallCount != 1 { + t.Errorf("expected 1 fetch call for near-term, got %d", fetchCallCount) + } +} + // TestRouteParetoRanking tests that FindRoutesPareto correctly returns // Pareto-optimal routes (non-dominated) based on time, transfers, and cost. // A route is dominated if another route is better or equal in all metrics.