Skip to content

Commit 4231c23

Browse files
committed
feat: caching introduced for categories endpoint
1 parent 0612a3c commit 4231c23

4 files changed

Lines changed: 157 additions & 17 deletions

File tree

‎goodmap/core_api.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,22 @@ def get_categories():
361361

362362
return jsonify({"categories": categories, "categories_help": proper_categories_help})
363363

364+
@core_api_blueprint.route("/categories-full", methods=["GET"])
365+
@spec.validate()
366+
def get_categories_full():
367+
"""Get all categories with their subcategory options in a single request.
368+
369+
Returns combined category data to reduce API calls for filter panel loading.
370+
This endpoint eliminates the need for multiple sequential requests.
371+
"""
372+
categories_data = database.get_category_data()
373+
result = []
374+
for key, options in categories_data["categories"].items():
375+
result.append(
376+
{"key": key, "name": gettext(key), "options": make_tuple_translation(options)}
377+
)
378+
return jsonify({"categories": result})
379+
364380
@core_api_blueprint.route("/languages", methods=["GET"])
365381
@spec.validate()
366382
def get_languages():

‎goodmap/db.py‎

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import os
44
import tempfile
5-
from functools import partial
5+
from functools import lru_cache, partial
66
from typing import Any
77

88
from goodmap.core import get_queried_data
@@ -20,6 +20,52 @@
2020
# it should be replaced with dynamic solution
2121

2222

23+
# ------------------------------------------------
24+
# Caching utilities for database operations
25+
# ------------------------------------------------
26+
27+
28+
@lru_cache(maxsize=32)
29+
def _cached_read_json_file(file_path: str) -> str:
30+
"""Read and cache a JSON file's raw content.
31+
32+
Uses lru_cache to avoid repeated disk I/O for frequently accessed files.
33+
Returns raw string (not dict) because lru_cache requires hashable,
34+
immutable return values to prevent cache corruption from mutations.
35+
36+
Args:
37+
file_path: Absolute path to the JSON file.
38+
39+
Returns:
40+
str: Raw file content. Caller must use json.loads() to parse.
41+
"""
42+
with open(file_path, "r") as file:
43+
return file.read()
44+
45+
46+
def _get_cached_json_data(file_path: str) -> dict[str, Any]:
47+
"""Get parsed JSON data from cache, reading from disk only on cache miss.
48+
49+
Args:
50+
file_path: Absolute path to the JSON file.
51+
52+
Returns:
53+
dict: Parsed JSON data. Note: caller should not mutate the result.
54+
"""
55+
raw_data = _cached_read_json_file(file_path)
56+
return json.loads(raw_data)
57+
58+
59+
def clear_cache():
60+
"""Clear all JSON file caches.
61+
62+
Must be called after any file modification to prevent stale reads.
63+
Automatically invoked by json_file_atomic_dump().
64+
"""
65+
_cached_read_json_file.cache_clear()
66+
logger.debug("Database caches cleared")
67+
68+
2369
def __parse_pagination_params(query):
2470
"""Extract and validate pagination parameters from query."""
2571
try:
@@ -68,6 +114,8 @@ def json_file_atomic_dump(data, file_path):
68114
temp_file.flush()
69115
os.fsync(temp_file.fileno())
70116
os.replace(temp_file.name, file_path)
117+
# Clear cache after file write to ensure fresh data on next read
118+
clear_cache()
71119

72120

73121
class PaginationHelper:
@@ -521,8 +569,9 @@ def json_db_get_categories(self):
521569

522570

523571
def json_file_db_get_categories(self):
524-
with open(self.data_file_path, "r") as file:
525-
return json.load(file)["map"]["categories"].keys()
572+
"""Retrieve category names from JSON file database (cached)."""
573+
data = _get_cached_json_data(self.data_file_path)
574+
return data["map"]["categories"].keys()
526575

527576

528577
def google_json_db_get_categories(self):
@@ -561,21 +610,29 @@ def json_db_get_category_data(self, category_type=None):
561610

562611

