diff --git a/docs/source/usage/serialization.md b/docs/source/usage/serialization.md index e77b7a5a0..3b33dba6e 100644 --- a/docs/source/usage/serialization.md +++ b/docs/source/usage/serialization.md @@ -109,6 +109,49 @@ Image URLs are created using the UID-based URL that changes each time the image } ``` +### Upload using multipart/form-data + +It's possible to upload a file or image using multipart/form-data in a POST or PATCH request. +In the form, the field data must be present and should contain the JSON data for the REST API request. +Other binary files are referenced by an ID in the data attribute of the corresponding file or image field. + +```{note} +Multipart PATCH requests require Zope >= 6 (Plone >= 6.2). +``` + +Example: + +``` +POST /++api++/folder1 HTTP/1.1 +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW + +------WebKitFormBoundary7MA4YWxkTrZu0gW +Content-Disposition: form-data; name="data" +Content-Type: application/json + +{ + "@type": "File", + "title": "Hello, Plone", + "file": { + "data": "attachment_001" + }, + "leadimage": { + "data": "attachment_002" + } +} +------WebKitFormBoundary7MA4YWxkTrZu0gW +Content-Disposition: form-data; name="attachment_001"; filename="hello_plone.odt" +Content-Type: application/vnd.oasis.opendocument.text + +[Binary data of hello_plone.odt] +------WebKitFormBoundary7MA4YWxkTrZu0gW +Content-Disposition: form-data; name="attachment_002"; filename="logo.svg" +Content-Type: image/svg+xml + +[Binary data of logo.svg] +------WebKitFormBoundary7MA4YWxkTrZu0gW-- +``` + ### Upload (deserialization) For file or image fields, the client must provide the file's data as a mapping containing the file data and some additional metadata: diff --git a/news/1953.feature b/news/1953.feature new file mode 100644 index 000000000..a7414b833 --- /dev/null +++ b/news/1953.feature @@ -0,0 +1 @@ +Add support for `multipart/form-data` in POST and PATCH requests, allowing file uploads alongside JSON metadata. [mamico] diff --git a/src/plone/restapi/deserializer/__init__.py b/src/plone/restapi/deserializer/__init__.py index e26406e32..171370d69 100644 --- a/src/plone/restapi/deserializer/__init__.py +++ b/src/plone/restapi/deserializer/__init__.py @@ -15,7 +15,19 @@ def json_body(request): # Once we have fixed this, we can remove the temporary patches.py. # See there for background information. try: - data = json.loads(request.get("BODY") or "{}") + if request.method in ("POST", "PATCH", "PUT") and request.getHeader( + "content-type", "" + ).startswith("multipart/form-data"): + # multipart/form-data + # Not all multipart requests carry a JSON "data" field (e.g. CSV + # file uploads use a "file" field). Return an empty dict so callers + # like HypermediaBatch treat the request as having no JSON body. + if "data" not in request.form: + return {} + request.form["data"].seek(0) + data = json.load(request.form["data"]) + else: + data = json.loads(request.get("BODY") or "{}") except ValueError: raise DeserializationError("No JSON object could be decoded") if not isinstance(data, dict): diff --git a/src/plone/restapi/deserializer/dxfields.py b/src/plone/restapi/deserializer/dxfields.py index 03b5bb4ac..50e27ddce 100644 --- a/src/plone/restapi/deserializer/dxfields.py +++ b/src/plone/restapi/deserializer/dxfields.py @@ -256,6 +256,16 @@ def __call__(self, value): content_type = value.get("content-type", content_type) filename = value.get("filename", filename) data = value.get("data", "") + if self.request.getHeader("content-type", "").startswith( + "multipart/form-data" + ): + data = self.request.form[data] + if not filename and data.filename: + filename = data.filename + if content_type == "application/octet-stream" and data.headers.get( + "Content-Type" + ): + content_type = data.headers.get("Content-Type") if isinstance(data, str): data = data.encode("utf-8") if "encoding" in value: diff --git a/src/plone/restapi/tests/test_content_patch.py b/src/plone/restapi/tests/test_content_patch.py index 067b33c30..8740300ff 100644 --- a/src/plone/restapi/tests/test_content_patch.py +++ b/src/plone/restapi/tests/test_content_patch.py @@ -1,3 +1,4 @@ +from importlib.metadata import distribution from OFS.interfaces import IObjectWillBeAddedEvent from plone.app.testing import login from plone.app.testing import setRoles @@ -15,9 +16,15 @@ from zope.lifecycleevent.interfaces import IObjectModifiedEvent import json +import packaging.version import requests import transaction import unittest +import uuid + +HAS_PLONE_62 = packaging.version.parse( + distribution("Products.CMFPlone").version +) >= packaging.version.parse("6.2.0a1") class TestContentPatch(unittest.TestCase): @@ -197,3 +204,47 @@ def test_patch_document_with_apostrophe_dont_return_500(self): self.assertEqual(204, response.status_code) transaction.begin() self.assertEqual("

example with '

", self.portal.doc1.text.raw) + + @unittest.skipUnless(HAS_PLONE_62, "Multipart PATCH requires Plone >= 6.2") + def test_patch_file_with_multipart(self): + response = requests.post( + f"{self.portal.absolute_url()}/++api++", + headers={"Accept": "application/json"}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + "@type": "File", + "file": { + "filename": "test.txt", + "data": "Spam and Eggs", + "content_type": "text/plain", + }, + }, + ) + transaction.commit() + + response = response.json() + + multipart_ref = uuid.uuid4().hex + response = requests.patch( + f"{self.portal.absolute_url()}/++api++/{response['id']}", + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + headers={"Accept": "application/json"}, + files={ + "data": json.dumps( + { + "file": { + "data": multipart_ref, + }, + } + ), + multipart_ref: ( + "test2.text", + "Cheese and Eggs", + "text/plain", + ), + }, + ) + self.assertEqual(204, response.status_code) + transaction.begin() + self.assertIn("test.txt", self.portal) + self.assertEqual(self.portal["test.txt"].file.data, b"Cheese and Eggs") diff --git a/src/plone/restapi/tests/test_content_post.py b/src/plone/restapi/tests/test_content_post.py index 66b331569..405018cea 100644 --- a/src/plone/restapi/tests/test_content_post.py +++ b/src/plone/restapi/tests/test_content_post.py @@ -14,9 +14,11 @@ from zope.lifecycleevent.interfaces import IObjectCreatedEvent from zope.lifecycleevent.interfaces import IObjectModifiedEvent +import json import requests import transaction import unittest +import uuid class TestFolderCreate(unittest.TestCase): @@ -139,6 +141,61 @@ def test_post_without_id_creates_id_from_filename(self): transaction.begin() self.assertIn("test.txt", self.portal.folder1) + def test_post_with_multipart_path_dispatcher(self): + multipart_ref = uuid.uuid4().hex + response = requests.post( + f"{self.portal.absolute_url()}/++api++/folder1", + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + files={ + "data": json.dumps( + { + "@type": "File", + "title": "My File", + "file": { + "data": multipart_ref, + }, + } + ), + multipart_ref: ( + "test.txt", + "Spam and Eggs", + "text/plain", + ), + }, + ) + self.assertEqual(201, response.status_code) + transaction.begin() + self.assertIn("test.txt", self.portal.folder1) + self.assertEqual(self.portal.folder1["test.txt"].file.data, b"Spam and Eggs") + + def test_post_with_multipart_accept_header_dispatcher(self): + multipart_ref = uuid.uuid4().hex + response = requests.post( + self.portal.folder1.absolute_url(), + headers={"Accept": "application/json"}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + files={ + "data": json.dumps( + { + "@type": "File", + "title": "My File", + "file": { + "data": multipart_ref, + }, + } + ), + multipart_ref: ( + "test.txt", + "Spam and Eggs", + "text/plain", + ), + }, + ) + self.assertEqual(201, response.status_code) + transaction.begin() + self.assertIn("test.txt", self.portal.folder1) + self.assertEqual(self.portal.folder1["test.txt"].file.data, b"Spam and Eggs") + def test_post_with_id_already_in_use_returns_400(self): self.portal.folder1.invokeFactory("Document", "mydocument") transaction.commit()