Skip to content

Commit dc7a653

Browse files
committed
feat: endpoint for general scheme catalog search
1 parent 5391e56 commit dc7a653

File tree

7 files changed

+738
-0
lines changed

7 files changed

+738
-0
lines changed

backend/firestore.indexes.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,60 @@
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+
{
29+
"fieldPath": "__name__",
30+
"order": "ASCENDING"
31+
}
32+
]
33+
},
34+
{
35+
"collectionGroup": "schemes",
36+
"queryScope": "COLLECTION",
37+
"fields": [
38+
{
39+
"fieldPath": "planning_area",
40+
"arrayConfig": "CONTAINS"
41+
},
42+
{
43+
"fieldPath": "last_scraped_update",
44+
"order": "DESCENDING"
45+
},
46+
{
47+
"fieldPath": "__name__",
48+
"order": "ASCENDING"
49+
}
50+
]
51+
},
52+
{
53+
"collectionGroup": "schemes",
54+
"queryScope": "COLLECTION",
55+
"fields": [
56+
{
57+
"fieldPath": "scheme_type",
58+
"arrayConfig": "CONTAINS"
59+
},
60+
{
61+
"fieldPath": "last_scraped_update",
62+
"order": "DESCENDING"
63+
},
64+
{
65+
"fieldPath": "__name__",
66+
"order": "ASCENDING"
67+
}
68+
]
1569
}
1670
],
1771
"fieldOverrides": []

