Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
6384f53
Add is_public field to caching
kathryn-dale Feb 12, 2026
c076561
Update tests
kathryn-dale Feb 12, 2026
dadfa62
Add non public cache bypass
kathryn-dale Feb 17, 2026
b41d7dd
Update testing
kathryn-dale Feb 17, 2026
b76ec72
Fix - rename duplicate handle_exceptions method
kathryn-dale Feb 17, 2026
c66a137
linting
kathryn-dale Feb 17, 2026
c156a21
linting
kathryn-dale Feb 17, 2026
b601d14
Minor test fix for consistency
kathryn-dale Feb 19, 2026
9f3bc57
Update cache-control for public api
kathryn-dale Mar 5, 2026
999d61c
Update caching
kathryn-dale Mar 6, 2026
8684ab6
update public api v2
kathryn-dale Mar 9, 2026
dde1e9e
TESTING ONLY: Remove data request restrictions for caching testing
kathryn-dale Mar 9, 2026
834cfda
Remove is_public query param check
kathryn-dale Mar 11, 2026
9a6155b
Update based on validation work
kathryn-dale Mar 11, 2026
3334ca0
linting
kathryn-dale Mar 11, 2026
4ea3e9c
Update validation
kathryn-dale Mar 11, 2026
35f78c9
Add django-cognito-jwt files
mattjreynolds Mar 9, 2026
525587a
Fix failing jwt tests
mattjreynolds Mar 9, 2026
5bf602d
Resolve linting issues with jwt
mattjreynolds Mar 9, 2026
d71b665
Make aud check optional for jwt
mattjreynolds Mar 9, 2026
48dcaa8
Use custom x-UHD-AUTH header for token, add requirements
mattjreynolds Mar 18, 2026
71567c2
Add cognito user manager, use env vars
mattjreynolds Mar 23, 2026
8607fe7
Resolve gap in test coverage
mattjreynolds Mar 23, 2026
cb19700
Resolve intermittent test failure triggered by random ordering
mattjreynolds Mar 23, 2026
95ac503
Ignore invalid sonar rule
mattjreynolds Mar 23, 2026
feb9651
Linting
mattjreynolds Mar 23, 2026
8b8bf22
Move Sonar ignore to correct line after linting broke it
mattjreynolds Mar 23, 2026
8de1b86
Ignore invalid sonar rule
mattjreynolds Mar 23, 2026
a229750
Add comment explaining constant
mattjreynolds Mar 24, 2026
affd9a9
Use jwt validation
kathryn-dale Mar 25, 2026
37f92e1
Update decorator
kathryn-dale Mar 25, 2026
a128bed
Update tests
kathryn-dale Mar 26, 2026
fea7423
Update linting errors
kathryn-dale Mar 26, 2026
78ac873
Update imports to match location change
kathryn-dale Mar 31, 2026
3e80b4f
remove old test files
kathryn-dale Mar 31, 2026
82f45ac
test updating sonar-project file
kathryn-dale Mar 31, 2026
e858469
attempt 2
kathryn-dale Mar 31, 2026
fcb0de9
super test
kathryn-dale Mar 31, 2026
749ef2b
Final test
kathryn-dale Mar 31, 2026
9429988
fully exclude tests?
kathryn-dale Mar 31, 2026
22a4bcd
specific file names might work?
kathryn-dale Mar 31, 2026
fc0cd53
Add clarity to decorator function
kathryn-dale Apr 1, 2026
96c26cf
Try new sonarcloud file
kathryn-dale Apr 1, 2026
fce4ab2
test it's running properly by forcing failure
kathryn-dale Apr 1, 2026
87640b6
Update sonarqube file
kathryn-dale Apr 1, 2026
25ce032
Remove testing change
kathryn-dale Apr 1, 2026
a5064f6
remove comment
kathryn-dale Apr 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions sonar-project.properties → .sonarcloud.properties
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ sonar.organization=ukhsa-internal

