Skip to content

Commit ca2dfc4

Browse files
committed
More fixes to migrate to Todoist v9 Sync API.
This prorgam was failing again, and it appears that this time it is because there are more changes in the Todoist v9 Sync API that it initially appeared: * GET requests are now POST * data must be passed as FormData instead of QueryString * The token must be passed in 'Authorization: Bearer ...' instead of data. For some reason this wasn't necessary 1 week ago, I guess they still kept some v8 API code which is now removed or something. Anyway, use the API properly.
1 parent a178c0c commit ca2dfc4

File tree

6 files changed

+63
-42
lines changed

6 files changed

+63
-42
lines changed

full_offline_backup_for_todoist/tests/test_integration.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,25 +29,25 @@ def setUp(self):
2929
# Set up the fake HTTP server with local responses
3030
# pylint: disable=line-too-long
3131
route_responses = {
32-
"/https://api.todoist.com/sync/v9/sync?token=mysecrettoken&sync_token=%2A&resource_types=%5B%22projects%22%5D":
32+
("POST", "/https://api.todoist.com/sync/v9/sync", b"sync_token=%2A&resource_types=%5B%22projects%22%5D", 'mysecrettoken'):
3333
Path(self.__get_test_file("sources/project_list.json")).read_bytes(),
34-
"/https://api.todoist.com/sync/v9/templates/export_as_file?token=mysecrettoken&project_id=2181147955":
34+
("POST", "/https://api.todoist.com/sync/v9/templates/export_as_file", b"project_id=2181147955", 'mysecrettoken'):
3535
Path(self.__get_test_file("sources/Project_2181147955.csv")).read_bytes(),
36-
"/https://api.todoist.com/sync/v9/templates/export_as_file?token=mysecrettoken&project_id=2181147714":
36+
("POST", "/https://api.todoist.com/sync/v9/templates/export_as_file", b"project_id=2181147714", 'mysecrettoken'):
3737
Path(self.__get_test_file("sources/Project_2181147714.csv")).read_bytes(),
38-
"/https://api.todoist.com/sync/v9/templates/export_as_file?token=mysecrettoken&project_id=2181147709":
38+
("POST", "/https://api.todoist.com/sync/v9/templates/export_as_file", b"project_id=2181147709", 'mysecrettoken'):
3939
Path(self.__get_test_file("sources/Project_2181147709.csv")).read_bytes(),
40-
"/https://api.todoist.com/sync/v9/templates/export_as_file?token=mysecrettoken&project_id=2181147715":
40+
("POST", "/https://api.todoist.com/sync/v9/templates/export_as_file", b"project_id=2181147715", 'mysecrettoken'):
4141
Path(self.__get_test_file("sources/Project_2181147715.csv")).read_bytes(),
42-
"/https://api.todoist.com/sync/v9/templates/export_as_file?token=mysecrettoken&project_id=2181147711":
42+
("POST", "/https://api.todoist.com/sync/v9/templates/export_as_file", b"project_id=2181147711", 'mysecrettoken'):
4343
Path(self.__get_test_file("sources/Project_2181147711.csv")).read_bytes(),
44-
"/https://api.todoist.com/sync/v9/templates/export_as_file?token=mysecrettoken&project_id=2181147712":
44+
("POST", "/https://api.todoist.com/sync/v9/templates/export_as_file", b"project_id=2181147712", 'mysecrettoken'):
4545
Path(self.__get_test_file("sources/Project_2181147712.csv")).read_bytes(),
46-
"/https://api.todoist.com/sync/v9/templates/export_as_file?token=mysecrettoken&project_id=2181147713":
46+
("POST", "/https://api.todoist.com/sync/v9/templates/export_as_file", b"project_id=2181147713", 'mysecrettoken'):
4747
Path(self.__get_test_file("sources/Project_2181147713.csv")).read_bytes(),
48-
"/https://d1x0mwiac2rqwt.cloudfront.net/g75-kL8pwVYNObSczLnVXe4FIyJd8YQL6b8yCilGyix09bMdJmxbtrGMW9jIeIwJ/by/16542905/as/bug.txt":
48+
("GET, /https://d1x0mwiac2rqwt.cloudfront.net/g75-kL8pwVYNObSczLnVXe4FIyJd8YQL6b8yCilGyix09bMdJmxbtrGMW9jIeIwJ/by/16542905/as/bug.txt", None, None):
4949
Path(self.__get_test_file("sources/bug.txt")).read_bytes(),
50-
"/https://d1x0mwiac2rqwt.cloudfront.net/s0snyb7n9tJXYijOK2LV6hjVar4YUkwYbHv3PBFYM-N4nJEtujC046OlEdZpKfZm/by/16542905/as/sample_image.png":
50+
("GET, /https://d1x0mwiac2rqwt.cloudfront.net/s0snyb7n9tJXYijOK2LV6hjVar4YUkwYbHv3PBFYM-N4nJEtujC046OlEdZpKfZm/by/16542905/as/sample_image.png", None, None):
5151
Path(self.__get_test_file("sources/sample_image.png")).read_bytes(),
5252
}
5353

