Skip to content

Commit 0b08d07

Browse files
authored
add support for global paginator (#61)
* add support for global paginator * add warning for incomplete paginators
1 parent cfb1dad commit 0b08d07

20 files changed

+325
-97
lines changed

dlt_openapi/detector/default/__init__.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Default open source detector
33
"""
44

5+
import json
56
from typing import Dict, List, Optional, Tuple, Union, cast
67

78
from dlt_openapi.config import Config
@@ -40,6 +41,7 @@
4041
BaseDetectionWarning,
4142
DataResponseNoBodyWarning,
4243
DataResponseUndetectedWarning,
44+
PossiblePaginatorWarning,
4345
PrimaryKeyNotFoundWarning,
4446
UnresolvedPathParametersWarning,
4547
UnsupportedSecuritySchemeWarning,
@@ -64,6 +66,7 @@ def run(self, open_api: OpenapiParser) -> None:
6466

6567
# discover stuff from responses
6668
self.detect_paginators_and_responses(open_api.endpoints)
69+
self.detect_global_pagination(open_api)
6770

6871
# discover parent child relationship
6972
self.detect_parent_child_relationships(open_api.endpoints)
@@ -86,6 +89,7 @@ def detect_security_schemes(self, open_api: OpenapiParser) -> None:
8689
schemes = list(open_api.security_schemes.values())
8790

8891
# detect scheme settings
92+
# TODO: make this a bit nicer
8993
for scheme in schemes:
9094

9195
if scheme.type == "apiKey":
@@ -112,7 +116,7 @@ def detect_security_schemes(self, open_api: OpenapiParser) -> None:
112116

113117
# find default scheme
114118
if len(schemes) and schemes[0].supported:
115-
open_api.detected_default_security_scheme = schemes[0]
119+
open_api.detected_global_security_scheme = schemes[0]
116120
elif len(schemes) and not schemes[0].supported:
117121
self._add_warning(UnsupportedSecuritySchemeWarning(schemes[0].name))
118122

@@ -200,6 +204,40 @@ def detect_paginators_and_responses(self, endpoints: EndpointCollection) -> None
200204
)
201205
self.detect_primary_key(endpoint, endpoint.detected_data_response, endpoint.path)
202206

207+
def detect_global_pagination(self, open_api: OpenapiParser) -> None:
208+
"""go through all detected paginators and see which one we can set as global"""
209+
paginator_by_key: Dict[str, Pagination] = {}
210+
paginator_count: Dict[str, int] = {}
211+
212+
# count how many every paginator appears
213+
for endpoint in open_api.endpoints.endpoints:
214+
if not endpoint.detected_pagination:
215+
continue
216+
params = endpoint.detected_pagination.paginator_config
217+
key = json.dumps(params, sort_keys=True)
218+
paginator_by_key[key] = endpoint.detected_pagination
219+
paginator_count.setdefault(key, 0)
220+
paginator_count[key] += 1
221+
222+
# no paginators found
223+
if len(paginator_by_key) == 0:
224+
return
225+
226+
# sort dict by value descending, so most used paginator is at the top
227+
sorted_paginator_count = sorted(paginator_count.items(), key=lambda item: item[1] * -1)
228+
229+
# we only set a global paginator, if we found one paginator, or if the top paginator has
230+
# a higher count than the second most used one
231+
if not (len(paginator_by_key) == 1 or sorted_paginator_count[0][1] > sorted_paginator_count[1][1]):
232+
return
233+
234+
global_paginator = paginator_by_key[sorted_paginator_count[0][0]]
235+
236+
# set global paginator on base object but also set on all endpoints
237+
open_api.detected_global_pagination = global_paginator
238+
for e in open_api.endpoints.endpoints:
239+
e.detected_global_pagination = global_paginator
240+
203241
def detect_primary_key(self, e: Endpoint, response: Response, path: str) -> None:
204242
"""detect the primary key from the payload"""
205243
if not response.detected_payload:
@@ -417,6 +455,10 @@ def detect_pagination(self, endpoint: Endpoint) -> Optional[Pagination]:
417455
#
418456
# Nothing found :(
419457
#
458+
pagination_params = [*cursor_params, *offset_params, *limit_params, *page_params]
459+
if pagination_params:
460+
self._add_warning(PossiblePaginatorWarning([p.name for p in pagination_params]), endpoint)
461+
420462
return None
421463

422464
def detect_parent_child_relationships(self, endpoints: EndpointCollection) -> None:

dlt_openapi/detector/default/warnings.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class UnresolvedPathParametersWarning(BaseDetectionWarning):
1111

1212
def __init__(self, params: List[str]) -> None:
1313
self.params = params
14-
self.msg = f"Could not resolve all path params, setting default values for: {','.join(params)}"
14+
self.msg = f"Could not resolve all path params, setting default values for: {', '.join(params)}"
1515

1616

1717
class DataResponseUndetectedWarning(BaseDetectionWarning):
@@ -35,3 +35,12 @@ def __init__(self, security_scheme: str) -> None:
3535
f"Security Scheme {security_scheme} is not supported natively at this time. "
3636
+ "Please provide a custom implementation."
3737
)
38+
39+
40+
class PossiblePaginatorWarning(BaseDetectionWarning):
41+
def __init__(self, params: List[str]) -> None:
42+
self.params = params
43+
self.msg = (
44+
"Found params that suggest this endpoint is paginated, but could not discover pagination mechnanism. "
45+
+ f"Params: {', '.join(params)}"
46+
)

dlt_openapi/parser/endpoints.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class Endpoint:
5454

5555
# detected values
5656
detected_pagination: Optional[Pagination] = None
57+
detected_global_pagination: Optional[Pagination] = None
5758
detected_data_response: Optional[Response] = None
5859
detected_resource_name: Optional[str] = None
5960
detected_table_name: Optional[str] = None
@@ -89,7 +90,10 @@ def list_all_parameters(self) -> List[Parameter]:
8990
return list(self.parameters.values())
9091

9192
@property
92-
def pagination_args(self) -> Optional[Dict[str, Union[str, int]]]:
93+
def render_pagination_args(self) -> Optional[Dict[str, Union[str, int]]]:
94+
# if own paginator equals global paginator, do not render anything
95+
if self.detected_pagination == self.detected_global_pagination:
96+
return None
9397
return self.detected_pagination.paginator_config if self.detected_pagination else None
9498

9599
@property

dlt_openapi/parser/openapi_parser.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from dlt_openapi.parser.context import OpenapiContext
1313
from dlt_openapi.parser.endpoints import EndpointCollection
1414
from dlt_openapi.parser.info import OpenApiInfo
15+
from dlt_openapi.parser.pagination import Pagination
1516
from dlt_openapi.parser.security import SecurityScheme
1617

1718

@@ -21,7 +22,8 @@ class OpenapiParser:
2122
endpoints: EndpointCollection = None
2223
security_schemes: Dict[str, SecurityScheme] = {}
2324

24-
detected_default_security_scheme: SecurityScheme = None
25+
detected_global_security_scheme: SecurityScheme = None
26+
detected_global_pagination: Pagination = None
2527

2628
def __init__(self, config: Config) -> None:
2729
self.config = config

dlt_openapi/parser/pagination.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,8 @@ class Pagination:
1414
def param_names(self) -> List[str]:
1515
"""All params used for pagination"""
1616
return [param.name for param in self.pagination_params]
17+
18+
def __eq__(self, other: object) -> bool:
19+
if isinstance(other, Pagination):
20+
return other.paginator_config == self.paginator_config
21+
return False

dlt_openapi/renderer/default/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,12 @@ def _render_source(self) -> str:
125125
source_name=self.source_name,
126126
endpoint_collection=self.openapi.endpoints,
127127
imports=[],
128-
credentials=self.openapi.detected_default_security_scheme,
128+
credentials=self.openapi.detected_global_security_scheme,
129+
global_paginator_config=(
130+
self.openapi.detected_global_pagination.paginator_config
131+
if self.openapi.detected_global_pagination
132+
else None
133+
),
129134
)
130135

131136
def _build_pipeline(self) -> None:

dlt_openapi/renderer/default/templates/source.py.j2

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ def {{ source_name }}(
2424
{{ credentials.detected_auth_vars }}
2525
},
2626
{% endif %}
27+
{% if global_paginator_config %}
28+
"paginator": {
29+
{% for key, value in global_paginator_config.items() %}
30+
"{{key}}":
31+
{% if value is integer %}
32+
{{value}},
33+
{% else %}
34+
"{{value}}",
35+
{% endif %}
36+
{% endfor %}
37+
},
38+
{% endif %}
2739
},
2840
"resources":
2941
[
@@ -60,9 +72,9 @@ def {{ source_name }}(
6072
{% endfor %}
6173
},
6274
{% endif %}
63-
{% if endpoint.pagination_args %}
75+
{% if endpoint.render_pagination_args %}
6476
"paginator": {
65-
{% for key, value in endpoint.pagination_args.items() %}
77+
{% for key, value in endpoint.render_pagination_args.items() %}
6678
"{{key}}":
6779
{% if value is integer %}
6880
{{value}},
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
openapi: 3.0.0
2+
info:
3+
title: 'pagination'
4+
version: 1.0.0
5+
description: 'pagination example with global pagination'
6+
servers:
7+
- url: 'https://pokeapi.co/'
8+
9+
paths:
10+
11+
/collection_1/:
12+
get:
13+
operationId: collection_1
14+
parameters:
15+
- in: query
16+
name: cursor
17+
schema:
18+
description: Put cursor here
19+
title: Cursor
20+
type: string
21+
responses:
22+
'200':
23+
description: "OK"
24+
content:
25+
application/json:
26+
schema:
27+
type: object
28+
properties:
29+
cursor:
30+
type: string
31+
results:
32+
type: array
33+
34+
/collection_2/:
35+
get:
36+
operationId: collection_2
37+
parameters:
38+
- in: query
39+
name: cursor
40+
schema:
41+
description: Put cursor here
42+
title: Cursor
43+
type: string
44+
responses:
45+
'200':
46+
description: "OK"
47+
content:
48+
application/json:
49+
schema:
50+
type: object
51+
properties:
52+
cursor:
53+
type: string
54+
other_value:
55+
type: array
56+
57+
58+
/collection_other_paginator/:
59+
get:
60+
operationId: collection_other_paginator
61+
parameters:
62+
- in: query
63+
name: page
64+
schema:
65+
type: string
66+
responses:
67+
'200':
68+
description: "OK"
69+
content:
70+
application/json:
71+
schema:
72+
type: object
73+
properties:
74+
count:
75+
type: integer
76+
example: 3
77+
78+
/item_endpoint/{some_id}:
79+
get:
80+
operationId: item_endpoint
81+
responses:
82+
'200':
83+
description: "OK"
84+
content:
85+
application/json:
86+
schema:
87+
type: object
88+
properties:
89+
id:
90+
type: string
91+
name:
92+
type: str

tests/cases/artificial_specs/pagination.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,28 @@ paths:
149149
properties:
150150
count:
151151
type: integer
152-
example: 3
152+
example: 3
153+
154+
155+
/cursor_pagination_incomplete/:
156+
get:
157+
operationId: cursor_pagination_incomplete
158+
parameters:
159+
- in: query
160+
name: cursor
161+
schema:
162+
description: Put cursor here
163+
title: Cursor
164+
type: string
165+
responses:
166+
'200':
167+
description: "OK"
168+
content:
169+
application/json:
170+
schema:
171+
type: object
172+
properties:
173+
other_value:
174+
type: string
175+
results:
176+
type: array

tests/integration/basics/test_paginator.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import pytest
44

55
from dlt_openapi.config import Config
6-
from tests.integration.utils import get_indexed_resources
6+
from dlt_openapi.detector.default.warnings import PossiblePaginatorWarning
7+
from tests.integration.utils import get_dict_by_case, get_indexed_resources, get_project_by_case
78

89

910
@pytest.fixture(scope="module")
@@ -57,3 +58,38 @@ def test_page_number_paginator_with_count(paginators: Dict[str, Any]) -> None:
5758
"page_param": "page",
5859
"total_path": "count",
5960
}
61+
62+
63+
def test_global_paginator() -> None:
64+
api_dict = get_dict_by_case("artificial", "global_pagination.yml", config=Config(name_resources_by_operation=True))
65+
# we have a global cursor paginator
66+
assert api_dict["client"]["paginator"] == {
67+
"type": "cursor",
68+
"cursor_path": "cursor",
69+
"cursor_param": "cursor",
70+
}
71+
72+
# check endpoint pagination settings
73+
resources: Any = {entry["name"]: entry for entry in api_dict["resources"]} # type: ignore
74+
assert not resources["item_endpoint"]["endpoint"].get("paginator")
75+
assert not resources["collection_1"]["endpoint"].get("paginator")
76+
assert not resources["collection_2"]["endpoint"].get("paginator")
77+
78+
# there is a different paginator on the diverging endpoint
79+
assert resources["collection_other_paginator"]["endpoint"].get("paginator") == {
80+
"page_param": "page",
81+
"total_path": "count",
82+
"type": "page_number",
83+
}
84+
85+
86+
def test_incomplete_paginator_warning() -> None:
87+
project = get_project_by_case("artificial", "pagination.yml", config=Config(name_resources_by_operation=True))
88+
89+
# check if the warnings exist that we expect
90+
warnings = project.detector.get_warnings()
91+
92+
# warning for possible paginator
93+
assert len(warnings.get("cursor_pagination_incomplete")) == 2
94+
assert type(warnings.get("cursor_pagination_incomplete")[0]) == PossiblePaginatorWarning
95+
assert warnings.get("cursor_pagination_incomplete")[0].params == ["cursor"] # type: ignore

0 commit comments

Comments
 (0)