diff --git a/README.md b/README.md new file mode 100644 index 0000000..194a05d --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Trip Planner + +A multimodal trip planning service that uses Yandex.Schedules API to provide routing across different transport modes (planes, trains, buses). The service implements lazy graph expansion to work within API quota constraints and provides route visualization on maps. + +## Technology Stack + +- **Backend**: Go +- **Database**: PostgreSQL (with PostGIS optional for geometry) +- **Cache/Queue**: Redis (native TTL) +- **Frontend Map**: Leaflet + OpenStreetMap tiles +- **Task Scheduler**: cron (internal `cmd/cron` or system cron) + +## Quick Start + +### Running Locally with Docker Compose + +```bash +docker-compose up -d +``` + +This starts: +- `api` service: Go HTTP server on port 8080 +- `cron` service: Go cron binary for reference data updates and station status detection +- `postgres`: PostgreSQL 15-alpine on port 5432 +- `redis`: Redis 7-alpine on port 6379 +- `watchtower`: nickfedor/watchtower for automatic container updates (interval: 60s) + +### Running the API Server Directly + +```bash +go run ./cmd/api +``` + +### Running Cron Jobs + +```bash +go run ./cmd/cron +``` + +## Deployment + +### Docker Deployment + +The project uses a multi-stage Dockerfile: +- **Builder stage**: `golang:1.26-alpine` to build `api` and `cron` binaries +- **Runtime stage**: `alpine:latest` with minimal footprint (~27MB) + +### CI/CD Pipeline + +Gitea Actions workflow (`.gitea/workflows/deploy.yml`) provides: +- Checkout code +- Setup Go 1.22+ +- Go modules cache +- Lint with golangci-lint v1.54.2 +- Run tests with race detector: `go test -v -race ./...` +- Docker Buildx setup +- Docker login to registry +- Build and push Docker image with tags: `latest` and commit SHA + +## API Endpoints + +- `GET /v1/cities?query=` — City autocomplete +- `GET /v1/cities/{id}/stations` — City stations (including neighbors if main closed) +- `POST /v1/routes/search` — Search for routes (Pareto-optimal results) +- `GET /v1/routes/{search_id}/{route_id}/geojson` — Get route geometry for map +- `GET /v1/stations/{id}/status` — Station status +- `POST /internal/admin/stations/{id}/status` — Manual station status override (requires auth) +- `GET /v1/preferences/saved-cities?user_id=` — Get user's saved cities +- `POST /v1/preferences/saved-cities?user_id=` — Add a city to user's saved cities +- `DELETE /v1/preferences/saved-cities/{city_code}?user_id=` — Remove a city from user's saved cities +- `GET /v1/preferences/search-history?user_id=` — Get user's search history +- `POST /v1/preferences/search-history?user_id=` — Add a search to user's history +- `GET /metrics` — Get observability metrics (cache hit rates, API quota, circuit breaker trips, search duration) diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index 1925cf2..7ed92a0 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -25,7 +25,6 @@ type HandlerContext struct { Redis *redis.Client Router *routing.Graph Yandex *yandex.Client - SearchCache *routing.SearchCacheService Preferences *cache.Preferences Metrics *metrics.Metrics SearchStart time.Time @@ -207,13 +206,14 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) { RankingMode: rankingMode, } - // Determine if this is a far-term search (date is more than 7 days in the future) + // Determine if this is a far-term search (date is 7 or more 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 { + // Check if date is 7 or more days in the future + sevenDaysLater := now.AddDate(0, 0, 7) + if !requestDate.Before(sevenDaysLater) { opts.FarTerm = true } } @@ -733,7 +733,6 @@ func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex Redis: redisClient, Router: router, Yandex: yandex, - SearchCache: routing.NewSearchCacheService(cacheStore, yandex, m), Preferences: cache.NewPreferences(cacheStore), Metrics: m, SearchStart: time.Now(), diff --git a/internal/routing/search_cache.go b/internal/routing/search_cache.go deleted file mode 100644 index af79cdd..0000000 --- a/internal/routing/search_cache.go +++ /dev/null @@ -1,90 +0,0 @@ -package routing - -import ( - "context" - "encoding/json" - "fmt" - - "trip-planner/internal/cache" - "trip-planner/internal/metrics" - "trip-planner/internal/yandex" -) - -// SearchCacheService handles caching and on-demand Yandex /search calls. -type SearchCacheService struct { - cache *cache.CacheAside - yclient *yandex.Client - metrics *metrics.Metrics -} - -// NewSearchCacheService creates a new search cache service. -func NewSearchCacheService(cacheStore cache.Cache, yclient *yandex.Client, m *metrics.Metrics) *SearchCacheService { - return &SearchCacheService{ - cache: cache.NewCacheAside(cacheStore, m), - yclient: yclient, - metrics: m, - } -} - -// SearchWithCache performs a route search with caching support. -// It uses the cache-aside pattern: try cache first, then Yandex API, then write back to cache. -func (s *SearchCacheService) SearchWithCache(ctx context.Context, from, to, date string, opts SearchOptions) (*yandex.Response, error) { - // Generate cache key including far-term flag to distinguish near-term vs far-term searches - farTermFlag := "near" - if opts.FarTerm { - farTermFlag = "far" - } - searchKey := cache.GetSearchKeyWithFarTerm(from, to, date, farTermFlag) - - // Try to get from cache first - fetchFunc := func() ([]byte, error) { - // If we reach here, it's a cache miss - perform on-demand Yandex /search call - return s.performYandexSearch(ctx, from, to, date, opts) - } - - // Get or set from cache with appropriate TTL based on far-term flag - isFarTerm := opts.FarTerm - data, err := s.cache.GetSearch(ctx, searchKey, fetchFunc, isFarTerm) - if err != nil { - return nil, fmt.Errorf("search cache get/set: %w", err) - } - - // Parse the yandex.Response from cached data - var result yandex.Response - if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("failed to parse yandex response from cache: %w", err) - } - - return &result, nil -} - -// performYandexSearch makes the actual Yandex /search API call. -func (s *SearchCacheService) performYandexSearch(ctx context.Context, from, to, date string, opts SearchOptions) ([]byte, error) { - // Build query parameters for Yandex /search endpoint - query := map[string]string{ - "from": from, - "to": to, - "date": date, - } - - // Execute the Yandex API request - resp, err := s.yclient.Do(ctx, "GET", "/v3.0/search/", query) - if err != nil { - return nil, fmt.Errorf("yandex search failed: %w", err) - } - - // Convert response to bytes for caching - return convertResponseToBytes(resp) -} - -// convertResponseToBytes converts Yandex API response to bytes for caching. -func convertResponseToBytes(resp *yandex.Response) ([]byte, error) { - if resp == nil { - return nil, fmt.Errorf("nil response") - } - data, err := json.Marshal(resp) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - return data, nil -} diff --git a/internal/routing/search_cache_test.go b/internal/routing/search_cache_test.go deleted file mode 100644 index 32a923c..0000000 --- a/internal/routing/search_cache_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package routing - -import ( - "context" - "testing" - "time" - - "trip-planner/internal/cache" - "trip-planner/internal/metrics" - "trip-planner/internal/yandex" -) - -// mockCacheStoreForSearch is a mock implementation of Cache for testing search cache -type mockCacheStoreForSearch struct { - data map[string][]byte -} - -func (m *mockCacheStoreForSearch) Get(ctx context.Context, key *cache.CacheKey) ([]byte, error) { - keyStr := key.Kind + ":" + key.Code - if data, ok := m.data[keyStr]; ok { - return data, nil - } - return nil, nil -} - -func (m *mockCacheStoreForSearch) Set(ctx context.Context, key *cache.CacheKey, value []byte, ttl time.Duration) error { - keyStr := key.Kind + ":" + key.Code - m.data[keyStr] = value - return nil -} - -func (m *mockCacheStoreForSearch) Exists(ctx context.Context, key *cache.CacheKey) (bool, error) { - keyStr := key.Kind + ":" + key.Code - _, ok := m.data[keyStr] - return ok, nil -} - -func (m *mockCacheStoreForSearch) Delete(ctx context.Context, key *cache.CacheKey) error { - keyStr := key.Kind + ":" + key.Code - delete(m.data, keyStr) - return nil -} - -func (m *mockCacheStoreForSearch) Increment(ctx context.Context, key *cache.CacheKey) (int64, error) { - return 0, nil -} - -func (m *mockCacheStoreForSearch) Decrement(ctx context.Context, key *cache.CacheKey) (int64, error) { - return 0, nil -} - -func TestNewSearchCacheService(t *testing.T) { - mockStore := &mockCacheStoreForSearch{data: make(map[string][]byte)} - metrics := metrics.New() - yclient := yandex.NewClient("test-key") - - svc := NewSearchCacheService(mockStore, yclient, metrics) - if svc == nil { - t.Error("expected SearchCacheService to be created") - } - if svc.cache == nil { - t.Error("expected cache to be initialized") - } - if svc.yclient == nil { - t.Error("expected yclient to be initialized") - } - if svc.metrics == nil { - t.Error("expected metrics to be initialized") - } -} - -func TestConvertResponseToBytes(t *testing.T) { - // Test with nil response - _, err := convertResponseToBytes(nil) - if err == nil { - t.Error("expected error for nil response") - } - - // Test with valid response - resp := &yandex.Response{ - Segments: []yandex.Segment{}, - } - data, err := convertResponseToBytes(resp) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if data == nil { - t.Error("expected non-nil data") - } -} diff --git a/internal/yandex/client.go b/internal/yandex/client.go index 8389ec9..582b203 100644 --- a/internal/yandex/client.go +++ b/internal/yandex/client.go @@ -277,10 +277,10 @@ func isRetryableError(err error) bool { if err == nil { return false } - // Check for HTTP status codes that are retryable (5xx errors) + // Check for HTTP status codes that are retryable (5xx errors and 429) apiErr, ok := err.(*APIError) if ok { - return apiErr.Code >= 500 && apiErr.Code < 600 + return (apiErr.Code >= 500 && apiErr.Code < 600) || apiErr.Code == 429 } // Check for network errors errStr := err.Error()