@@ -57,12 +57,12 @@ def tearDown(self):
5757
""" Destroys the sample HTTP server for the test """
5858
self.__httpd.shutdown()
5959

60-
def __opener_open_redirect_to_local(self, original_self, url):
60+
def __opener_open_redirect_to_local(self, original_self, url, data):
6161
""" Replaces the OpenerDirector.open function of URLLib, in order to redirect all requests
6262
to a local server.
6363
This way, we are still able to do the integration test with actual HTTP requests,
6464
though being handled by a local test HTTP server """
65-
return self.__original_opener_open(original_self, "http://127.0.0.1:33327/" + url)
65+
return self.__original_opener_open(original_self, "http://127.0.0.1:33327/" + url, data)
6666

6767
@staticmethod
6868
def __get_test_file(subpath):

full_offline_backup_for_todoist/tests/test_todoist_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ def test_on_call_get_projects_with_token_calls_download_with_token():
6060
todoist_api.get_projects()
6161

6262
# Assert
63+
mock_urldownloader.set_bearer_token.assert_called_with('FAKE TOKEN')
6364
mock_urldownloader.get.assert_called_with(ANY, {
64-
'token': 'FAKE TOKEN',
6565
'sync_token': '*',
6666
'resource_types': '["projects"]'
6767
})

full_offline_backup_for_todoist/tests/test_url_downloader.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ def setUp(self):
1414

1515
# Set up a quick and dirty HTTP server
1616
route_responses = {
17-
"/sample.txt": "this is a sample".encode(),
18-
"/sample.txt?param=value": "this is a sample with a parameter".encode(),
17+
("GET", "/sample.txt", None, None):
18+
"this is a sample".encode(),
19+
("POST", "/sample.txt", b"param=value", None):
20+
"this is a sample with a parameter".encode(),
1921
}
2022

2123
self.__httpd = TestStaticHTTPServer(("127.0.0.1", 33327), route_responses)

full_offline_backup_for_todoist/tests/test_util_static_http_request_handler.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,41 @@ class TestHTTPRequestHandler(BaseHTTPRequestHandler):
2929
def log_message(self, format, *args): # pylint: disable=redefined-builtin
3030
""" Disables console output for the HTTP Request Handler """
3131

32-
def do_GET(self): # pylint: disable=invalid-name
33-
""" Handles a request using the defined static mapping """
32+
def __find_response_for_request_key(self, request_key):
33+
authorization_header = self.headers.get('Authorization')
34+
if authorization_header is not None and authorization_header.startswith("Bearer "):
35+
token = authorization_header[len("Bearer "):]
36+
key_with_auth = request_key + (token,)
37+
if key_with_auth in route_responses:
38+
return route_responses[key_with_auth]
39+
40+
key_without_auth = request_key + (None,)
41+
return route_responses.get(key_without_auth)
42+
43+
def __handle_request(self, request_key):
3444
if flaky and self.path not in handled:
3545
handled.add(self.path)
3646
self.send_response(503)
3747
self.end_headers()
3848
return
3949

40-
self.send_response(200 if self.path in route_responses else 404)
50+
response = self.__find_response_for_request_key(request_key)
51+
self.send_response(200 if response else 404)
4152

4253
self.send_header('Content-type', 'text/plain')
4354
self.end_headers()
4455

45-
if self.path in route_responses:
46-
self.wfile.write(route_responses[self.path])
56+
if response:
57+
self.wfile.write(response)
58+
59+
def do_GET(self): # pylint: disable=invalid-name
60+
""" Handles a GET request using the defined static mapping """
61+
self.__handle_request(('GET', self.path, None))
62+
63+
def do_POST(self): # pylint: disable=invalid-name
64+
""" Handles a POST request using the defined static mapping """
65+
body_length = int(self.headers.get('Content-Length'))
66+
body = self.rfile.read(body_length)
67+
self.__handle_request(('POST', self.path, body))
4768

4869
return TestHTTPRequestHandler

full_offline_backup_for_todoist/todoist_api.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,19 @@ class TodoistApi:
2323
__SYNC_ENDPOINT = __BASE_URL + "/sync"
2424
__EXPORT_PROJECT_AS_CSV_FILE_ENDPOINT = __BASE_URL + "/templates/export_as_file"
2525

26-
__api_token: str
2726
__tracer: Tracer
2827
__urldownloader: URLDownloader
2928

3029
def __init__(self, api_token: str, tracer: Tracer, urldownloader: URLDownloader):
31-
self.__api_token = api_token
3230
self.__tracer = tracer
3331
self.__urldownloader = urldownloader
32+
self.__urldownloader.set_bearer_token(api_token)
3433

