Skip to content

Commit 128b6ba

Browse files
committed
fix: follow the next-page cursor when paginating via the request executor
The resilient list_applications/list_group_apps path fetches pages through the request executor, which returns the raw aiohttp response. extract_after_cursor only understood the typed-SDK shapes (ApiResponse.headers / OktaAPIResponse._next), so on the executor response it found no cursor and pagination stopped after the first page — listings silently truncated to one page (e.g. fetch_all returned only 20 items) on accounts/groups with more. Root cause: Okta returns 'self' and 'next' as SEPARATE Link headers, so a multidict headers.get('Link') yields only 'self'. aiohttp already parses them into .links (rel-keyed), which is what the SDK itself uses (OktaAPIResponse.extract_pagination). extract_after_cursor now reads response.links['next'] first (raw executor response), then falls back to a Link-header string via .headers / get_headers() / _resp_headers (ApiResponse and other shapes), then the SDK v2 has_next()/_next path. Adds tests for the aiohttp .links shape and the header-accessor fallbacks. Refs #48
1 parent 83b44fc commit 128b6ba

2 files changed

Lines changed: 80 additions & 5 deletions

File tree

src/okta_mcp_server/utils/pagination.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,53 @@ def extract_after_cursor(response) -> Optional[str]:
2525
Returns:
2626
str: The 'after' cursor value, or None if no next page
2727
"""
28-
# --- Okta SDK v3: ApiResponse with Link header ---
29-
if response and hasattr(response, "headers") and response.headers:
28+
# --- Raw aiohttp response (returned by the request executor) ---
29+
# The request executor returns aiohttp's response object directly. aiohttp
30+
# pre-parses the (possibly multiple) Link headers into ``.links``, a mapping
31+
# keyed by rel — e.g. ``links["next"]["url"]`` is a yarl URL. This is what the
32+
# SDK itself uses (OktaAPIResponse.extract_pagination). Reading the raw
33+
# ``headers.get("Link")`` is NOT enough: Okta sends ``self`` and ``next`` as
34+
# SEPARATE Link headers, and a multidict ``.get`` returns only the first
35+
# (self), so the next cursor would be missed.
36+
links = getattr(response, "links", None) if response is not None else None
37+
if links:
38+
try:
39+
nxt = links.get("next")
40+
url = nxt.get("url") if nxt is not None and hasattr(nxt, "get") else None
41+
if url is not None:
42+
cursor = parse_qs(urlparse(str(url)).query).get("after", [None])[0]
43+
if cursor:
44+
return cursor
45+
except Exception as e:
46+
logger.warning(f"Failed to parse aiohttp links cursor: {e}")
47+
48+
# --- Okta SDK v3: Link-header cursor ---
49+
# Resolve a headers mapping from whichever response shape we got:
50+
# * ApiResponse exposes a ``.headers`` attribute
51+
# * OktaAPIResponse (what the request executor returns) exposes headers via
52+
# ``get_headers()`` / ``_resp_headers`` and does NOT have ``.headers``
53+
# Reading both ensures the cursor is found regardless of how the page was
54+
# fetched (typed client vs. raw request executor).
55+
headers = None
56+
if response is not None:
57+
if getattr(response, "headers", None):
58+
headers = response.headers
59+
elif hasattr(response, "get_headers"):
60+
try:
61+
headers = response.get_headers()
62+
except Exception:
63+
headers = None
64+
if not headers and getattr(response, "_resp_headers", None):
65+
headers = response._resp_headers
66+
67+
if headers:
3068
link_header = ""
3169
try:
32-
link_header = response.headers.get("Link", "") or response.headers.get("link", "")
70+
link_header = headers.get("Link", "") or headers.get("link", "")
3371
except Exception:
34-
for key in response.headers:
72+
for key in headers:
3573
if key.lower() == "link":
36-
link_header = response.headers[key]
74+
link_header = headers[key]
3775
break
3876

3977
if link_header and 'rel="next"' in link_header:

tests/test_pagination.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,43 @@ def test_handles_multiple_link_rels(self):
106106
}
107107
assert extract_after_cursor(response) == "cursor99"
108108

109+
def test_reads_link_from_okta_api_response_get_headers(self):
110+
"""OktaAPIResponse (request-executor result) exposes headers via
111+
get_headers(), not a .headers attribute, and leaves _next unset — the
112+
cursor must still be extracted from the Link header."""
113+
response = MagicMock(spec=["get_headers"])
114+
response.get_headers.return_value = {
115+
"link": '<https://test.okta.com/api/v1/apps?after=execcursor1>; rel="next"'
116+
}
117+
assert extract_after_cursor(response) == "execcursor1"
118+
119+
def test_reads_link_from_resp_headers_attr(self):
120+
"""Fallback to the private _resp_headers mapping when neither .headers
121+
nor get_headers() yields a usable header set."""
122+
response = MagicMock(spec=["_resp_headers"])
123+
response._resp_headers = {
124+
"Link": '<https://test.okta.com/api/v1/apps?after=execcursor2>; rel="next"'
125+
}
126+
assert extract_after_cursor(response) == "execcursor2"
127+
128+
def test_reads_next_from_aiohttp_links(self):
129+
"""The request executor returns the raw aiohttp response, whose .links
130+
is a rel-keyed mapping ({'next': {'url': <yarl URL>}}). Okta sends self
131+
and next as separate Link headers, so this parsed mapping — not a raw
132+
headers.get('Link') — is the reliable source of the next cursor."""
133+
response = MagicMock(spec=["links"])
134+
response.links = {
135+
"self": {"url": "https://test.okta.com/api/v1/apps?limit=20"},
136+
"next": {"url": "https://test.okta.com/api/v1/apps?after=aiocursor1&limit=20"},
137+
}
138+
assert extract_after_cursor(response) == "aiocursor1"
139+
140+
def test_no_next_in_aiohttp_links_returns_none(self):
141+
"""Only a self link (last page) → no cursor."""
142+
response = MagicMock(spec=["links"])
143+
response.links = {"self": {"url": "https://test.okta.com/api/v1/apps?limit=20"}}
144+
assert extract_after_cursor(response) is None
145+
109146

110147
# ---------------------------------------------------------------------------
111148
# extract_after_cursor — SDK v2 path

0 commit comments

Comments
 (0)