From c0cc71ac236256bbe7c834957883301d84c64b32 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Wed, 10 May 2023 20:50:36 +1000 Subject: [PATCH 1/2] fix: url-encode parameters URL paths and the fallback_id header - per pyodk/#53, pyodk/#54 --- pyodk/_endpoints/comments.py | 8 +++-- pyodk/_endpoints/form_assignments.py | 4 +-- pyodk/_endpoints/form_draft_attachments.py | 4 ++- pyodk/_endpoints/form_drafts.py | 8 +++-- pyodk/_endpoints/forms.py | 4 +-- pyodk/_endpoints/project_app_users.py | 4 +-- pyodk/_endpoints/projects.py | 2 +- pyodk/_endpoints/submissions.py | 20 +++++++---- pyodk/_utils/session.py | 24 ++++++++++++- tests/endpoints/test_forms.py | 42 +++++++++++++++++++++- tests/test_session.py | 42 ++++++++++++++++++++++ 11 files changed, 141 insertions(+), 21 deletions(-) diff --git a/pyodk/_endpoints/comments.py b/pyodk/_endpoints/comments.py index a5cd02d..1bab2ba 100644 --- a/pyodk/_endpoints/comments.py +++ b/pyodk/_endpoints/comments.py @@ -70,7 +70,9 @@ def list( response = self.session.response_or_error( method="GET", - url=self.urls.list.format(project_id=pid, form_id=fid, instance_id=iid), + url=self.session.urlformat( + self.urls.list, project_id=pid, form_id=fid, instance_id=iid + ), logger=log, ) data = response.json() @@ -105,7 +107,9 @@ def post( response = self.session.response_or_error( method="POST", - url=self.urls.post.format(project_id=pid, form_id=fid, instance_id=iid), + url=self.session.urlformat( + self.urls.post, project_id=pid, form_id=fid, instance_id=iid + ), logger=log, json=json, ) diff --git a/pyodk/_endpoints/form_assignments.py b/pyodk/_endpoints/form_assignments.py index 8376091..4968b82 100644 --- a/pyodk/_endpoints/form_assignments.py +++ b/pyodk/_endpoints/form_assignments.py @@ -58,8 +58,8 @@ def assign( response = self.session.response_or_error( method="POST", - url=self.urls.post.format( - project_id=pid, form_id=fid, role_id=rid, user_id=uid + url=self.session.urlformat( + self.urls.post, project_id=pid, form_id=fid, role_id=rid, user_id=uid ), logger=log, ) diff --git a/pyodk/_endpoints/form_draft_attachments.py b/pyodk/_endpoints/form_draft_attachments.py index b376082..0fc4bb6 100644 --- a/pyodk/_endpoints/form_draft_attachments.py +++ b/pyodk/_endpoints/form_draft_attachments.py @@ -61,7 +61,9 @@ def upload( with open(file_path, "rb") as fd: response = self.session.response_or_error( method="POST", - url=self.urls.post.format(project_id=pid, form_id=fid, fname=file_name), + url=self.session.urlformat( + self.urls.post, project_id=pid, form_id=fid, fname=file_name + ), logger=log, data=fd, ) diff --git a/pyodk/_endpoints/form_drafts.py b/pyodk/_endpoints/form_drafts.py index 0c71da9..ad504a5 100644 --- a/pyodk/_endpoints/form_drafts.py +++ b/pyodk/_endpoints/form_drafts.py @@ -76,7 +76,7 @@ def create( ) headers = { "Content-Type": content_type, - "X-XlsForm-FormId-Fallback": file_path.stem, + "X-XlsForm-FormId-Fallback": self.session.urlquote(file_path.stem), } except PyODKError as err: log.error(err, exc_info=True) @@ -85,7 +85,7 @@ def create( with open(file_path, "rb") if file_path is not None else nullcontext() as fd: response = self.session.response_or_error( method="POST", - url=self.urls.post.format(project_id=pid, form_id=fid), + url=self.session.urlformat(self.urls.post, project_id=pid, form_id=fid), logger=log, headers=headers, params=params, @@ -121,7 +121,9 @@ def publish( response = self.session.response_or_error( method="POST", - url=self.urls.post_publish.format(project_id=pid, form_id=fid), + url=self.session.urlformat( + self.urls.post_publish, project_id=pid, form_id=fid + ), logger=log, params=params, ) diff --git a/pyodk/_endpoints/forms.py b/pyodk/_endpoints/forms.py index bf57f31..87dcb22 100644 --- a/pyodk/_endpoints/forms.py +++ b/pyodk/_endpoints/forms.py @@ -85,7 +85,7 @@ def list(self, project_id: Optional[int] = None) -> List[Form]: else: response = self.session.response_or_error( method="GET", - url=self.urls.list.format(project_id=pid), + url=self.session.urlformat(self.urls.list, project_id=pid), logger=log, ) data = response.json() @@ -113,7 +113,7 @@ def get( else: response = self.session.response_or_error( method="GET", - url=self.urls.get.format(project_id=pid, form_id=fid), + url=self.session.urlformat(self.urls.get, project_id=pid, form_id=fid), logger=log, ) data = response.json() diff --git a/pyodk/_endpoints/project_app_users.py b/pyodk/_endpoints/project_app_users.py index 7ba17ee..5de7d6d 100644 --- a/pyodk/_endpoints/project_app_users.py +++ b/pyodk/_endpoints/project_app_users.py @@ -63,7 +63,7 @@ def list( response = self.session.response_or_error( method="GET", - url=self.urls.list.format(project_id=pid), + url=self.session.urlformat(self.urls.list, project_id=pid), logger=log, ) data = response.json() @@ -92,7 +92,7 @@ def create( response = self.session.response_or_error( method="POST", - url=self.urls.post.format(project_id=pid), + url=self.session.urlformat(self.urls.post, project_id=pid), logger=log, json=json, ) diff --git a/pyodk/_endpoints/projects.py b/pyodk/_endpoints/projects.py index 901de2d..159e3fb 100644 --- a/pyodk/_endpoints/projects.py +++ b/pyodk/_endpoints/projects.py @@ -95,7 +95,7 @@ def get(self, project_id: Optional[int] = None) -> Project: else: response = self.session.response_or_error( method="GET", - url=self.urls.get.format(project_id=pid), + url=self.session.urlformat(self.urls.get, project_id=pid), logger=log, ) data = response.json() diff --git a/pyodk/_endpoints/submissions.py b/pyodk/_endpoints/submissions.py index 41ff07e..26f1489 100644 --- a/pyodk/_endpoints/submissions.py +++ b/pyodk/_endpoints/submissions.py @@ -88,7 +88,7 @@ def list( response = self.session.response_or_error( method="GET", - url=self.urls.list.format(project_id=pid, form_id=fid), + url=self.session.urlformat(self.urls.list, project_id=pid, form_id=fid), logger=log, ) data = response.json() @@ -119,7 +119,9 @@ def get( response = self.session.response_or_error( method="GET", - url=self.urls.get.format(project_id=pid, form_id=fid, instance_id=iid), + url=self.session.urlformat( + self.urls.get, project_id=pid, form_id=fid, instance_id=iid + ), logger=log, ) data = response.json() @@ -180,7 +182,9 @@ def get_table( response = self.session.response_or_error( method="GET", - url=self.urls.get_table.format(project_id=pid, form_id=fid, table_name=table), + url=self.session.urlformat( + self.urls.get_table, project_id=pid, form_id=fid, table_name=table + ), logger=log, params=params, ) @@ -225,7 +229,7 @@ def create( response = self.session.response_or_error( method="POST", - url=self.urls.post.format(project_id=pid, form_id=fid), + url=self.session.urlformat(self.urls.post, project_id=pid, form_id=fid), logger=log, headers={"Content-Type": "application/xml"}, params=params, @@ -272,7 +276,9 @@ def _put( response = self.session.response_or_error( method="PUT", - url=self.urls.put.format(project_id=pid, form_id=fid, instance_id=iid), + url=self.session.urlformat( + self.urls.put, project_id=pid, form_id=fid, instance_id=iid + ), logger=log, headers={"Content-Type": "application/xml"}, data=xml, @@ -308,7 +314,9 @@ def _patch( response = self.session.response_or_error( method="PATCH", - url=self.urls.patch.format(project_id=pid, form_id=fid, instance_id=iid), + url=self.session.urlformat( + self.urls.patch, project_id=pid, form_id=fid, instance_id=iid + ), logger=log, json=json, ) diff --git a/pyodk/_utils/session.py b/pyodk/_utils/session.py index 3b92610..f0d1f90 100644 --- a/pyodk/_utils/session.py +++ b/pyodk/_utils/session.py @@ -1,5 +1,7 @@ from logging import Logger -from urllib.parse import urljoin +from string import Formatter +from typing import Any +from urllib.parse import quote_plus, urljoin from requests import PreparedRequest, Response from requests import Session as RequestsSession @@ -12,6 +14,18 @@ from pyodk.errors import PyODKError +class URLFormatter(Formatter): + """ + Makes a valid URL by sending each format input field through urllib.parse.quote_plus. + """ + + def format_field(self, value: Any, format_spec: str) -> Any: + return format(quote_plus(str(value)), format_spec) + + +_URL_FORMATTER = URLFormatter() + + class Adapter(HTTPAdapter): def __init__(self, *args, **kwargs): if "timeout" in kwargs: @@ -100,6 +114,14 @@ def base_url_validate(base_url: str, api_version: str): def urljoin(self, url: str) -> str: return urljoin(self.base_url, url.lstrip("/")) + @staticmethod + def urlformat(url: str, *args, **kwargs) -> str: + return _URL_FORMATTER.format(url, *args, **kwargs) + + @staticmethod + def urlquote(url: str) -> str: + return _URL_FORMATTER.format_field(url, format_spec="") + def request(self, method, url, *args, **kwargs): return super().request(method, self.urljoin(url), *args, **kwargs) diff --git a/tests/endpoints/test_forms.py b/tests/endpoints/test_forms.py index cf0abaf..e145a87 100644 --- a/tests/endpoints/test_forms.py +++ b/tests/endpoints/test_forms.py @@ -3,10 +3,11 @@ from functools import wraps from typing import Callable from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, mock_open, patch from pyodk._endpoints.form_draft_attachments import FormDraftAttachmentService from pyodk._endpoints.form_drafts import FormDraftService +from pyodk._endpoints.form_drafts import log as form_drafts_log from pyodk._endpoints.forms import Form, FormService from pyodk._utils.session import Session from pyodk.client import Client @@ -179,6 +180,45 @@ def test_update__def_and_attach__create_upload_publish(self, ctx: MockContext): form_id="foo", version=None, project_id=None ) + @staticmethod + def update__def_encoding_steps( + form_id: str, definition: str, expected_url: str, expected_fallback_id: str + ): + client = Client() + + def mock_wrap_error(**kwargs): + return kwargs["value"] + + with patch.object(Session, "response_or_error") as mock_response, patch( + "pyodk._utils.validators.wrap_error", mock_wrap_error + ), patch("builtins.open", mock_open(), create=True) as mock_open_patch: + client.forms.update(form_id, definition=definition) + mock_response.assert_any_call( + method="POST", + url=expected_url, + logger=form_drafts_log, + headers={ + "Content-Type": ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + "X-XlsForm-FormId-Fallback": expected_fallback_id, + }, + params={"ignoreWarnings": True}, + data=mock_open_patch.return_value, + ) + + def test_update__def_encoding(self): + """Should find that the URL and fallback header are url-encoded.""" + test_cases = ( + ("foo", "/some/path/foo.xlsx", "projects/1/forms/foo/draft", "foo"), + ("foo", "/some/path/✅.xlsx", "projects/1/forms/foo/draft", "%E2%9C%85"), + ("✅", "/some/path/✅.xlsx", "projects/1/forms/%E2%9C%85/draft", "%E2%9C%85"), + ("✅", "/some/path/foo.xlsx", "projects/1/forms/%E2%9C%85/draft", "foo"), + ) + for case in test_cases: + with self.subTest(msg=str(case)): + self.update__def_encoding_steps(*case) + def test_update__no_def_no_attach__raises(self): """Should raise an error if there is no definition or attachment.""" client = Client() diff --git a/tests/test_session.py b/tests/test_session.py index ff56555..bafe41f 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,3 +1,4 @@ +from pathlib import Path from unittest import TestCase from pyodk._utils.session import Session @@ -18,3 +19,44 @@ def test_base_url_validate(self): with self.subTest(msg=f"{base_url}"): observed = Session.base_url_validate(base_url=base_url, api_version="v1") self.assertEqual(expected, observed) + + def test_urlformat(self): + """Should replace input fields with url-encoded values.""" + url = "projects/{project_id}/forms/{form_id}" + test_cases = ( + # Basic latin string + ({"project_id": 1, "form_id": "a"}, "projects/1/forms/a"), + # integer + ({"project_id": 1, "form_id": 1}, "projects/1/forms/1"), + # latin symbols + ({"project_id": 1, "form_id": "+-_*%*"}, "projects/1/forms/%2B-_%2A%25%2A"), + # lower case e, with combining acute accent (2 symbols) + ({"project_id": 1, "form_id": "tést"}, "projects/1/forms/te%CC%81st"), + # lower case e with acute (1 symbol) + ({"project_id": 1, "form_id": "tést"}, "projects/1/forms/t%C3%A9st"), + # white heavy check mark + ({"project_id": 1, "form_id": "✅"}, "projects/1/forms/%E2%9C%85"), + ) + for params, expected in test_cases: + with self.subTest(msg=str(params)): + self.assertEqual(expected, Session.urlformat(url, **params)) + + def test_urlquote(self): + """Should url-encode input values.""" + test_cases = ( + # Basic latin string + ("test.xlsx", "test"), + # integer + ("1.xls", "1"), + # latin symbols + ("+-_*%*.xls", "%2B-_%2A%25%2A"), + # lower case e, with combining acute accent (2 symbols) + ("tést.xlsx", "te%CC%81st"), + # lower case e with acute (1 symbol) + ("tést", "t%C3%A9st"), + # white heavy check mark + ("✅.xlsx", "%E2%9C%85"), + ) + for params, expected in test_cases: + with self.subTest(msg=str(params)): + self.assertEqual(expected, Session.urlquote(Path(params).stem)) From ffc4ae7be2ecb4a2bef14cbb5223785b6f74efd6 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 11 May 2023 14:43:28 +1000 Subject: [PATCH 2/2] fix: encode submission xml as utf-8 to avoid default latin-1 - strings passed in to request(data=some_str) get encoded as latin-1 by default. Data passed in as dictionaries to `json` or `params` are encoded to utf-8 by default. So encode the XML. - forms.py: tidy up docstring wrap length to 90 chars - submissions.py: - add encoding parameter, default utf-8, for encoding the XML, for submission.create and submission.edit - move example submission edit XML from private ._put method to public .edit method so users can see it. - test_client.py: add integration tests for non-ascii form_ids and instance_ids, incl. check for retrieval after create/update. --- pyodk/_endpoints/forms.py | 12 ++-- pyodk/_endpoints/submissions.py | 44 ++++++++------ tests/resources/forms/non_ascii_form_id.xlsx | Bin 0 -> 6666 bytes "tests/resources/forms/\342\234\205.xlsx" | Bin 0 -> 6577 bytes tests/test_client.py | 58 +++++++++++++++++++ 5 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 tests/resources/forms/non_ascii_form_id.xlsx create mode 100644 "tests/resources/forms/\342\234\205.xlsx" diff --git a/pyodk/_endpoints/forms.py b/pyodk/_endpoints/forms.py index 87dcb22..5e3e4a4 100644 --- a/pyodk/_endpoints/forms.py +++ b/pyodk/_endpoints/forms.py @@ -137,8 +137,8 @@ def update( * form attachments only * form attachments with `version_updater` - If a definition is provided, the new version name must be specified in the definition. - If no definition is provided, a default version will be set using + If a definition is provided, the new version name must be specified in the + definition. If no definition is provided, a default version will be set using the current datetime is ISO format. The default datetime version can be overridden by providing a `version_updater` @@ -150,12 +150,12 @@ def update( :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project this form belongs to. - :param definition: The path to a form definition file to upload. The form definition - must include an updated version string. + :param definition: The path to a form definition file to upload. The form + definition must include an updated version string. :param attachments: The paths of the form attachment file(s) to upload. :param version_updater: A function that accepts a version name string and returns - a version name string, which is used for the new form version. Not allowed if a form - definition is specified. + a version name string, which is used for the new form version. Not allowed if a + form definition is specified. """ if definition is None and attachments is None: raise PyODKError("Must specify a form definition and/or attachments.") diff --git a/pyodk/_endpoints/submissions.py b/pyodk/_endpoints/submissions.py index 26f1489..192d6c2 100644 --- a/pyodk/_endpoints/submissions.py +++ b/pyodk/_endpoints/submissions.py @@ -196,6 +196,7 @@ def create( form_id: Optional[str] = None, project_id: Optional[int] = None, device_id: Optional[str] = None, + encoding: str = "utf-8", ) -> Submission: """ Create a Submission. @@ -216,6 +217,7 @@ def create( :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project this form belongs to. :param device_id: An optional deviceID associated with the submission. + :param encoding: The encoding of the submission XML, default "utf-8". """ try: pid = pv.validate_project_id(project_id, self.default_project_id) @@ -233,7 +235,7 @@ def create( logger=log, headers={"Content-Type": "application/xml"}, params=params, - data=xml, + data=xml.encode(encoding=encoding), ) data = response.json() return Submission(**data) @@ -244,27 +246,16 @@ def _put( xml: str, form_id: Optional[str] = None, project_id: Optional[int] = None, + encoding: str = "utf-8", ) -> Submission: """ Update Submission data. - Example submission XML structure: - - ``` - - - uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44 - uuid:315c2f74-c8fc-4606-ae3f-22f8983e441e - - Alice - 36 - - ``` - :param instance_id: The instanceId of the Submission being referenced. :param xml: The submission XML. :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project this form belongs to. + :param encoding: The encoding of the submission XML, default "utf-8". """ try: pid = pv.validate_project_id(project_id, self.default_project_id) @@ -281,7 +272,7 @@ def _put( ), logger=log, headers={"Content-Type": "application/xml"}, - data=xml, + data=xml.encode(encoding=encoding), ) data = response.json() return Submission(**data) @@ -330,18 +321,37 @@ def edit( form_id: Optional[str] = None, project_id: Optional[int] = None, comment: Optional[str] = None, + encoding: str = "utf-8", ) -> None: """ Edit a submission and optionally comment on it. - :param instance_id: The instanceId of the Submission being referenced. + Example edited submission XML structure: + + ``` + + + uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44 + uuid:315c2f74-c8fc-4606-ae3f-22f8983e441e + + Alice + 36 + + ``` + + :param instance_id: The instanceId of the Submission being referenced. The + `instance_id` each Submission is first submitted with will always represent + that Submission as a whole. Each version of the Submission, though, has its own + `instance_id`. So `instance_id` will not necessarily match the values in + the XML elements named `instanceID` and `deprecatedID`. :param xml: The submission XML. :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project this form belongs to. :param comment: The text of the comment. + :param encoding: The encoding of the submission XML, default "utf-8". """ fp_ids = {"form_id": form_id, "project_id": project_id} - self._put(instance_id=instance_id, xml=xml, **fp_ids) + self._put(instance_id=instance_id, xml=xml, encoding=encoding, **fp_ids) if comment is not None: self.add_comment(instance_id=instance_id, comment=comment, **fp_ids) diff --git a/tests/resources/forms/non_ascii_form_id.xlsx b/tests/resources/forms/non_ascii_form_id.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fb488cb1799db44f2c970aad49a73e86e463acd4 GIT binary patch literal 6666 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 literal 0 HcmV?d00001 diff --git a/tests/test_client.py b/tests/test_client.py index 13a4e68..dcce9d5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,4 @@ +from datetime import datetime from unittest import TestCase, skip from pyodk.client import Client @@ -49,6 +50,26 @@ def test_form_update__new_definition_and_attachments(self): attachments=[(RESOURCES / "forms" / "fruits.csv").as_posix()], ) + def test_form_update__new_definition_and_attachments__non_ascii_dingbat(self): + """Should create a new version with new definition and attachment.""" + with Client() as client: + client.forms.update( + form_id="✅", + definition=(RESOURCES / "forms" / "✅.xlsx").as_posix(), + attachments=[(RESOURCES / "forms" / "fruits.csv").as_posix()], + ) + form = client.forms.get("✅") + self.assertEqual(form.xmlFormId, "✅") + + def test_form_update__with_version_updater__non_ascii_specials(self): + """Should create a new version with new definition and attachment.""" + with Client() as client: + client.forms.update( + form_id="'=+/*-451%/%", + attachments=[], + version_updater=lambda v: datetime.now().isoformat(), + ) + def test_form_update__attachments(self): """Should create a new version with new attachment.""" with Client() as client: @@ -78,3 +99,40 @@ def test_project_create_app_users__names_and_forms(self): display_names=["test_assign3", "test_assign_23"], forms=["range", "pull_data"], ) + + def test_submission_create__non_ascii(self): + """Should create an instance of the form, encoded to utf-8.""" + client = Client() + xml = """ + + + ~!@#$%^&*()_+=-✅✅ + + Banana + + + """ + client.submissions.create(xml=xml, form_id="'=+/*-451%/%") + submission = client.submissions.get( + form_id="'=+/*-451%/%", instance_id="~!@#$%^&*()_+=-✅✅" + ) + self.assertEqual("~!@#$%^&*()_+=-✅✅", submission.instanceId) + + def test_submission_edit__non_ascii(self): + """Should edit an existing instance of the form, encoded to utf-8.""" + client = Client() + # The "instance_id" remains the id of the first submission, not the + # instanceID/deprecatedID used in the XML. + xml = """ + + + ~!@#$%^&*()_+=-✘✘ + ~!@#$%^&*()_+=-✘✘✘ + + Papaya + + + """ + client.submissions.edit( + xml=xml, form_id="'=+/*-451%/%", instance_id="~!@#$%^&*()_+=-✅✅" + )