Skip to content

Commit 6834532

Browse files
Add search route (#73)
* Add search route * Add check for search service enabled
1 parent aa8947d commit 6834532

File tree

6 files changed

+484
-1
lines changed

6 files changed

+484
-1
lines changed

app/db.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@
55
from couchbase.exceptions import CouchbaseException
66
from datetime import timedelta
77
from dotenv import load_dotenv
8+
from couchbase.result import PingResult
9+
from couchbase.diagnostics import PingState, ServiceType
810
import os
11+
import json
912
from functools import cache
13+
from couchbase.management.search import SearchIndex
14+
from couchbase.exceptions import QueryIndexAlreadyExistsException
15+
from couchbase.options import SearchOptions
16+
from couchbase.search import MatchQuery, ConjunctionQuery, TermQuery
17+
import couchbase.search as search
1018

1119

1220
class CouchbaseClient(object):
@@ -19,6 +27,7 @@ def __init__(self, conn_str: str, username: str, password: str) -> CouchbaseClie
1927
self.conn_str = conn_str
2028
self.username = username
2129
self.password = password
30+
self.index_name = "hotel_search"
2231
self.bucket_name = "travel-sample"
2332
self.scope_name = "inventory"
2433
self.connect()
@@ -57,6 +66,13 @@ def connect(self) -> None:
5766

5867
# get a reference to our scope
5968
self.scope = self.bucket.scope(self.scope_name)
69+
# Call the method to create the fts index if search service is enabled
70+
if self.is_search_service_enabled():
71+
self.create_search_index()
72+
else:
73+
print(
74+
"Search service is not enabled on this cluster. Skipping search index creation."
75+
)
6076

6177
def check_scope_exists(self) -> bool:
6278
"""Check if the scope exists in the bucket"""
@@ -70,6 +86,36 @@ def check_scope_exists(self) -> bool:
7086
"Error fetching scopes in cluster. \nEnsure that travel-sample bucket exists."
7187
)
7288

89+
def is_search_service_enabled(self, min_nodes: int = 1) -> bool:
90+
try:
91+
ping_result: PingResult = self.cluster.ping()
92+
search_endpoints = ping_result.endpoints[ServiceType.Search]
93+
available_search_nodes = 0
94+
for endpoint in search_endpoints:
95+
if endpoint.state == PingState.OK:
96+
available_search_nodes += 1
97+
return available_search_nodes >= min_nodes
98+
except Exception as e:
99+
print(
100+
f"Error checking search service status. \nEnsure that Search Service is enabled: {e}"
101+
)
102+
return False
103+
104+
def create_search_index(self) -> None:
105+
"""Upsert a fts index in the Couchbase cluster"""
106+
try:
107+
scope_index_manager = self.bucket.scope(self.scope_name).search_indexes()
108+
with open(f"{self.index_name}_index.json", "r") as f:
109+
index_definition = json.load(f)
110+
111+
# Upsert the index
112+
scope_index_manager.upsert_index(SearchIndex.from_json(index_definition))
113+
print(f"Index '{self.index_name}' created or updated successfully.")
114+
except QueryIndexAlreadyExistsException:
115+
print(f"Index with name '{self.index_name}' already exists")
116+
except Exception as e:
117+
print(f"Error upserting index '{self.index_name}': {e}")
118+
73119
def close(self) -> None:
74120
"""Close the connection to the Couchbase cluster"""
75121
if self.cluster:
@@ -100,6 +146,59 @@ def query(self, sql_query, *options, **kwargs):
100146
# kwargs are used for named parameters
101147
return self.scope.query(sql_query, *options, **kwargs)
102148

149+
def search_by_name(self, name):
150+
"""Perform a full-text search for hotel names using the given name"""
151+
try:
152+
searchQuery = search.SearchRequest.create(
153+
search.MatchQuery(name, field="name")
154+
)
155+
searchResult = self.scope.search(
156+
self.index_name, searchQuery, SearchOptions(limit=50, fields=["name"])
157+
)
158+
names = []
159+
for row in searchResult.rows():
160+
hotel = row.fields
161+
names.append(hotel.get("name", ""))
162+
except Exception as e:
163+
print("Error while performing fts search", {e})
164+
return names
165+
166+
def filter(self, filter: dict, limit, offset):
167+
"""Perform a full-text search with filters and pagination"""
168+
try:
169+
conjuncts = []
170+
171+
match_query_terms = ["description", "name", "title"]
172+
conjuncts.extend(
173+
[
174+
MatchQuery(filter[t], field=t)
175+
for t in match_query_terms
176+
if t in filter
177+
]
178+
)
179+
term_query_terms = ["city", "country", "state"]
180+
conjuncts.extend(
181+
[TermQuery(filter[t], field=t) for t in term_query_terms if t in filter]
182+
)
183+
184+
if conjuncts:
185+
query = ConjunctionQuery(*conjuncts)
186+
else:
187+
return []
188+
189+
options = SearchOptions(fields=["*"], limit=limit, skip=offset)
190+
191+
result = self.scope.search(
192+
self.index_name, search.SearchRequest.create(query), options
193+
)
194+
hotels = []
195+
for row in result.rows():
196+
hotel = row.fields
197+
hotels.append(hotel)
198+
except Exception as e:
199+
print("Error while performing fts search", {e})
200+
return hotels
201+
103202

