Skip to content

Commit b7f8042

Browse files
Add fts search endpoint (#113)
* Add fts search endpoint * Format and add more tests * Add check for search service enabled
1 parent 425fa70 commit b7f8042

File tree

7 files changed

+466
-0
lines changed

7 files changed

+466
-0
lines changed

src/api/hotel.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from flask_restx import Namespace, fields, Resource
2+
from flask import request
3+
from extensions import couchbase_db
4+
from couchbase.exceptions import CouchbaseException
5+
6+
hotel_COLLECTION = "hotel"
7+
hotel_ns = Namespace("Hotel", description="Hotel related APIs", ordered=True)
8+
9+
hotel_name_model = hotel_ns.model(
10+
"HotelName", {"name": fields.String(required=True, description="Hotel Name")}
11+
)
12+
13+
hotel_model = hotel_ns.model(
14+
"Hotel",
15+
{
16+
"city": fields.String(description="Hotel Name", example="Santa Margarita"),
17+
"country": fields.String(description="Country Name", example="United States"),
18+
"description": fields.String(
19+
description="Description", example="newly renovated"
20+
),
21+
"name": fields.String(description="Name", example="KCL Campground"),
22+
"state": fields.String(description="state", example="California"),
23+
"title": fields.String(
24+
description="title", example="Carrizo Plain National Monument"
25+
),
26+
},
27+
)
28+
29+
30+
@hotel_ns.route("/autocomplete")
31+
class HotelAutoComplete(Resource):
32+
@hotel_ns.doc(
33+
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 Class: `HotelAutoComplete` \n Method: `get`",
34+
responses={
35+
200: "List of Hotel Names",
36+
500: "Unexpected Error",
37+
},
38+
)
39+
@hotel_ns.marshal_with(hotel_name_model)
40+
@hotel_ns.doc(params={"name": "Hotel Name like Seal View"})
41+
def get(self):
42+
name = request.args.get("name", "")
43+
try:
44+
result = couchbase_db.search_by_name(name=name)
45+
return [{"name": name} for name in result], 200
46+
except (CouchbaseException, Exception) as e:
47+
return f"Unexpected error: {e}", 500
48+
49+
50+
@hotel_ns.route("/filter")
51+
class HotelFilter(Resource):
52+
@hotel_ns.marshal_list_with(hotel_model)
53+
@hotel_ns.doc(
54+
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 Class: `HotelFilter` \n Method: `post`",
55+
responses={
56+
200: "List of Hotels",
57+
500: "Unexpected Error",
58+
},
59+
params={
60+
"limit": {
61+
"description": "Number of hotels to return (page size)",
62+
"in": "query",
63+
"required": False,
64+
"default": 10,
65+
},
66+
"offset": {
67+
"description": "Number of hotels to skip (for pagination)",
68+
"in": "query",
69+
"required": False,
70+
"default": 0,
71+
},
72+
},
73+
)
74+
@hotel_ns.expect(hotel_model, validate=True)
75+
def post(self):
76+
try:
77+
limit = int(request.args.get("limit", 10))
78+
offset = int(request.args.get("offset", 0))
79+
data = request.json
80+
hotels = couchbase_db.filter(data, limit=limit, offset=offset)
81+
return hotels, 200
82+
except (CouchbaseException, Exception) as e:
83+
return f"Unexpected error: {e}", 500

src/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from api.airport import airport_ns
33
from api.airline import airline_ns
44
from api.route import route_ns
5+
from api.hotel import hotel_ns
56
import os
67
from dotenv import load_dotenv
78
from flask import Flask
@@ -63,6 +64,7 @@
6364
api.add_namespace(airport_ns, path="/api/v1/airport")
6465
api.add_namespace(airline_ns, path="/api/v1/airline")
6566
api.add_namespace(route_ns, path="/api/v1/route")
67+
api.add_namespace(hotel_ns, path="/api/v1/hotel")
6668

6769
if __name__ == "__main__":
6870
app.run(debug=True, host="0.0.0.0", port=8080)

src/db.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import json
12
from couchbase.cluster import Cluster
23
from couchbase.options import ClusterOptions
34
from couchbase.auth import PasswordAuthenticator
45
from couchbase.exceptions import CouchbaseException
56
from datetime import timedelta
7+
from couchbase.result import PingResult
8+
from couchbase.diagnostics import PingState, ServiceType
9+
from couchbase.management.search import SearchIndex
10+
from couchbase.exceptions import QueryIndexAlreadyExistsException
11+
from couchbase.options import SearchOptions
12+
from couchbase.search import MatchQuery, ConjunctionQuery, TermQuery
13+
import couchbase.search as search
614

715

816
class CouchbaseClient(object):
@@ -21,6 +29,7 @@ def init_app(self, conn_str: str, username: str, password: str, app):
2129
self.scope_name = "inventory"
2230
self.username = username
2331
self.password = password
32+
self.index_name = "hotel_search"
2433
self.app = app
2534
self.connect()
2635

@@ -59,6 +68,13 @@ def connect(self) -> None:
5968

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

6379
def check_scope_exists(self) -> bool:
6480
"""Check if the scope exists in the bucket"""
@@ -74,6 +90,36 @@ def check_scope_exists(self) -> bool:
7490
print(e)
7591
exit()
7692

93+
def is_search_service_enabled(self, min_nodes: int = 1) -> bool:
94+
try:
95+
ping_result: PingResult = self.cluster.ping()
96+
search_endpoints = ping_result.endpoints[ServiceType.Search]
97+
available_search_nodes = 0
98+
for endpoint in search_endpoints:
99+
if endpoint.state == PingState.OK:
100+
available_search_nodes += 1
101+
return available_search_nodes >= min_nodes
102+
except Exception as e:
103+
print(
104+
f"Error checking search service status. \nEnsure that Search Service is enabled: {e}"
105+
)
106+
return False
107+
108+
def create_search_index(self) -> None:
109+
"""Upsert a fts index in the Couchbase cluster"""
110+
try:
111+
scope_index_manager = self.bucket.scope(self.scope_name).search_indexes()
112+
with open(f"{self.index_name}_index.json", "r") as f:
113+
index_definition = json.load(f)
114+
115+
# Upsert the index
116+
scope_index_manager.upsert_index(SearchIndex.from_json(index_definition))
117+
print(f"Index '{self.index_name}' created or updated successfully.")
118+
except QueryIndexAlreadyExistsException:
119+
print(f"Index with name '{self.index_name}' already exists")
120+
except Exception as e:
121+
print(f"Error upserting index '{self.index_name}': {e}")
122+
77123
def get_document(self, collection_name: str, key: str):
78124
"""Get document by key using KV operation"""
79125
return self.scope.collection(collection_name).get(key)
@@ -95,3 +141,56 @@ def query(self, sql_query, *options, **kwargs):
95141
# options are used for positional parameters
96142
# kwargs are used for named parameters
97143
return self.scope.query(sql_query, *options, **kwargs)
144+
145+
def search_by_name(self, name):
146+
"""Perform a full-text search for hotel names using the given name"""
147+
try:
148+
searchQuery = search.SearchRequest.create(
149+
search.MatchQuery(name, field="name")
150+
)
151+
searchResult = self.scope.search(
152+
self.index_name, searchQuery, SearchOptions(limit=50, fields=["name"])
153+
)
154+
names = []
155+
for row in searchResult.rows():
156+
hotel = row.fields
157+
names.append(hotel.get("name", ""))
158+
except Exception as e:
159+
print("Error while performing fts search", {e})
160+
return names
161+
162+
def filter(self, filter, limit, offset):
163+
"""Perform a full-text search with filters and pagination"""
164+
try:
165+
conjuncts = []
166+
167+
match_query_terms = ["description", "name", "title"]
168+
conjuncts.extend(
169+
[
170+
MatchQuery(filter[t], field=t)
171+
for t in match_query_terms
172+
if t in filter
173+
]
174+
)
175+
term_query_terms = ["city", "country", "state"]
176+
conjuncts.extend(
177+
[TermQuery(filter[t], field=t) for t in term_query_terms if t in filter]
178+
)
179+
180+
if conjuncts:
181+
query = ConjunctionQuery(*conjuncts)
182+
else:
183+
return []
184+
185+
options = SearchOptions(fields=["*"], limit=limit, skip=offset)
186+
187+
result = self.scope.search(
188+
self.index_name, search.SearchRequest.create(query), options
189+
)
190+
hotels = []
191+
for row in result.rows():
192+
hotel = row.fields
193+
hotels.append(hotel)
194+
except Exception as e:
195+
print("Error while performing fts search", {e})
196+
return hotels

src/hotel_search_index.json

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
{
2+
"name": "hotel_search",
3+
"type": "fulltext-index",
4+
"sourceType": "gocbcore",
5+
"sourceName": "travel-sample",
6+
"planParams": {
7+
"indexPartitions": 1,
8+
"numReplicas": 0
9+
},
10+
"params": {
11+
"doc_config": {
12+
"docid_prefix_delim": "",
13+
"docid_regexp": "",
14+
"mode": "scope.collection.type_field",
15+
"type_field": "type"
16+
},
17+
"mapping": {
18+
"analysis": {
19+
"analyzers": {
20+
"edge_ngram": {
21+
"token_filters": [
22+
"to_lower",
23+
"edge_ngram_2_8"
24+
],
25+
"tokenizer": "unicode",
26+
"type": "custom"
27+
}
28+
},
29+
"token_filters": {
30+
"edge_ngram_2_8": {
31+
"back": false,
32+
"max": 8,
33+
"min": 2,
34+
"type": "edge_ngram"
35+
}
36+
}
37+
},
38+
"default_analyzer": "standard",
39+
"default_datetime_parser": "dateTimeOptional",
40+
"index_dynamic": false,
41+
"store_dynamic": false,
42+
"default_mapping": {
43+
"dynamic": true,
44+
"enabled": false
45+
},
46+
"types": {
47+
"inventory.hotel": {
48+
"dynamic": false,
49+
"enabled": true,
50+
"properties": {
51+
"title": {
52+
"enabled": true,
53+
"fields":[
54+
{
55+
"docvalues": true,
56+
"include_in_all": false,
57+
"include_term_vectors": false,
58+
"index": true,
59+
"name": "title",
60+
"store": true,
61+
"type": "text"
62+
}
63+
]
64+
},
65+
"name": {
66+
"enabled": true,
67+
"fields":[
68+
{
69+
"docvalues": true,
70+
"include_in_all": false,
71+
"include_term_vectors": false,
72+
"index": true,
73+
"name": "name",
74+
"store": true,
75+
"type": "text",
76+
"analyzer": "edge_ngram"
77+
},
78+
{
79+
"docvalues": true,
80+
"include_in_all": false,
81+
"include_term_vectors": false,
82+
"index": true,
83+
"name": "name_keyword",
84+
"store": false,
85+
"type": "text",
86+
"analyzer": "keyword"
87+
}
88+
]
89+
},
90+
"description": {
91+
"enabled": true,
92+
"fields":[
93+
{
94+
"docvalues": true,
95+
"include_in_all": false,
96+
"include_term_vectors": false,
97+
"index": true,
98+
"name": "description",
99+
"store": true,
100+
"type": "text",
101+
"analyzer": "en"
102+
}
103+
]
104+
},
105+
"city": {
106+
"enabled": true,
107+
"fields":[
108+
{
109+
"docvalues": true,
110+
"include_in_all": false,
111+
"include_term_vectors": false,
112+
"index": true,
113+
"name": "city",
114+
"store": true,
115+
"type": "text",
116+
"analyzer": "keyword"
117+
}
118+
]
119+
},
120+
"state": {
121+
"enabled": true,
122+
"fields":[
123+
{
124+
"docvalues": true,
125+
"include_in_all": false,
126+
"include_term_vectors": false,
127+
"index": true,
128+
"name": "state",
129+
"store": true,
130+
"type": "text",
131+
"analyzer": "keyword"
132+
}
133+
]
134+
},
135+
"country": {
136+
"enabled": true,
137+
"fields":[
138+
{
139+
"docvalues": true,
140+
"include_in_all": false,
141+
"include_term_vectors": false,
142+
"index": true,
143+
"name": "country",
144+
"store": true,
145+
"type": "text",
146+
"analyzer": "keyword"
147+
}
148+
]
149+
}
150+
}
151+
}
152+
}
153+
}
154+
}
155+
}

0 commit comments

Comments
 (0)