backend/functions/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from firebase_functions import https_fn, options
5454
from loguru import logger
5555
from new_scheme.trigger_new_scheme_pipeline import on_new_scheme_entry # noqa: F401
56+
from schemes.catalog import catalog # noqa: F401
5657
from schemes.schemes import schemes # noqa: F401
5758
from schemes.search import schemes_search # noqa: F401
5859
from schemes.search_queries import retrieve_search_queries # noqa: F401
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
"""
2+
Handler for catalog endpoint
3+
4+
URL for local testing:
5+
http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog
6+
http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog?agency=<agency>
7+
http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog?area=<area>
8+
http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog?scheme_type=<scheme_type>
9+
"""
10+
11+
import json
12+
from dataclasses import asdict, dataclass
13+
from typing import Callable
14+
15+
from fb_manager.firebaseManager import FirebaseManager
16+
from firebase_functions import https_fn, options
17+
from google.cloud.firestore_v1 import FieldFilter
18+
from loguru import logger
19+
from utils.auth import verify_auth_token
20+
from utils.catalog_pagination import PaginationResult, get_paginated_results
21+
from utils.cors_config import get_cors_headers, handle_cors_preflight
22+
from utils.json_utils import safe_json_dumps
23+
from werkzeug.datastructures import MultiDict
24+
25+
26+
DEFAULT_LIMIT = 10
27+
28+
29+
@dataclass(frozen=True)
30+
class CatalogFilterSpec:
31+
"""Describes how a supported catalog query param maps to Firestore."""
32+
33+
firestore_field: str
34+
operator: str
35+
normalize: Callable[[str], str]
36+
37+
38+
@dataclass(kw_only=True)
39+
class CatalogRequestParams:
40+
"""Parsed catalog request parameters with an optional active filter."""
41+
42+
limit: int = DEFAULT_LIMIT
43+
cursor: str | None = None
44+
filter_name: str | None = None
45+
filter_value: str | None = None
46+
47+
48+
FILTER_SPECS = {
49+
"agency": CatalogFilterSpec(
50+
firestore_field="agency",
51+
operator="==",
52+
normalize=lambda value: value.title(),
53+
),
54+
"area": CatalogFilterSpec(
55+
firestore_field="planning_area",
56+
operator="array_contains",
57+
normalize=lambda value: value.upper(),
58+
),
59+
"scheme_type": CatalogFilterSpec(
60+
firestore_field="scheme_type",
61+
operator="array_contains",
62+
normalize=lambda value: value,
63+
),
64+
}
65+
ALLOWED_QUERY_PARAMS = set(FILTER_SPECS) | {"limit", "cursor", "is_warmup", "sort"}
66+
67+
68+
def create_firebase_manager() -> FirebaseManager:
69+
"""Factory function to create a FirebaseManager instance."""
70+
71+
return FirebaseManager()
72+
73+
74+
def _supported_catalog_query_message() -> str:
75+
"""Return the standard validation error for supported catalog query shapes."""
76+
77+
supported_queries = ["/catalog", *[f"/catalog?{name}=<{name}>" for name in FILTER_SPECS]]
78+
return f"Error parsing query parameters; only {', '.join(supported_queries)} are supported"
79+
80+
81+
@https_fn.on_request(region="asia-southeast1", memory=options.MemoryOption.GB_1)
82+
def catalog(req: https_fn.Request) -> https_fn.Response:
83+
"""
84+
Handler for catalog endpoint
85+
86+
Args:
87+
req (https_fn.Request): request sent from client
88+
89+
Returns:
90+
https_fn.Response: response sent to client
91+
"""
92+
# Handle CORS preflight request
93+
if req.method == "OPTIONS":
94+
return handle_cors_preflight(req)
95+
96+
# Get standard CORS headers for all other requests
97+
headers = get_cors_headers(req)
98+
99+
# Verify authentication
100+
is_valid, auth_message = verify_auth_token(req)
101+
if not is_valid:
102+
return https_fn.Response(
103+
response=json.dumps({"error": f"Authentication failed: {auth_message}"}),
104+
status=401,
105+
mimetype="application/json",
106+
headers=headers,
107+
)
108+
109+
firebase_manager = create_firebase_manager()
110+
111+
if not req.method == "GET":
112+
return https_fn.Response(
113+
response=json.dumps({"error": "Invalid request method; only GET is supported"}),
114+
status=405,
115+
mimetype="application/json",
116+
headers=headers,
117+
)
118+
119+
# Check if this is a warmup request from the query parameters
120+
is_warmup = req.args.get("is_warmup", "false").lower() == "true"
121+
122+
if is_warmup:
123+
return https_fn.Response(
124+
response=json.dumps({"message": "Warmup request received"}),
125+
status=200,
126+
mimetype="application/json",
127+
headers=headers,
128+
)
129+
130+
try:
131+
query_params = _parse_query_params(req.args)
132+
except ValueError as e:
133+
logger.exception("Error parsing query parameters", e)
134+
return https_fn.Response(
135+
response=json.dumps(
136+
{
137+
"error": _supported_catalog_query_message()
138+
}
139+
),
140+
status=400,
141+
mimetype="application/json",
142+
headers=headers,
143+
)
144+
145+
try:
146+
results = _handle_catalog_request(firebase_manager, query_params)
147+
except Exception as e:
148+
logger.exception("Unable to fetch scheme from firestore", e)
149+
return https_fn.Response(
150+
response=json.dumps({"error": "Internal server error, unable to fetch scheme from firestore"}),
151+
status=500,
152+
mimetype="application/json",
153+
headers=headers,
154+
)
155+
156+
if results.data is None or len(results.data) == 0:
157+
return https_fn.Response(
158+
response=json.dumps({"error": "No scheme found"}),
159+
status=404,
160+
mimetype="application/json",
161+
headers=headers,
162+
)
163+
164+
return https_fn.Response(
165+
response=safe_json_dumps(asdict(results)),
166+
status=200,
167+
mimetype="application/json",
168+
headers=headers,
169+
)
170+
171+
172+
def _parse_query_params(query_params: MultiDict[str, str]) -> CatalogRequestParams:
173+
"""
174+
Parse request query parameters into CatalogRequestParams.
175+
176+
Supported:
177+
- /catalog
178+
- /catalog?agency=<name>
179+
- /catalog?area=<name>
180+
- /catalog?scheme_type=<name>
181+
182+
Raises:
183+
ValueError: If unsupported query parameters are provided.
184+
"""
185+
186+
# Validate unknown query parameters
187+
unknown_params = set(query_params.keys()) - ALLOWED_QUERY_PARAMS
188+
if unknown_params:
189+
raise ValueError(f"Unsupported query parameter(s): {', '.join(sorted(unknown_params))}")
190+
191+
selected_filters = [name for name in FILTER_SPECS if query_params.get(name)]
192+
if len(selected_filters) > 1:
193+
raise ValueError(f"Invalid query; {', '.join(repr(name) for name in selected_filters)} cannot be used together")
194+
195+
# Retrieve limit and cursor from query parameters
196+
limit = int(query_params.get("limit", DEFAULT_LIMIT))
197+
cursor = query_params.get("cursor", "")
198+
199+
if not selected_filters:
200+
return CatalogRequestParams(limit=limit, cursor=cursor)
201+
202+
filter_name = selected_filters[0]
203+
raw_value = query_params.get(filter_name, "")
204+
if not raw_value.strip():
205+
raise ValueError(f"Invalid query; '{filter_name}' must be a non-empty value")
206+
207+
spec = FILTER_SPECS[filter_name]
208+
return CatalogRequestParams(
209+
filter_name=filter_name,
210+
filter_value=spec.normalize(raw_value.strip()),
211+
limit=limit,
212+
cursor=cursor,
213+
)
214+
215+
216+
def _handle_catalog_request(
217+
firebase_manager: FirebaseManager,
218+
query_params: CatalogRequestParams,
219+
) -> PaginationResult:
220+
"""Retrieve catalog entries with optional filter-based pagination.
221+
222+
Args:
223+
firebase_manager: Firebase manager providing Firestore access.
224+
query_params: Parsed catalog parameters, including any active filter.
225+
226+
Returns:
227+
PaginationResult:
228+
data: Documents for the current page.
229+
next_cursor: Cursor for the next page, or None if exhausted.
230+
has_more: Whether more results exist.
231+
"""
232+
col = firebase_manager.firestore_client.collection("schemes")
233+
234+
if not query_params.filter_name or query_params.filter_value is None:
235+
return get_paginated_results(
236+
collection_ref=col,
237+
cursor=query_params.cursor,
238+
limit=query_params.limit,
239+
)
240+
241+
spec = FILTER_SPECS[query_params.filter_name]
242+
query = col.where(filter=FieldFilter(spec.firestore_field, spec.operator, query_params.filter_value))
243+
244+
return get_paginated_results(
245+
collection_ref=col,
246+
base_query=query,
247+
cursor=query_params.cursor,
248+
limit=query_params.limit,
249+
)

0 commit comments

Comments
 (0)