Skip to content

Commit 4fbd57b

Browse files
committed
feat: add pagination setup for catalog search
1 parent f2691f2 commit 4fbd57b

File tree

4 files changed

+291
-73
lines changed

4 files changed

+291
-73
lines changed

backend/firestore.indexes.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@
1212
}
1313
}
1414
]
15+
},
16+
{
17+
"collectionGroup": "schemes",
18+
"queryScope": "COLLECTION",
19+
"fields": [
20+
{
21+
"fieldPath": "agency",
22+
"order": "ASCENDING"
23+
},
24+
{
25+
"fieldPath": "last_scraped_update",
26+
"order": "DESCENDING"
27+
}
28+
]
1529
}
1630
],
1731
"fieldOverrides": []
18-
}
32+
}

backend/functions/schemes/catalog.py

Lines changed: 88 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,54 @@
11
"""
2-
url for local testing:
2+
Handler for catalog endpoint
3+
4+
URL for local testing:
35
http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog
4-
http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog?category=<category>
56
http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog?agency=<agency>
67
"""
78

89
import json
9-
from dataclasses import dataclass
10+
from dataclasses import asdict, dataclass
11+
from enum import Enum
1012
from typing import Literal, Union
1113

1214
from fb_manager.firebaseManager import FirebaseManager
1315
from firebase_functions import https_fn, options
14-
from firebase_functions.firestore_fn import DocumentSnapshot
1516
from google.cloud.firestore_v1 import FieldFilter
16-
from google.cloud.firestore_v1.query_results import QueryResultsList
1717
from loguru import logger
1818
from utils.auth import verify_auth_token
19+
from utils.catalog_pagination import PaginationResult, get_paginated_results
1920
from utils.cors_config import get_cors_headers, handle_cors_preflight
2021
from utils.json_utils import safe_json_dumps
2122
from werkzeug.datastructures import MultiDict
2223

2324

24-
@dataclass
25-
class CatalogRequestTarget:
26-
ALL: Literal["all"] = "all"
27-
CATEGORY: Literal["category"] = "category"
28-
AGENCY: Literal["agency"] = "agency"
25+
ALLOWED_QUERY_PARAMS = {"agency", "limit", "cursor", "is_warmup"}
2926

3027

31-
@dataclass
32-
class AllCatalogRequestParams:
33-
target: CatalogRequestTarget.ALL
28+
class CatalogTarget(Enum):
29+
ALL = "all"
30+
AGENCY = "agency"
31+
32+
33+
@dataclass(kw_only=True)
34+
class BaseCatalogRequestParams:
35+
target: CatalogTarget
36+
limit: int = 10
37+
cursor: str | None = None
3438

3539

3640
@dataclass
37-
class CategoryCatalogRequestParams:
38-
target: CatalogRequestTarget.CATEGORY
39-
category_name: str
41+
class AllCatalogRequestParams(BaseCatalogRequestParams):
42+
target: Literal[CatalogTarget.ALL]
4043

4144

4245
@dataclass
43-
class AgencyCatalogRequestParams:
44-
target: CatalogRequestTarget.AGENCY
46+
class AgencyCatalogRequestParams(BaseCatalogRequestParams):
47+
target: Literal[CatalogTarget.AGENCY]
4548
agency_name: str
4649

4750

48-
CatalogRequestParams = Union[AllCatalogRequestParams, CategoryCatalogRequestParams, AgencyCatalogRequestParams]
51+
CatalogRequestParams = Union[AllCatalogRequestParams, AgencyCatalogRequestParams]
4952

5053

5154
def create_firebase_manager() -> FirebaseManager:
@@ -106,27 +109,22 @@ def catalog(req: https_fn.Request) -> https_fn.Response:
106109
try:
107110
query_params = _parse_query_params(req.args)
108111
except ValueError as e:
109-
logger.error("Error parsing query parameters", e)
112+
logger.exception("Error parsing query parameters", e)
110113
return https_fn.Response(
111114
response=json.dumps(
112-
{
113-
"error": "Error parsing query parameters; only /catalog, /catalog?category=<category> and /catalog?agency=<agency> are supported"
114-
}
115+
{"error": "Error parsing query parameters; only /catalog and /catalog?agency=<agency> are supported"}
115116
),
116117
status=400,
117118
mimetype="application/json",
118119
headers=headers,
119120
)
120121

121122
try:
122-
docs: QueryResultsList[DocumentSnapshot] | None = None
123123
match query_params.target:
124-
case CatalogRequestTarget.ALL:
125-
docs = _handle_all_catalog_request(firebase_manager)
126-
case CatalogRequestTarget.CATEGORY:
127-
docs = _handle_category_catalog_request(firebase_manager, query_params)
128-
case CatalogRequestTarget.AGENCY:
129-
docs = _handle_agency_catalog_request(firebase_manager, query_params)
124+
case CatalogTarget.ALL:
125+
results = _handle_all_catalog_request(firebase_manager, query_params)
126+
case CatalogTarget.AGENCY:
127+
results = _handle_agency_catalog_request(firebase_manager, query_params)
130128
except Exception as e:
131129
logger.exception("Unable to fetch scheme from firestore", e)
132130
return https_fn.Response(
@@ -136,81 +134,105 @@ def catalog(req: https_fn.Request) -> https_fn.Response:
136134
headers=headers,
137135
)
138136

139-
if not docs:
137+
if results.data is None or len(results.data) == 0:
140138
return https_fn.Response(
141139
response=json.dumps({"error": "No scheme found"}),
142140
status=404,
143141
mimetype="application/json",
144142
headers=headers,
145143
)
146144

