Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 9 additions & 8 deletions actiapi/v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@
"""

import logging
from collections import defaultdict
from typing import Any, Dict, List, Literal, Optional, Union

import requests

from actiapi import ActiGraphClient

tokens: defaultdict[str, Optional[str]] = defaultdict(lambda: None)
session = requests.Session()

logger = logging.getLogger(__name__)
Expand All @@ -23,6 +21,11 @@ class ActiGraphClientV3(ActiGraphClient):
BASE_URL = "https://api.actigraphcorp.com"
AUTH_API = "https://auth.actigraphcorp.com/connect/token"

def __init__(self, api_access_key: str, api_secret_key: str):
"""Initialize a client with an independent token cache."""
super().__init__(api_access_key, api_secret_key)
self._tokens: Dict[str, Optional[str]] = {}

@staticmethod
def _generate_headers(token: str, raw: bool = False):
headers = {}
Expand Down Expand Up @@ -220,17 +223,15 @@ def get_sleep_summary(
return results

def _get_single(self, request: str, scope: str):
global tokens
if tokens[scope] is None:
tokens[scope] = self._get_access_token(scope)
if self._tokens.get(scope) is None:
self._tokens[scope] = self._get_access_token(scope)
logger.info("Requesting %s", request)
headers = self._generate_headers(str(tokens[scope]))
headers = self._generate_headers(str(self._tokens[scope]))
response = session.get(self.BASE_URL + request, headers=headers, stream=False)
reply = validate_response(response)
return reply

def _get_paginated(self, request: str, scope: str):
global tokens
results = []
offset = 0
limit = 100
Expand All @@ -243,7 +244,7 @@ def _get_paginated(self, request: str, scope: str):

total_count = reply["totalCount"]
except KeyError:
tokens[scope] = None
self._tokens.pop(scope, None)
reply = self._get_single(request=paginated_request, scope=scope)
if reply is None:
break
Expand Down
119 changes: 119 additions & 0 deletions tests/test_v3_authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import json
from collections import deque
from urllib.parse import parse_qs, urlsplit

import pytest
import requests

from actiapi.v3 import ActiGraphClientV3


@pytest.fixture
def http(monkeypatch):
calls = {"auth": [], "get": [], "replies": deque()}

def request(session, method, url, **kwargs):
response = requests.Response()
response.status_code = 200
if method.upper() == "POST":
data = kwargs["data"]
calls["auth"].append((data["client_id"], data["scope"]))
payload = {"access_token": f"synthetic-{data['client_id']}-{data['scope']}"}
else:
calls["get"].append((url, kwargs["headers"]["Authorization"]))
if calls["replies"]:
payload = calls["replies"].popleft()
elif "offset=" in url:
payload = {"totalCount": 1, "items": [
{"id": 1, "downloadUrl": "https://example.invalid/synthetic"}
]}
else:
payload = {"id": 1}
response._content = json.dumps(payload).encode()
return response

def forbidden_send(*args, **kwargs):
pytest.fail("A test attempted a real HTTP request")

monkeypatch.setattr(requests.Session, "request", request)
monkeypatch.setattr(requests.Session, "send", forbidden_send)
return calls


def test_clients_use_their_own_credentials_at_the_same_scope(http):
first = ActiGraphClientV3("first", "synthetic-secret-a")
second = ActiGraphClientV3("second", "synthetic-secret-b")
for client in (first, second, first, second):
assert client.get_study_info(1) == {"id": 1}
assert [token for _, token in http["get"]] == [
"Bearer synthetic-first-CentrePoint",
"Bearer synthetic-second-CentrePoint",
"Bearer synthetic-first-CentrePoint",
"Bearer synthetic-second-CentrePoint",
]
assert http["auth"] == [("first", "CentrePoint"), ("second", "CentrePoint")]


def test_client_reuses_each_scope_independently(http):
client = ActiGraphClientV3("first", "synthetic-secret")
for _ in range(2):
for scope in ("CentrePoint", "Analytics", "DataAccess"):
client._get_single("/synthetic", scope)
assert http["auth"] == [
("first", "CentrePoint"), ("first", "Analytics"), ("first", "DataAccess")
]


@pytest.mark.parametrize("method,args,scope", [
("get_study_info", (1,), "CentrePoint"),
("get_studies", (), "CentrePoint"),
("get_study_metadata", (1,), "CentrePoint"),
("get_event_markers", (2, 1), "Analytics"),
("get_minute_summary", (2, 1), "Analytics"),
("get_daily_summary", (2, 1), "Analytics"),
("get_sleep_summary", (2, 1), "Analytics"),
("get_files", (2, 1), "DataAccess"),
])
def test_public_v3_methods_keep_their_scope(http, method, args, scope):
client = ActiGraphClientV3("wrapper", "synthetic-secret")
result = getattr(client, method)(*args)
assert result
assert http["auth"] == [("wrapper", scope)]
assert http["get"][0][1] == f"Bearer synthetic-wrapper-{scope}"


def test_pagination_reuses_token_and_preserves_page_order(http):
http["replies"].extend([
{"totalCount": 101, "items": [{"id": i} for i in range(100)]},
{"totalCount": 101, "items": [{"id": 100}]},
])
client = ActiGraphClientV3("pages", "synthetic-secret")
assert client.get_studies() == [{"id": i} for i in range(101)]
assert http["auth"] == [("pages", "CentrePoint")]
offsets = [parse_qs(urlsplit(url).query)["offset"] for url, _ in http["get"]]
assert offsets == [["0"], ["100"]]


def test_pagination_refresh_does_not_invalidate_another_client(http):
first = ActiGraphClientV3("first", "synthetic-secret-a")
second = ActiGraphClientV3("second", "synthetic-secret-b")
second.get_study_info(1)
http["replies"].extend([
{"error": "synthetic-expired-token"},
{"totalCount": 1, "items": [{"id": 1}]},
])
assert first.get_studies() == [{"id": 1}]
second.get_study_info(1)
assert http["auth"] == [
("second", "CentrePoint"), ("first", "CentrePoint"),
("first", "CentrePoint"),
]
assert http["get"][-1][1] == "Bearer synthetic-second-CentrePoint"


def test_existing_malformed_page_refresh_remains_bounded(http):
http["replies"].extend([{}, {}])
client = ActiGraphClientV3("bounded", "synthetic-secret")
with pytest.raises(KeyError, match="totalCount"):
client.get_studies()
assert len(http["get"]) == len(http["auth"]) == 2