Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
branches: [main]
schedule:
- cron: "40 9 * * *"
workflow_dispatch:

jobs:
run_tests:
Expand All @@ -27,8 +28,64 @@ jobs:
ruby-version: 3.4.1
bundler-cache: true

- name: Run tests
run: bundle exec rspec test/integration
- name: Validate Couchbase Configuration
run: |
echo "Validating Couchbase environment variables..."

if [ -z "$DB_CONN_STR" ]; then
echo "::error::DB_CONN_STR environment variable is not set"
exit 1
fi

if [ -z "$DB_USERNAME" ]; then
echo "::error::DB_USERNAME environment variable is not set"
exit 1
fi

if [ -z "$DB_PASSWORD" ]; then
echo "::error::DB_PASSWORD secret is not set"
exit 1
fi

echo "βœ“ All Couchbase environment variables are configured"
env:
DB_CONN_STR: ${{ vars.DB_CONN_STR }}
DB_USERNAME: ${{ vars.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

- name: Test Couchbase Connection
run: |
bundle exec rails runner '
begin
if defined?(COUCHBASE_CLUSTER) && COUCHBASE_CLUSTER
bucket = COUCHBASE_CLUSTER.bucket("travel-sample")
puts "βœ“ Successfully connected to Couchbase cluster"
puts "βœ“ Successfully accessed travel-sample bucket"
else
puts "βœ— Couchbase cluster not initialized"
exit 1
end
rescue => e
puts "βœ— Failed to connect to Couchbase: #{e.class.name} - #{e.message}"
exit 1
end
'
env:
DB_CONN_STR: ${{ vars.DB_CONN_STR }}
DB_USERNAME: ${{ vars.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
CI: true

- name: Run integration tests
run: bundle exec rspec spec/requests/api/v1

- name: Verify Swagger documentation generates
run: bundle exec rake rswag:specs:swaggerize
env:
DB_CONN_STR: ${{ vars.DB_CONN_STR }}
DB_USERNAME: ${{ vars.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
CI: true

- name: Report Status
if: always()
Expand Down
114 changes: 113 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,121 @@ DB_PASSWORD=<password_for_user>
### Run the integration tests

```sh
bundle exec rspec test/integration
bundle exec rspec spec/requests
```

## Troubleshooting

### Integration Tests Failing with "undefined method 'get' for nil"

This error means Couchbase is not properly initialized. Follow these steps:

#### 1. Verify Environment Variables

For local development, ensure `dev.env` file exists in project root:

```bash
cat dev.env
```

Should contain:
```
DB_CONN_STR="couchbases://cb.hlcup4o4jmjr55yf.cloud.couchbase.com"
DB_USERNAME="your-username"
DB_PASSWORD="your-password"
```

#### 2. Verify Couchbase Connection

Test the connection:
```bash
bundle exec rails runner 'puts COUCHBASE_CLUSTER ? "βœ“ Connected" : "βœ— Not connected"'
```

#### 3. Verify travel-sample Bucket

The application requires the `travel-sample` bucket with:
- **Scope:** `inventory`
- **Collections:** `airline`, `airport`, `route`, `hotel`

For Couchbase Capella:
1. Log into Capella console
2. Navigate to your cluster
3. Check Buckets > travel-sample exists
4. Verify inventory scope and collections exist

#### 4. Check Permissions

The database user needs:
- Read/Write access to `travel-sample` bucket
- Query permissions for N1QL queries
- Search permissions for FTS operations

#### 5. Verify Network Access (Capella Only)

For Couchbase Capella:
1. Go to Settings > Allowed IP Addresses
2. Add your IP address or `0.0.0.0/0` for testing
3. Ensure cluster is not paused

### CI Tests Failing

Check GitHub repository configuration:

1. **Secrets** (Settings > Secrets and variables > Actions > Secrets):
- `DB_PASSWORD` - Your Couchbase password

2. **Variables** (Settings > Secrets and variables > Actions > Variables):
- `DB_CONN_STR` - Your Couchbase connection string
- `DB_USERNAME` - Your Couchbase username

3. **Capella IP Allowlist**:
- GitHub Actions runners use dynamic IPs
- Temporarily allow `0.0.0.0/0` or use Capella's "Allow All IPs" option

### Common Errors

**Error:** `Couchbase::Error::AuthenticationFailure`
- **Solution:** Check username/password in `dev.env` or GitHub Secrets

**Error:** `Couchbase::Error::BucketNotFound`
- **Solution:** Ensure `travel-sample` bucket is created and loaded

**Error:** `Couchbase::Error::Timeout`
- **Solution:** Check network connectivity, verify connection string uses correct protocol (`couchbase://` for local, `couchbases://` for Capella)

**Error:** `Couchbase::Error::ScopeNotFound` or `CollectionNotFound`
- **Solution:** The initializer auto-creates scope/collections, but user needs create permissions

### Health Check Endpoint

Check application health:
```bash
curl http://localhost:3000/api/v1/health
```

Response shows Couchbase status:
```json
{
"status": "healthy",
"timestamp": "2025-12-02T10:30:00Z",
"services": {
"couchbase": {
"status": "up",
"message": "Connected to travel-sample bucket"
}
}
}
```

### Getting Help

If issues persist:
1. Check application logs for detailed error messages
2. Verify Ruby version matches `.ruby-version` (3.4.1)
3. Run `bundle install` to ensure all gems are current
4. Check Couchbase SDK compatibility

# Appendix

## Data Model
Expand Down
1 change: 0 additions & 1 deletion app/controllers/api/v1/airlines_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
module Api
module V1
class AirlinesController < ApplicationController
skip_before_action :verify_authenticity_token, only: %i[create update destroy]
before_action :set_airline, only: %i[show update destroy]

# GET /api/v1/airlines/{id}
Expand Down
1 change: 0 additions & 1 deletion app/controllers/api/v1/airports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
module Api
module V1
class AirportsController < ApplicationController
skip_before_action :verify_authenticity_token, only: %i[create update destroy]
before_action :set_airport, only: %i[show update destroy]

# GET /api/v1/airports/{id}
Expand Down
36 changes: 36 additions & 0 deletions app/controllers/api/v1/health_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module Api
module V1
class HealthController < ApplicationController
def show
health_status = {
status: 'healthy',
timestamp: Time.current.iso8601,
services: {
couchbase: check_couchbase
}
}

all_up = health_status[:services].values.all? { |s| s[:status] == 'up' }
status_code = all_up ? :ok : :service_unavailable

render json: health_status, status: status_code
end

private

def check_couchbase
if defined?(COUCHBASE_CLUSTER) && COUCHBASE_CLUSTER
# Perform simple bucket check to verify connection
COUCHBASE_CLUSTER.bucket('travel-sample')
{ status: 'up', message: 'Connected to travel-sample bucket' }
else
{ status: 'down', message: 'Couchbase not initialized' }
end
rescue StandardError => e
{ status: 'down', message: e.message }
end
end
end
end
21 changes: 10 additions & 11 deletions app/controllers/api/v1/hotels_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,33 @@
module Api
module V1
class HotelsController < ApplicationController
skip_before_action :verify_authenticity_token, only: %i[search filter]
before_action :validate_query_params, only: [:search]
# GET /api/v1/hotels/autocomplete
def search
@hotels = HotelSearch.search_name(params[:name])
@hotels = Hotel.search_name(params[:name])
render json: @hotels, status: :ok
rescue StandardError => e
render json: { error: 'Internal server error', message: e.message }, status: :internal_server_error
end

# GET /api/v1/hotels/filter
def filter
@hotels = HotelSearch.filter(HotelSearch.new(
@hotels = Hotel.filter(Hotel.new(
{
"name"=> hotel_search_params[:name],
"title" => hotel_search_params[:title],
"description" => hotel_search_params[:description],
"country" => hotel_search_params[:country],
"city" => hotel_search_params[:city],
"state" => hotel_search_params[:state]
"name"=> hotel_params[:name],
"title" => hotel_params[:title],
"description" => hotel_params[:description],
"country" => hotel_params[:country],
"city" => hotel_params[:city],
"state" => hotel_params[:state]
}
), hotel_search_params[:offset], hotel_search_params[:limit])
), hotel_params[:offset], hotel_params[:limit])
render json: @hotels, status: :ok
rescue StandardError => e
render json: { error: 'Internal server error', message: e.message }, status: :internal_server_error
end

def hotel_search_params
def hotel_params
params.require(:hotel).permit(:name, :title, :description, :country, :city, :state, :offset, :limit)
end

Expand Down
1 change: 0 additions & 1 deletion app/controllers/api/v1/routes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
module Api
module V1
class RoutesController < ApplicationController
skip_before_action :verify_authenticity_token, only: %i[create update destroy]
before_action :set_route, only: %i[show update destroy]

# GET /api/v1/routes/{id}
Expand Down
12 changes: 11 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
class ApplicationController < ActionController::Base
class ApplicationController < ActionController::API
rescue_from CouchbaseConnection::CouchbaseUnavailableError, with: :handle_database_unavailable

private

def handle_database_unavailable(exception)
render json: {
error: 'Service Unavailable',
message: 'Database connection is not available. Please check configuration or try again later.'
}, status: :service_unavailable
end
end
8 changes: 8 additions & 0 deletions app/models/airline.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class Airline
include CouchbaseConnection

attr_accessor :name, :iata, :icao, :callsign, :country

def initialize(attributes)
Expand All @@ -12,13 +14,15 @@ def initialize(attributes)
end

def self.find(id)
ensure_couchbase!
result = AIRLINE_COLLECTION.get(id)
new(result.content) if result.success?
rescue Couchbase::Error::DocumentNotFound
nil
end

def self.all(country = nil, limit = 10, offset = 0)
ensure_couchbase!
bucket_name = 'travel-sample'
scope_name = 'inventory'
collection_name = 'airline'
Expand All @@ -33,6 +37,7 @@ def self.all(country = nil, limit = 10, offset = 0)
end

def self.to_airport(destination_airport_code, limit = 10, offset = 0)
ensure_couchbase!
bucket_name = 'travel-sample'
scope_name = 'inventory'
route_collection_name = 'route'
Expand All @@ -58,6 +63,7 @@ def self.to_airport(destination_airport_code, limit = 10, offset = 0)
end

def self.create(id, attributes)
ensure_couchbase!
required_fields = %w[name iata icao callsign country]
missing_fields = required_fields - attributes.keys
extra_fields = attributes.keys - required_fields
Expand All @@ -78,6 +84,7 @@ def self.create(id, attributes)
end

def update(id, attributes)
self.class.ensure_couchbase!
required_fields = %w[name iata icao callsign country]
missing_fields = required_fields - attributes.keys
extra_fields = attributes.keys - required_fields
Expand All @@ -98,6 +105,7 @@ def update(id, attributes)
end

def destroy(id)
self.class.ensure_couchbase!
AIRLINE_COLLECTION.remove(id)
end
end
Loading