104203
@cache
105204
def get_db() -> CouchbaseClient:

app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from fastapi import FastAPI
22
from contextlib import asynccontextmanager
33
from starlette.responses import RedirectResponse
4-
from app.routers import airline, airport, route
4+
from app.routers import airline, airport, route, hotel
55
from app.db import get_db
66

77

@@ -43,6 +43,7 @@ async def lifespan(app: FastAPI):
4343
app.include_router(airline.router, tags=["airline"], prefix="/api/v1/airline")
4444
app.include_router(airport.router, tags=["airport"], prefix="/api/v1/airport")
4545
app.include_router(route.router, tags=["route"], prefix="/api/v1/route")
46+
app.include_router(hotel.router, tags=["hotel"], prefix="/api/v1/hotel")
4647

4748

4849
# Redirect to Swagger documentation on loading the API for demo purposes

app/routers/hotel.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from typing import List, Optional
2+
from typing_extensions import Annotated
3+
4+
from app.db import get_db as CouchbaseClient
5+
from fastapi import APIRouter, Depends, HTTPException, Query
6+
from pydantic import BaseModel, Field
7+
8+
router = APIRouter()
9+
10+
HOTEL_COLLECTION = "hotel"
11+
NAME_DESCRIPTION = "Hotel Name"
12+
EXAMPLE_HOTEL_NAME = "Seal View"
13+
14+
15+
class HotelName(BaseModel):
16+
"""Model for Hotel Name"""
17+
18+
name: Annotated[str, Field(description="Hotel Name")]
19+
20+
21+
class Hotel(BaseModel):
22+
"""Model for Hotels"""
23+
24+
city: Optional[str] = Field(
25+
None, examples=["Santa Margarita"], description="Hotel Name"
26+
)
27+
country: Optional[str] = Field(
28+
None, examples=["United States"], description="Country Name"
29+
)
30+
description: Optional[str] = Field(
31+
None, examples=["newly renovated"], description="Description"
32+
)
33+
name: Optional[str] = Field(None, examples=["KCL Campground"], description="Name")
34+
state: Optional[str] = Field(None, examples=["California"], description="State")
35+
title: Optional[str] = Field(
36+
None, examples=["Carrizo Plain National Monument"], description="Title"
37+
)
38+
39+
40+
@router.get(
41+
"/autocomplete",
42+
response_model=List[HotelName],
43+
description="Search for hotels based on their name. \n\n This provides an example of using [Search operations](https://docs.couchbase.com/python-sdk/current/howtos/full-text-searching-with-sdk.html#search-queries) in Couchbase to search for a specific name using the fts index.\n\n Code: [`api/hotel.py`](https://github.com/couchbase-examples/python-quickstart/blob/main/src/api/hotel.py) \n Method: `get`",
44+
responses={
45+
200: {
46+
"description": "List of Hotel Names",
47+
},
48+
500: {
49+
"description": "Unexpected Error",
50+
},
51+
},
52+
)
53+
def hotel_autocomplete(
54+
name: Annotated[
55+
str,
56+
Query(
57+
description=NAME_DESCRIPTION,
58+
examples=[EXAMPLE_HOTEL_NAME],
59+
openapi_examples={"Seal View": {"value": "Seal View"}},
60+
),
61+
],
62+
db=Depends(CouchbaseClient),
63+
) -> List[HotelName]:
64+
"""Hotel name with specified name"""
65+
try:
66+
result = db.search_by_name(name)
67+
return [{"name": name} for name in result]
68+
except Exception as e:
69+
raise HTTPException(status_code=500, detail=f"Unexpected error: {e}")
70+
71+
72+
@router.post(
73+
"/filter",
74+
response_model=List[Hotel],
75+
description="Filter hotels using various filters such as name, title, description, country, state and city. \n\n This provides an example of using [Search operations](https://docs.couchbase.com/python-sdk/current/howtos/full-text-searching-with-sdk.html#search-queries) in Couchbase to filter documents using the fts index.\n\n Code: [`api/hotel.py`](https://github.com/couchbase-examples/python-quickstart/blob/main/src/api/hotel.py) \n Method: `post`",
76+
responses={
77+
200: {
78+
"description": "List of Hotel",
79+
},
80+
500: {
81+
"description": "Unexpected Error",
82+
},
83+
},
84+
)
85+
def hotel_filter(
86+
hotel: Optional[Hotel] = None,
87+
limit: int = Query(10, description="Number of hotels to return (page size)"),
88+
offset: int = Query(0, description="Number of hotels to skip (for pagination)"),
89+
db=Depends(CouchbaseClient),
90+
) -> List[Hotel]:
91+
"""Hotel filter with various filters"""
92+
try:
93+
hotels = db.filter(
94+
hotel.model_dump(exclude_none=True), limit=limit, offset=offset
95+
)
96+
return hotels
97+
except Exception as e:
98+
raise HTTPException(status_code=500, detail=f"Unexpected error: {e}")

