Skip to content

Commit 2b4a681

Browse files
Refactor Endpoints: Introduce Strategy Pattern for Params and DRY List Logic
- **Strategies:** Introduced `MappingParamProcessor` and `ParamRule` for declarative parameter processing, and `StudyKeyStrategy` (`Keep`/`Pop`) for handling study key extraction. - **Refactoring:** Updated `ParamMixin` to use `StudyKeyStrategy`, replacing `_pop_study_filter` boolean logic. - **Cleanup:** Refactored `UsersParamProcessor` and `RecordsParamProcessor` to use `MappingParamProcessor`. Updated `UsersEndpoint` and `RecordsEndpoint` configuration. - **DRY:** Extracted `_create_paginator_and_parser` in `ListEndpointMixin` to unify setup logic for sync and async list operations. - **Safety:** Maintained backward compatibility for legacy endpoints not explicitly updated. Verified with full test suite passing. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 916f080 commit 2b4a681

6 files changed

Lines changed: 178 additions & 70 deletions

File tree

imednet/core/endpoint/mixins/bases.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from imednet.core.endpoint.base import GenericEndpoint
44
from imednet.core.endpoint.edc_mixin import EdcEndpointMixin
5+
from imednet.core.endpoint.strategies import PopStudyKeyStrategy
56
from imednet.core.paginator import AsyncPaginator, Paginator # noqa: F401
67

78
from .get import FilterGetEndpointMixin, PathGetEndpointMixin
@@ -76,7 +77,7 @@ class EdcStrictListGetEndpoint(EdcListGetEndpoint[T]):
7677
Populates study key from filters and raises KeyError if missing.
7778
"""
7879

79-
_pop_study_filter = True
80+
STUDY_KEY_STRATEGY = PopStudyKeyStrategy
8081
_missing_study_exception = KeyError
8182

8283

imednet/core/endpoint/mixins/list.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Callable, Dict, Iterable, List, Optional, cast
3+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union, cast
44

55
from imednet.constants import DEFAULT_PAGE_SIZE
66
from imednet.core.endpoint.abc import EndpointABC
@@ -128,6 +128,19 @@ def _prepare_list_request(
128128
cache=cache,
129129
)
130130

131+
def _create_paginator_and_parser(
132+
self,
133+
client: Union[RequestorProtocol, AsyncRequestorProtocol],
134+
paginator_cls: Union[type[Paginator], type[AsyncPaginator]],
135+
state: ListRequestState[T],
136+
) -> Tuple[Union[Paginator, AsyncPaginator], Callable[[Any], T]]:
137+
"""Create paginator and resolve parser."""
138+
paginator = paginator_cls( # type: ignore[operator]
139+
client, state.path, params=state.params, page_size=self.PAGE_SIZE
140+
)
141+
parse_func = self._resolve_parse_func()
142+
return paginator, parse_func
143+
131144
def _list_sync(
132145
self,
133146
client: RequestorProtocol,
@@ -143,11 +156,10 @@ def _list_sync(
143156
if state.cached_result is not None:
144157
return state.cached_result
145158

146-
paginator = paginator_cls(client, state.path, params=state.params, page_size=self.PAGE_SIZE)
147-
parse_func = self._resolve_parse_func()
159+
paginator, parse_func = self._create_paginator_and_parser(client, paginator_cls, state)
148160

149161
return self._execute_sync_list(
150-
paginator,
162+
cast(Paginator, paginator),
151163
parse_func,
152164
state.study,
153165
state.has_filters,
@@ -169,11 +181,10 @@ async def _list_async(
169181
if state.cached_result is not None:
170182
return state.cached_result
171183

172-
paginator = paginator_cls(client, state.path, params=state.params, page_size=self.PAGE_SIZE)
173-
parse_func = self._resolve_parse_func()
184+
paginator, parse_func = self._create_paginator_and_parser(client, paginator_cls, state)
174185

175186
return await self._execute_async_list(
176-
paginator,
187+
cast(AsyncPaginator, paginator),
177188
parse_func,
178189
state.study,
179190
state.has_filters,

imednet/core/endpoint/mixins/params.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from __future__ import annotations
22

3-
from typing import Any, Dict, Optional, cast
3+
from typing import Any, Dict, Optional, Type, cast
44

5-
from imednet.core.endpoint.strategies import DefaultParamProcessor
5+
from imednet.core.endpoint.strategies import (
6+
DefaultParamProcessor,
7+
KeepStudyKeyStrategy,
8+
StudyKeyStrategy,
9+
)
610
from imednet.core.endpoint.structs import ParamState
711
from imednet.core.protocols import ParamProcessor
812
from imednet.utils.filters import build_filter_string
@@ -14,10 +18,28 @@ class ParamMixin:
1418
"""Mixin for handling endpoint parameters and filters."""
1519

1620
requires_study_key: bool = True
17-
_pop_study_filter: bool = False
1821
_missing_study_exception: type[Exception] = ValueError
1922

2023
PARAM_PROCESSOR_CLS: type[ParamProcessor] = DefaultParamProcessor
24+
STUDY_KEY_STRATEGY: Type[StudyKeyStrategy] = KeepStudyKeyStrategy
25+
26+
# Backward compatibility for subclasses that haven't migrated
27+
_pop_study_filter: bool = False
28+
29+
def _resolve_study_strategy(self) -> StudyKeyStrategy:
30+
"""Resolve the study key strategy."""
31+
# If the class has overridden STUDY_KEY_STRATEGY, use it.
32+
if self.STUDY_KEY_STRATEGY is not KeepStudyKeyStrategy:
33+
return self.STUDY_KEY_STRATEGY(self.requires_study_key, self._missing_study_exception)
34+
35+
# Fallback to checking legacy flag if strategy is default
36+
# But for cleaner refactor, we should assume subclasses are updated or we update them.
37+
# However, to be safe during refactor:
38+
if self._pop_study_filter:
39+
from imednet.core.endpoint.strategies import PopStudyKeyStrategy
40+
return PopStudyKeyStrategy(self.requires_study_key, self._missing_study_exception)
41+
42+
return self.STUDY_KEY_STRATEGY(self.requires_study_key, self._missing_study_exception)
2143

2244
def _resolve_params(
2345
self,
@@ -41,27 +63,14 @@ def _resolve_params(
4163
if study_key:
4264
filters["studyKey"] = study_key
4365

44-
study: Optional[str] = None
45-
if self.requires_study_key:
46-
if self._pop_study_filter:
47-
try:
48-
study = filters.pop("studyKey")
49-
except KeyError as exc:
50-
raise self._missing_study_exception(
51-
"Study key must be provided or set in the context"
52-
) from exc
53-
else:
54-
study = filters.get("studyKey")
55-
if not study:
56-
raise ValueError("Study key must be provided or set in the context")
57-
else:
58-
study = filters.get("studyKey")
59-
60-
other_filters = {k: v for k, v in filters.items() if k != "studyKey"}
66+
strategy = self._resolve_study_strategy()
67+
study, query_filters = strategy.extract(filters)
68+
69+
other_filters = {k: v for k, v in query_filters.items() if k != "studyKey"}
6170

6271
params: Dict[str, Any] = {}
63-
if filters:
64-
params["filter"] = build_filter_string(filters)
72+
if query_filters:
73+
params["filter"] = build_filter_string(query_filters)
6574
if extra_params:
6675
params.update(extra_params)
6776

imednet/core/endpoint/strategies.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
to customize how filters are processed and special parameters are extracted.
66
"""
77