# Exclude other generated/auto-generated files
sonar.exclusions=**/migrations/**,**/__pycache__/**

# Exclude files from duplication check
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provide justification for this here in the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test files have to contain much duplication, as the top base file for both versions of the public api are incredibly similar, meaning that they require incredibly similar tests. These tests are almost full duplicates of each other, but are required to reach the 100% code coverage set. It was agreed with Phill and Josh to exclude these files from the sonarqube duplication check for this reason

Copy link
Copy Markdown

@jeanpierrefouche-ukhsa jeanpierrefouche-ukhsa Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, no probs. Adding it into the code comment on line 13 it will be best, as that allows future readers to see the rationale.

sonar.cpd.exclusions=tests/unit/public_api/views/test_base.py,tests/unit/public_api/v2/views/test_base.py
52 changes: 45 additions & 7 deletions caching/private_api/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from rest_framework.response import Response

from caching.private_api.management import CacheManagement, CacheMissError
from common.auth.cognito_jwt import backend


class CacheCheckResultedInMissError(Exception): ...
Expand All @@ -14,7 +15,11 @@ def is_caching_v2_enabled() -> bool:
return os.environ.get("CACHING_V2_ENABLED", "").lower() in {"true", "1"}


def cache_response(*, timeout: int | None = None, is_reserved_namespace: bool = False):
def cache_response(
*,
timeout: int | None = None,
is_reserved_namespace: bool = False,
):
"""Decorator to wrap API views to use a previously cached response. Otherwise, calculate and save on the way out.

Notes:
Expand Down Expand Up @@ -49,17 +54,33 @@ def cache_response(*, timeout: int | None = None, is_reserved_namespace: bool =
def decorator(view_function):
@wraps(view_function)
def wrapped_view(*args, **kwargs) -> Response:

request = args[1]
is_public = not (_check_if_valid_non_public_request(request=request))

return _retrieve_response_from_cache_or_calculate(
view_function, timeout, is_reserved_namespace, *args, **kwargs
view_function,
timeout,
is_reserved_namespace,
is_public,
*args,
**kwargs,
)

return wrapped_view

return decorator


def _check_if_valid_non_public_request(request) -> bool:
auth = backend.JSONWebTokenAuthentication()
result = auth.authenticate(request)

return result is not None


def _retrieve_response_from_cache_or_calculate(
view_function, timeout, is_reserved_namespace, *args, **kwargs
view_function, timeout, is_reserved_namespace, is_public, *args, **kwargs
) -> Response:
"""Gets the response from the cache, otherwise recalculates from the view

Expand All @@ -68,6 +89,10 @@ def _retrieve_response_from_cache_or_calculate(
then the response will always be recalculated from the server
and no caching will take place.

If data is not public (i.e. is_public is set to "false"), then the
response will always be recalculated from the server and no caching
will take place.

Args:
view_function: The view associated with the endpoint
timeout: The number of seconds after which the response is expired
Expand All @@ -83,9 +108,15 @@ def _retrieve_response_from_cache_or_calculate(

"""
request: Request = args[1]
if not is_public:
return _calculate_response_from_view(
view_function, *args, is_public=is_public, **kwargs
)

if is_caching_v2_enabled() and not is_reserved_namespace:
return _calculate_response_from_view(view_function, *args, **kwargs)
return _calculate_response_from_view(
view_function, *args, is_public=is_public, **kwargs
)

cache_management = kwargs.pop(
"cache_management",
Expand Down Expand Up @@ -114,7 +145,9 @@ def _retrieve_response_from_cache_or_calculate(
def _calculate_response_and_save_in_cache(
view_function, timeout, cache_management, cache_entry_key, *args, **kwargs
) -> Response:
response: Response = _calculate_response_from_view(view_function, *args, **kwargs)
response: Response = _calculate_response_from_view(
view_function, *args, is_public=True, **kwargs
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a concern about the assumption that is_public is being derived from authentication context (e.g. JWT presence).

It feels like the current logic effectively treats:
JWT present => non-public => not cached

This may be an oversimplification of the data classification model. Authentication status does not necessarily define whether data is public or private, and conflating these concepts could lead to incorrect caching behaviour or unintended data exposure patterns.

More generally, caching decisions (Cache-Control) and data sensitivity classification (public/private) should ideally be derived from the data itself (or an explicit domain-level metadata flag), rather than inferred from request/auth state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So only non-public requests will have a JWT, all public requests will not. This is the design decision made by the non public team at the outset of this ticket. I agree that the data itself should be checked, but this work is only for updating the caching element, not actually fetching non public data. As it's been agreed that only non-public requests will send a JWT, it was decided that it was the most straightforward approach to determine if the response should be cached or not :)

)
if timeout == 0:
return response

Expand All @@ -124,5 +157,10 @@ def _calculate_response_and_save_in_cache(
return response


def _calculate_response_from_view(view_function, *args, **kwargs) -> Response:
return view_function(*args, **kwargs)
def _calculate_response_from_view(
view_function, *args, is_public: bool = True, **kwargs
) -> Response:
response = view_function(*args, **kwargs)
if not (is_public):
response["Cache-Control"] = "private, no-cache"
return response
10 changes: 9 additions & 1 deletion public_api/version_02/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from rest_framework.request import Request
from rest_framework.response import Response

from common.auth.cognito_jwt import backend
from public_api.metrics_interface.interface import MetricsPublicAPIInterface
from public_api.version_02.serializers.api_time_series_request_serializer import (
APITimeSeriesDTO,
Expand Down Expand Up @@ -39,4 +40,11 @@ def get(self, request: Request, *args, **kwargs) -> Response:
)

serializer = self.get_serializer(timeseries_dto_slice, many=True)
return Response(serializer.data)
response = Response(data=serializer.data)

auth = backend.JSONWebTokenAuthentication()
is_valid_non_public_request = auth.authenticate(request)
if is_valid_non_public_request:
response["Cache-Control"] = "private, no-cache"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Evaluate whether Cache-Control: no-store is required here for sensitive/user-specific responses.
Also ensure CloudFront cache policy aligns with the intended no-caching behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting to "private, no-cache" was agreed to after a review of the documentation and ensuring that it aligned with what we are trying to achieve. @luketowell can you confirm if you are happy with "private, no-cache" or if you'd prefer we use "no-store"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider centralising Cache-Control policy strings into a shared constants/module (or system settings if these vary by environment).
This would avoid duplication and reduce the risk of inconsistent cache behaviour across endpoints.

For example:

response["Cache-Control"] = system_settings.CACHE_POLICY_NO_CACHE

This makes it easier to audit and update caching policy centrally.


return response
11 changes: 10 additions & 1 deletion public_api/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from rest_framework.request import Request
from rest_framework.response import Response

from common.auth.cognito_jwt import backend
from public_api.metrics_interface.interface import MetricsPublicAPIInterface
from public_api.serializers.api_time_series_request_serializer import (
APITimeSeriesDTO,
Expand Down Expand Up @@ -31,6 +32,7 @@ def _build_request_serializer(

@extend_schema(tags=[PUBLIC_API_TAG])
def get(self, request: Request, *args, **kwargs) -> Response:

serializer: APITimeSeriesRequestSerializer = self._build_request_serializer(
request=request
)
Expand All @@ -39,4 +41,11 @@ def get(self, request: Request, *args, **kwargs) -> Response:
)

serializer = self.get_serializer(timeseries_dto_slice, many=True)
return Response(serializer.data)
response = Response(data=serializer.data)

auth = backend.JSONWebTokenAuthentication()
is_valid_non_public_request = auth.authenticate(request)
if is_valid_non_public_request:
response["Cache-Control"] = "private, no-cache"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Derive cache policy string centrally from shared module.
For example:

system_settings.CACHE_CONTROL_HTTP_HEADER


return response
Loading
Loading