147-
results = {"data": [doc.to_dict() for doc in docs]}
148145
return https_fn.Response(
149-
response=safe_json_dumps(results),
146+
response=safe_json_dumps(asdict(results)),
150147
status=200,
151148
mimetype="application/json",
152149
headers=headers,
153150
)
154151

155152

156-
# TODO: Add support for pagination parsing
157153
def _parse_query_params(query_params: MultiDict[str, str]) -> CatalogRequestParams:
158154
"""
159155
Parse request query parameters into CatalogRequestParams.
160156
161157
Supported:
162-
- /catalog -> AllCatalogRequestParams
163-
- /catalog?category=<name> -> CategoryCatalogRequestParams
164-
- /catalog?agency=<name> -> AgencyCatalogRequestParams
158+
- /catalog -> AllCatalogRequestParams
159+
- /catalog?agency=<name> -> AgencyCatalogRequestParams
160+
161+
Raises:
162+
ValueError: If unsupported query parameters are provided.
165163
"""
166164

167-
category = query_params.get("category")
168-
agency = query_params.get("agency")
165+
# Validate unknown query parameters
166+
unknown_params = set(query_params.keys()) - ALLOWED_QUERY_PARAMS
167+
if unknown_params:
168+
raise ValueError(f"Unsupported query parameter(s): {', '.join(sorted(unknown_params))}")
169169

170-
# Disallow ambiguous requests
171-
if category and agency:
172-
raise ValueError("Invalid query; provide only one of 'category' or 'agency'")
170+
# Retrieve relevant sub-catalog query parameters
171+
agency = query_params.get("agency")
173172

174-
# /catalog?category=<>
175-
if category:
176-
if not category.strip():
177-
raise ValueError("Invalid query; 'category' must be a non-empty value")
178-
return CategoryCatalogRequestParams(target=CatalogRequestTarget.CATEGORY, category_name=category)
173+
# Retrieve limit and cursor from query parameters
174+
limit = int(query_params.get("limit", "10"))
175+
cursor = query_params.get("cursor", "")
179176

180177
# /catalog?agency=<>
181178
if agency:
182179
if not agency.strip():
183180
raise ValueError("Invalid query; 'agency' must be a non-empty value")
184-
return AgencyCatalogRequestParams(target=CatalogRequestTarget.AGENCY, agency_name=agency)
181+
return AgencyCatalogRequestParams(target=CatalogTarget.AGENCY, agency_name=agency, limit=limit, cursor=cursor)
185182

186183
# /catalog
187-
return AllCatalogRequestParams(target=CatalogRequestTarget.ALL)
188-
184+
return AllCatalogRequestParams(target=CatalogTarget.ALL, limit=limit, cursor=cursor)
189185

190-
def _handle_all_catalog_request(firebase_manager: FirebaseManager) -> QueryResultsList[DocumentSnapshot] | None:
191-
"""Handle all catalog request."""
192186

193-
ref = firebase_manager.firestore_client.collection("schemes")
194-
return ref.get()
187+
def _handle_all_catalog_request(
188+
firebase_manager: FirebaseManager,
189+
query_params: AllCatalogRequestParams,
190+
) -> PaginationResult:
191+
"""Retrieve all catalog entries using cursor pagination.
195192
193+
Args:
194+
firebase_manager: Firebase manager providing Firestore access.
196195
197-
def _handle_category_catalog_request(
198-
firebase_manager: FirebaseManager, query_params: CategoryCatalogRequestParams
199-
) -> QueryResultsList[DocumentSnapshot] | None:
200-
"""Handle category catalog request."""
196+
Returns:
197+
PaginationResult:
198+
data: Documents for the current page.
199+
next_cursor: Cursor for the next page, or None if exhausted.
200+
has_more: Whether more results exist.
201+
"""
202+
col = firebase_manager.firestore_client.collection("schemes")
201203

202-
ref = firebase_manager.firestore_client.collection("schemes").where(
203-
filter=FieldFilter("scheme_type", "==", query_params.category_name)
204+
return get_paginated_results(
205+
collection_ref=col,
206+
cursor=query_params.cursor,
207+
limit=query_params.limit,
204208
)
205-
return ref.get()
206209

207210

208211
def _handle_agency_catalog_request(
209212
firebase_manager: FirebaseManager, query_params: AgencyCatalogRequestParams
210-
) -> QueryResultsList[DocumentSnapshot] | None:
211-
"""Handle agency catalog request."""
213+
) -> PaginationResult:
214+
"""Retrieve agency-filtered catalog entries using cursor pagination.
215+
216+
Args:
217+
firebase_manager: Firebase manager providing Firestore access.
218+
query_params: Request parameters containing agency name, limit,
219+
and optional pagination cursor.
220+
221+
Returns:
222+
PaginationResult:
223+
data: Documents for the current page.
224+
next_cursor: Cursor for the next page, or None if exhausted.
225+
has_more: Whether more results exist.
226+
"""
227+
col = firebase_manager.firestore_client.collection("schemes")
228+
229+
# Apply base filters for filtering within catalog
230+
query = col.where(filter=FieldFilter("agency", "==", query_params.agency_name))
231+
print("Query:", query)
212232

213-
ref = firebase_manager.firestore_client.collection("schemes").where(
214-
filter=FieldFilter("agency", "==", query_params.agency_name)
233+
return get_paginated_results(
234+
collection_ref=col,
235+
base_query=query,
236+
cursor=query_params.cursor,
237+
limit=query_params.limit,
215238
)
216-
return ref.get()

0 commit comments

Comments
 (0)