8-
from typing import Any, Dict, Tuple
8+
from dataclasses import dataclass, field
9+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type
910

1011
from imednet.core.protocols import ParamProcessor
1112

@@ -28,3 +29,104 @@ def process_filters(self, filters: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict
2829
A tuple of (copy of filters, empty dict).
2930
"""
3031
return filters.copy(), {}
32+
33+
34+
@dataclass
35+
class ParamRule:
36+
"""Rule for mapping a filter key to a parameter."""
37+
38+
source: str
39+
target: str
40+
transform: Callable[[Any], Any] = field(default_factory=lambda: lambda x: x)
41+
default: Any = None
42+
skip_none: bool = True
43+
skip_falsey: bool = False
44+
45+
46+
class MappingParamProcessor(ParamProcessor):
47+
"""
48+
Declarative parameter processor.
49+
50+
Iterates over defined rules to process filters, extracting special parameters.
51+
"""
52+
53+
rules: List[ParamRule] = []
54+
55+
def process_filters(self, filters: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
56+
"""
57+
Process filters based on configured rules.
58+
59+
Args:
60+
filters: The input filters dictionary.
61+
62+
Returns:
63+
A tuple of (cleaned filters, special parameters).
64+
"""
65+
filters = filters.copy()
66+
special_params: Dict[str, Any] = {}
67+
68+
for rule in self.rules:
69+
# Pop the source key if present, otherwise use default
70+
value = filters.pop(rule.source, rule.default)
71+
72+
# If value is None and skip_none is True, skip
73+
if value is None and rule.skip_none:
74+
continue
75+
76+
transformed = rule.transform(value)
77+
78+
# If transformed value is falsey and skip_falsey is True, skip
79+
if not transformed and rule.skip_falsey:
80+
continue
81+
82+
special_params[rule.target] = transformed
83+
84+
return filters, special_params
85+
86+
87+
class StudyKeyStrategy:
88+
"""Strategy for handling study key extraction from filters."""
89+
90+
def __init__(self, requires_study_key: bool, missing_exception: Type[Exception] = ValueError):
91+
self.requires_study_key = requires_study_key
92+
self.missing_exception = missing_exception
93+
94+
def extract(self, filters: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]:
95+
"""
96+
Extract study key from filters.
97+
98+
Args:
99+
filters: The filters dictionary.
100+
101+
Returns:
102+
Tuple of (study_key, filters_for_query).
103+
"""
104+
raise NotImplementedError
105+
106+
107+
class KeepStudyKeyStrategy(StudyKeyStrategy):
108+
"""Strategy that keeps the study key in filters (validation only)."""
109+
110+
def extract(self, filters: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]:
111+
filters = filters.copy()
112+
study = filters.get("studyKey")
113+
if not study and self.requires_study_key:
114+
raise self.missing_exception("Study key must be provided or set in the context")
115+
return study, filters
116+
117+
118+
class PopStudyKeyStrategy(StudyKeyStrategy):
119+
"""Strategy that pops the study key from filters."""
120+
121+
def extract(self, filters: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]:
122+
filters = filters.copy()
123+
try:
124+
study = filters.pop("studyKey")
125+
except KeyError as exc:
126+
if self.requires_study_key:
127+
raise self.missing_exception(
128+
"Study key must be provided or set in the context"
129+
) from exc
130+
study = None
131+
132+
return study, filters

imednet/endpoints/records.py

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,23 @@
11
"""Endpoint for managing records (eCRF instances) in a study."""
22

3-
from typing import Any, Dict, List, Optional, Tuple, Union
3+
from typing import Any, Dict, List, Optional, Union
44

55
from imednet.constants import HEADER_EMAIL_NOTIFY
66
from imednet.core.endpoint.mixins import CreateEndpointMixin, EdcListGetEndpoint
7-
from imednet.core.protocols import ParamProcessor
7+
from imednet.core.endpoint.strategies import (
8+
KeepStudyKeyStrategy,
9+
MappingParamProcessor,
10+
ParamRule,
11+
)
812
from imednet.models.jobs import Job
913
from imednet.models.records import Record
1014
from imednet.validation.cache import SchemaCache, validate_record_data
1115

1216

13-
class RecordsParamProcessor(ParamProcessor):
17+
class RecordsParamProcessor(MappingParamProcessor):
1418
"""Parameter processor for Records endpoint."""
1519

16-
def process_filters(self, filters: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
17-
"""
18-
Extract 'record_data_filter' parameter.
19-
20-
Args:
21-
filters: The filters dictionary.
22-
23-
Returns:
24-
Tuple of (cleaned filters, special parameters).
25-
"""
26-
filters = filters.copy()
27-
record_data_filter = filters.pop("record_data_filter", None)
28-
special_params = {}
29-
if record_data_filter:
30-
special_params["recordDataFilter"] = record_data_filter
31-
return filters, special_params
20+
rules = [ParamRule(source="record_data_filter", target="recordDataFilter")]
3221

3322

3423
class RecordsEndpoint(EdcListGetEndpoint[Record], CreateEndpointMixin[Job]):
@@ -41,7 +30,7 @@ class RecordsEndpoint(EdcListGetEndpoint[Record], CreateEndpointMixin[Job]):
4130
PATH = "records"
4231
MODEL = Record
4332
_id_param = "recordId"
44-
_pop_study_filter = False
33+
STUDY_KEY_STRATEGY = KeepStudyKeyStrategy
4534
PARAM_PROCESSOR_CLS = RecordsParamProcessor
4635

4736
def _prepare_create_request(

imednet/endpoints/users.py

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
11
"""Endpoint for managing users in a study."""
22

3-
from typing import Any, Dict, Tuple
4-
53
from imednet.core.endpoint.mixins import EdcListGetEndpoint
6-
from imednet.core.protocols import ParamProcessor
4+
from imednet.core.endpoint.strategies import (
5+
MappingParamProcessor,
6+
ParamRule,
7+
PopStudyKeyStrategy,
8+
)
79
from imednet.models.users import User
810

911

10-
class UsersParamProcessor(ParamProcessor):
12+
class UsersParamProcessor(MappingParamProcessor):
1113
"""Parameter processor for Users endpoint."""
1214

13-
def process_filters(self, filters: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
14-
"""
15-
Extract 'include_inactive' parameter.
16-
17-
Args:
18-
filters: The filters dictionary.
19-
20-
Returns:
21-
Tuple of (cleaned filters, special parameters).
22-
"""
23-
filters = filters.copy()
24-
include_inactive = filters.pop("include_inactive", False)
25-
special_params = {"includeInactive": str(include_inactive).lower()}
26-
return filters, special_params
15+
rules = [
16+
ParamRule(
17+
source="include_inactive",
18+
target="includeInactive",
19+
default=False,
20+
transform=lambda x: str(x).lower(),
21+
)
22+
]
2723

2824

2925
class UsersEndpoint(EdcListGetEndpoint[User]):
@@ -36,5 +32,5 @@ class UsersEndpoint(EdcListGetEndpoint[User]):
3632
PATH = "users"
3733
MODEL = User
3834
_id_param = "userId"
39-
_pop_study_filter = True
35+
STUDY_KEY_STRATEGY = PopStudyKeyStrategy
4036
PARAM_PROCESSOR_CLS = UsersParamProcessor

0 commit comments

Comments
 (0)