Skip to content
Open
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
43 changes: 43 additions & 0 deletions docs/source/usage/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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--
```
Comment on lines +124 to +153

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see how to document examples at https://6.docs.plone.org/plone.restapi/docs/source/contributing/index.html#generate-documentation-examples. I'd suggest using the Plone logo for the image (https://6.docs.plone.org/_static/logo.svg) and "Hello, Plone" in a Libre Office file as the binary data.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


### 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:
Expand Down
1 change: 1 addition & 0 deletions news/1953.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for `multipart/form-data` in POST and PATCH requests, allowing file uploads alongside JSON metadata. [mamico]
14 changes: 13 additions & 1 deletion src/plone/restapi/deserializer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions src/plone/restapi/deserializer/dxfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions src/plone/restapi/tests/test_content_patch.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -197,3 +204,47 @@ def test_patch_document_with_apostrophe_dont_return_500(self):
self.assertEqual(204, response.status_code)
transaction.begin()
self.assertEqual("<p>example with '</p>", 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")
57 changes: 57 additions & 0 deletions src/plone/restapi/tests/test_content_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down