app/tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ def route_collection():
4343
return "route"
4444

4545

46+
@pytest.fixture(scope="module")
47+
def hotel_api():
48+
return f"{BASE_URI}/hotel"
49+
50+
4651
class Helpers:
4752
@staticmethod
4853
def delete_existing_document(couchbase_client, collection, key):

app/tests/test_hotel.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from fastapi.testclient import TestClient
2+
from app.main import app
3+
4+
client = TestClient(app)
5+
6+
7+
class TestHotel:
8+
def test_hotel_autocomplete_search(self, hotel_api):
9+
"""Test searching hotels by name."""
10+
url = f"{hotel_api}/autocomplete"
11+
search_term = "KCL"
12+
13+
response = client.get(url, params={"name": search_term})
14+
assert response.status_code == 200
15+
16+
result = response.json()
17+
assert len(result) > 0
18+
assert all(search_term.lower() in hotel["name"].lower() for hotel in result)
19+
20+
def test_hotel_autocomplete_search_no_results(self, hotel_api):
21+
"""Test searching hotels by name with a term that should yield no results."""
22+
url = f"{hotel_api}/autocomplete"
23+
search_term = "XYZNonexistentHotel"
24+
25+
response = client.get(url, params={"name": search_term})
26+
assert response.status_code == 200
27+
28+
result = response.json()
29+
assert len(result) == 0
30+
31+
def test_hotel_autocomplete(self, hotel_api):
32+
"""Test the autocomplete endpoint with a valid name query parameter."""
33+
url = f"{hotel_api}/autocomplete?name=sea"
34+
response = client.get(url)
35+
assert response.status_code == 200
36+
37+
result = response.json()
38+
assert isinstance(result, list)
39+
assert len(result) == 25
40+
41+
def test_hotel_filter_no_results(self, hotel_api):
42+
"""Test filtering with criteria that should yield no results."""
43+
url = f"{hotel_api}/filter"
44+
impossible_filter = {"city": "NonexistentCity", "country": "NonexistentCountry"}
45+
46+
response = client.post(url, json=impossible_filter)
47+
assert response.status_code == 200
48+
49+
result = response.json()
50+
assert len(result) == 0
51+
52+
def test_hotel_all_filter(self, hotel_api):
53+
"""Test filtering hotels with specific filters."""
54+
url = f"{hotel_api}/filter"
55+
hotel_filter = {
56+
"title": "Carrizo Plain National Monument",
57+
"name": "KCL Campground",
58+
"country": "United States",
59+
"city": "Santa Margarita",
60+
"state": "California",
61+
"description": "newly renovated",
62+
}
63+
64+
expected_hotels = [
65+
{
66+
"title": "Carrizo Plain National Monument",
67+
"name": "KCL Campground",
68+
"country": "United States",
69+
"city": "Santa Margarita",
70+
"state": "California",
71+
"description": "The campground has a gravel road, pit toilets, corrals and water for livestock. There are some well established shade trees and the facilities have just been renovated to include new fire rings with BBQ grates, lantern poles, and gravel roads and tent platforms. Tenters, and small to medium sized campers will find the KCL a good fit.",
72+
}
73+
]
74+
75+
response = client.post(url, json=hotel_filter)
76+
assert response.status_code == 200
77+
78+
result = response.json()
79+
assert result == expected_hotels
80+
81+
def test_hotel_country_filter(self, hotel_api):
82+
"""Test filtering hotels by country."""
83+
url = f"{hotel_api}/filter"
84+
country_filter = {"country": "United States"}
85+
86+
response = client.post(url, json=country_filter)
87+
assert response.status_code == 200
88+
89+
result = response.json()
90+
assert len(result) > 0
91+
assert all(hotel["country"] == "United States" for hotel in result)
92+
93+
def test_hotel_with_single_filter(self, hotel_api):
94+
"""Test filtering hotels with a single description filter."""
95+
url = f"{hotel_api}/filter"
96+
hotel_filter = {"description": "newly renovated"}
97+
98+
response = client.post(url, json=hotel_filter)
99+
assert response.status_code == 200
100+
101+
result = response.json()
102+
assert isinstance(result, list)
103+
assert len(result) > 2
104+
105+
def test_hotel_single_filter_with_pagination(self, hotel_api):
106+
"""Test filtering hotels with description, offset, and limit filters."""
107+
page_size = 3
108+
iterations = 3
109+
hotel_filter = {"description": "newly renovated"}
110+
all_hotels = set()
111+
112+
for i in range(iterations):
113+
hotel_filter["offset"] = page_size * i
114+
url = f"{hotel_api}/filter?limit={page_size}&offset={page_size*i}"
115+
response = client.post(url, json=hotel_filter)
116+
assert response.status_code == 200
117+
118+
result = response.json()
119+
assert isinstance(result, list)
120+
assert len(result) <= page_size
121+
122+
for hotel in result:
123+
all_hotels.add(hotel["name"])
124+
125+
assert len(all_hotels) >= page_size * iterations

0 commit comments

Comments
 (0)