3534
def get_projects(self) -> List[TodoistProjectInfo]:
3635
""" Obtains the list of all projects from the Todoist API """
3736
self.__tracer.trace("Fetching projects using the Todoist API...")
3837
project_list_json = self.__urldownloader.get(
3938
self.__SYNC_ENDPOINT, {
40-
"token": self.__api_token,
4139
"sync_token": '*',
4240
"resource_types": '["projects"]',
4341
})
@@ -55,6 +53,5 @@ def export_project_as_csv(self, project: TodoistProjectInfo) -> bytes:
5553

5654
return self.__urldownloader.get(
5755
self.__EXPORT_PROJECT_AS_CSV_FILE_ENDPOINT, {
58-
"token": self.__api_token,
5956
"project_id": project.identifier
6057
})

full_offline_backup_for_todoist/url_downloader.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,48 +14,53 @@ class URLDownloader(metaclass=ABCMeta):
1414
""" Implementation of a class to download the contents of an URL """
1515

1616
_tracer: Tracer
17+
__bearer_token: Optional[str]
1718

1819
def __init__(self, tracer: Tracer):
1920
self._tracer = tracer
21+
self.__bearer_token = None
2022

21-
def _download(self, opener: urllib.request.OpenerDirector, url: str) -> bytes:
23+
def _download(self, opener: urllib.request.OpenerDirector, url: str,
24+
data: Optional[Dict[str, str]]=None) -> bytes:
2225
""" Downloads the specified URL as bytes using the specified opener """
23-
with opener.open(url) as url_handle:
26+
encoded_data = urllib.parse.urlencode(data).encode() if data else None
27+
with opener.open(url, encoded_data) as url_handle:
2428
return cast(bytes, url_handle.read())
2529

26-
def _download_with_retry(self, opener: urllib.request.OpenerDirector, url: str) -> bytes:
30+
def _download_with_retry(self, opener: urllib.request.OpenerDirector, url: str,
31+
data: Optional[Dict[str, str]]=None) -> bytes:
2732
""" Downloads the specified URL as bytes using the specified opener, retrying on failure """
2833
for i in range(NUM_RETRIES):
2934
try:
30-
return self._download(opener, url)
35+
return self._download(opener, url, data)
3136
except urllib.error.URLError as exception:
3237
self._tracer.trace(f"Got exception: {exception}, retrying...")
3338
time.sleep(3**i)
3439

35-
return self._download(opener, url)
40+
return self._download(opener, url, data)
41+
42+
def set_bearer_token(self, bearer_token: Optional[str]) -> None:
43+
""" Sets the value of the 'Authorization: Bearer XXX' HTTP header """
44+
self.__bearer_token = bearer_token
3645

3746
@abstractmethod
3847
def get(self, url: str, data: Optional[Dict[str, str]]=None) -> bytes:
3948
""" Download the contents of the specified URL with a GET request.
4049
You can specify any additional data parameters to pass to the destination. """
4150

42-
@staticmethod
4351
def _build_opener_with_app_useragent(
44-
*handlers: urllib.request.BaseHandler) -> urllib.request.OpenerDirector:
52+
self, *handlers: urllib.request.BaseHandler) -> urllib.request.OpenerDirector:
4553
opener = urllib.request.build_opener(*handlers)
46-
opener.addheaders = [('User-agent', 'full-offline-backup-for-todoist')]
54+
opener.addheaders = ([('User-agent', 'full-offline-backup-for-todoist')] +
55+
([('Authorization', 'Bearer ' + self.__bearer_token)] if self.__bearer_token else []))
4756
return opener
4857

4958
class URLLibURLDownloader(URLDownloader):
5059
""" Implementation of a class to download the contents of an URL through URLLib """
5160

5261
def get(self, url: str, data: Optional[Dict[str, str]]=None) -> bytes:
53-
real_url = url
54-
if data:
55-
real_url += "?" + urllib.parse.urlencode(data)
56-
5762
opener = self._build_opener_with_app_useragent()
58-
return self._download_with_retry(opener, real_url)
63+
return self._download_with_retry(opener, url, data)
5964

6065
class TodoistAuthURLDownloader(URLDownloader):
6166
""" Implementation of a class to download the contents of an URL through URLLib,
@@ -110,8 +115,4 @@ def get(self, url: str, data: Optional[Dict[str, str]]=None) -> bytes:
110115

111116
self._tracer.trace("Auth completed")
112117

113-
real_url = url
114-
if data:
115-
real_url += "?" + urllib.parse.urlencode(data)
116-
117-
return self._download_with_retry(self.__opener, real_url)
118+
return self._download_with_retry(self.__opener, url, data)

0 commit comments

Comments
 (0)