From 1161433aab597d9f849acdc2a6a7fc72a3e1a2a3 Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Sat, 18 Oct 2025 20:41:34 +0000 Subject: [PATCH 1/5] multipart form --- docs/source/usage/serialization.md | 39 ++++++++++++++++++++ src/plone/restapi/deserializer/__init__.py | 9 ++++- src/plone/restapi/deserializer/dxfields.py | 10 +++++ src/plone/restapi/tests/test_content_post.py | 29 +++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/docs/source/usage/serialization.md b/docs/source/usage/serialization.md index e77b7a5a0a..6d7f114482 100644 --- a/docs/source/usage/serialization.md +++ b/docs/source/usage/serialization.md @@ -109,6 +109,45 @@ 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 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. + +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": "My file", + "file": { + "data": "attachment_002", + }, + "leadimage": { + "data": "attachment_001", + } +} +------WebKitFormBoundary7MA4YWxkTrZu0gW +Content-Disposition: form-data; name="attachment_001"; filename="profile.jpg" +Content-Type: image/jpeg + +[Binary data of the JPEG file] +------WebKitFormBoundary7MA4YWxkTrZu0gW +Content-Disposition: form-data; name="atttachment_02"; filename="docuument.docx" +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document + +[Binary data of the Word document] +------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/src/plone/restapi/deserializer/__init__.py b/src/plone/restapi/deserializer/__init__.py index 91b754c149..8df241cd12 100644 --- a/src/plone/restapi/deserializer/__init__.py +++ b/src/plone/restapi/deserializer/__init__.py @@ -9,7 +9,14 @@ 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 + 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 47ae4dfba7..a622756bfb 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_post.py b/src/plone/restapi/tests/test_content_post.py index 66b3315692..380bffad71 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,33 @@ def test_post_without_id_creates_id_from_filename(self): transaction.begin() self.assertIn("test.txt", self.portal.folder1) + def test_post_with_multipart(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_id_already_in_use_returns_400(self): self.portal.folder1.invokeFactory("Document", "mydocument") transaction.commit() From 88149039667ea1ce0b56cc41be15fd5c65e91a90 Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Sun, 19 Oct 2025 07:07:23 +0000 Subject: [PATCH 2/5] Update serialization.md Co-authored-by: Steve Piercy --- docs/source/usage/serialization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/usage/serialization.md b/docs/source/usage/serialization.md index 6d7f114482..c587bdfee9 100644 --- a/docs/source/usage/serialization.md +++ b/docs/source/usage/serialization.md @@ -111,7 +111,7 @@ 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. +It's possible to upload a file or image using multipart/form-data in a POST 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. From 33ec897ec2ef2ba89d813e67fb53d97c89918280 Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Sat, 15 Nov 2025 21:52:15 +0100 Subject: [PATCH 3/5] patch --- src/plone/restapi/tests/test_content_patch.py | 44 +++++++++++++++++++ src/plone/restapi/tests/test_content_post.py | 30 ++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/plone/restapi/tests/test_content_patch.py b/src/plone/restapi/tests/test_content_patch.py index 067b33c30f..14f00d15d8 100644 --- a/src/plone/restapi/tests/test_content_patch.py +++ b/src/plone/restapi/tests/test_content_patch.py @@ -18,6 +18,7 @@ import requests import transaction import unittest +import uuid class TestContentPatch(unittest.TestCase): @@ -197,3 +198,46 @@ 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) + + 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 380bffad71..405018cead 100644 --- a/src/plone/restapi/tests/test_content_post.py +++ b/src/plone/restapi/tests/test_content_post.py @@ -141,7 +141,7 @@ def test_post_without_id_creates_id_from_filename(self): transaction.begin() self.assertIn("test.txt", self.portal.folder1) - def test_post_with_multipart(self): + def test_post_with_multipart_path_dispatcher(self): multipart_ref = uuid.uuid4().hex response = requests.post( f"{self.portal.absolute_url()}/++api++/folder1", @@ -168,6 +168,34 @@ def test_post_with_multipart(self): 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() From f5c7f4e81f74c4be35380c3a1f4e79c8155bb528 Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Sun, 14 Jun 2026 20:19:10 +0200 Subject: [PATCH 4/5] changelog + fix tests --- docs/source/usage/serialization.md | 6 +++++- news/1953.feature | 1 + src/plone/restapi/deserializer/__init__.py | 5 +++++ src/plone/restapi/tests/test_content_patch.py | 7 +++++++ 4 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 news/1953.feature diff --git a/docs/source/usage/serialization.md b/docs/source/usage/serialization.md index c587bdfee9..39da3edda0 100644 --- a/docs/source/usage/serialization.md +++ b/docs/source/usage/serialization.md @@ -111,10 +111,14 @@ 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 request. +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: ``` diff --git a/news/1953.feature b/news/1953.feature new file mode 100644 index 0000000000..a7414b833d --- /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 613bd2580b..171370d69a 100644 --- a/src/plone/restapi/deserializer/__init__.py +++ b/src/plone/restapi/deserializer/__init__.py @@ -19,6 +19,11 @@ def json_body(request): "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: diff --git a/src/plone/restapi/tests/test_content_patch.py b/src/plone/restapi/tests/test_content_patch.py index 14f00d15d8..8740300ff3 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,11 +16,16 @@ 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): @@ -199,6 +205,7 @@ def test_patch_document_with_apostrophe_dont_return_500(self): 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++", From a96d62d9f124b748aab13abf1ca8581f62a010e7 Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Sun, 14 Jun 2026 22:25:48 +0200 Subject: [PATCH 5/5] docs --- docs/source/usage/serialization.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/source/usage/serialization.md b/docs/source/usage/serialization.md index 39da3edda0..3b33dba6ec 100644 --- a/docs/source/usage/serialization.md +++ b/docs/source/usage/serialization.md @@ -131,24 +131,24 @@ Content-Type: application/json { "@type": "File", - "title": "My file", + "title": "Hello, Plone", "file": { - "data": "attachment_002", + "data": "attachment_001" }, "leadimage": { - "data": "attachment_001", + "data": "attachment_002" } } ------WebKitFormBoundary7MA4YWxkTrZu0gW -Content-Disposition: form-data; name="attachment_001"; filename="profile.jpg" -Content-Type: image/jpeg +Content-Disposition: form-data; name="attachment_001"; filename="hello_plone.odt" +Content-Type: application/vnd.oasis.opendocument.text -[Binary data of the JPEG file] +[Binary data of hello_plone.odt] ------WebKitFormBoundary7MA4YWxkTrZu0gW -Content-Disposition: form-data; name="atttachment_02"; filename="docuument.docx" -Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document +Content-Disposition: form-data; name="attachment_002"; filename="logo.svg" +Content-Type: image/svg+xml -[Binary data of the Word document] +[Binary data of logo.svg] ------WebKitFormBoundary7MA4YWxkTrZu0gW-- ```