563612
def json_file_db_get_category_data(self, category_type=None):
564-
with open(self.data_file_path, "r") as file:
565-
data = json.load(file)["map"]
566-
if category_type:
567-
return {
568-
"categories": {category_type: data["categories"].get(category_type, [])},
569-
"categories_help": data.get("categories_help", []),
570-
"categories_options_help": {
571-
category_type: data.get("categories_options_help", {}).get(category_type, [])
572-
},
573-
}
613+
"""Retrieve category data from JSON file database (cached).
614+
615+
Args:
616+
category_type: If provided, return data for this category only.
617+
If None, return all categories.
618+
619+
Returns:
620+
dict: Contains 'categories', 'categories_help', and 'categories_options_help'.
621+
"""
622+
data = _get_cached_json_data(self.data_file_path)["map"]
623+
if category_type:
574624
return {
575-
"categories": data["categories"],
625+
"categories": {category_type: data["categories"].get(category_type, [])},
576626
"categories_help": data.get("categories_help", []),
577-
"categories_options_help": data.get("categories_options_help", {}),
627+
"categories_options_help": {
628+
category_type: data.get("categories_options_help", {}).get(category_type, [])
629+
},
578630
}
631+
return {
632+
"categories": data["categories"],
633+
"categories_help": data.get("categories_help", []),
634+
"categories_options_help": data.get("categories_options_help", {}),
635+
}
579636

580637

581638
def google_json_db_get_category_data(self, category_type=None):

‎goodmap/goodmap.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine:
7777

7878
# Use the extended db method directly (already bound by extend_db_with_goodmap_queries)
7979
try:
80-
category_data = app.db.get_category_data()
80+
category_data = app.db.get_category_data() # type: ignore[attr-defined]
8181
categories = category_data.get("categories", {})
8282
except (KeyError, AttributeError):
8383
# Handle case where categories don't exist in the data
@@ -121,7 +121,7 @@ def index():
121121
"""
122122
# Prepare location schema for frontend dynamic forms
123123
# Include full schema from Pydantic model for better type information
124-
category_data = app.db.get_category_data()
124+
category_data = app.db.get_category_data() # type: ignore[attr-defined]
125125
categories = category_data.get("categories", {})
126126

127127
# Get full JSON schema from Pydantic model

‎tests/unit_tests/test_core_api.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,73 @@ def test_category_data_endpoint_with_none_categories_options_help():
141141
assert data["categories_options_help"] == []
142142

143143

144+
# --- Categories-full endpoint tests ---
145+
146+
147+
@mock.patch("goodmap.core_api.gettext", fake_translation)
148+
def test_categories_full_endpoint_returns_proper_structure(test_app):
149+
response = test_app.get("/api/categories-full")
150+
assert response.status_code == 200
151+
data = response.json
152+
assert "categories" in data
153+
assert isinstance(data["categories"], list)
154+
155+
156+
@mock.patch("goodmap.core_api.gettext", fake_translation)
157+
def test_categories_full_endpoint_returns_categories_with_required_fields(test_app):
158+
response = test_app.get("/api/categories-full")
159+
assert response.status_code == 200
160+
data = response.json
161+
assert len(data["categories"]) > 0
162+
category = data["categories"][0]
163+
assert "key" in category
164+
assert "name" in category
165+
assert "options" in category
166+
167+
168+
@mock.patch("goodmap.core_api.gettext", fake_translation)
169+
def test_categories_full_endpoint_returns_translated_name(test_app):
170+
response = test_app.get("/api/categories-full")
171+
assert response.status_code == 200
172+
data = response.json
173+
category = data["categories"][0]
174+
assert category["key"] == "test-category"
175+
assert category["name"] == "test-category-translated"
176+
177+
178+
@mock.patch("goodmap.core_api.gettext", fake_translation)
179+
def test_categories_full_endpoint_returns_translated_options(test_app):
180+
response = test_app.get("/api/categories-full")
181+
assert response.status_code == 200
182+
data = response.json
183+
category = data["categories"][0]
184+
# Options should be translated tuples (key, translated_name)
185+
assert isinstance(category["options"], list)
186+
assert len(category["options"]) == 2
187+
assert category["options"][0] == ["test", "test-translated"]
188+
assert category["options"][1] == ["test2", "test2-translated"]
189+
190+
191+
@mock.patch("goodmap.core_api.gettext", fake_translation)
192+
def test_categories_full_endpoint_with_multiple_categories():
193+
test_app = create_test_app(
194+
db_overrides={
195+
"categories": {
196+
"category1": ["opt1", "opt2"],
197+
"category2": ["opt3"],
198+
}
199+
}
200+
)
201+
response = test_app.get("/api/categories-full")
202+
assert response.status_code == 200
203+
data = response.json
204+
assert data is not None
205+
assert len(data["categories"]) == 2
206+
keys = [cat["key"] for cat in data["categories"]]
207+
assert "category1" in keys
208+
assert "category2" in keys
209+
210+
144211
# --- Locations endpoint tests ---
145212

146213

0 commit comments

Comments
 (0)