Merge lp:~cjwatson/lazr.restful/web-service-caller-multipart-named-post into lp:lazr.restful

Proposed by Colin Watson
Status: Merged
Merged at revision: 274
Proposed branch: lp:~cjwatson/lazr.restful/web-service-caller-multipart-named-post
Merge into: lp:lazr.restful
Diff against target: 226 lines (+127/-20)
4 files modified
NEWS.rst (+9/-0)
setup.py (+1/-1)
src/lazr/restful/example/base/tests/hostedfile.txt (+10/-3)
src/lazr/restful/testing/webservice.py (+107/-16)
To merge this branch: bzr merge lp:~cjwatson/lazr.restful/web-service-caller-multipart-named-post
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+395705@code.launchpad.net

Commit message

Rework WebServiceCaller to send named POSTs as multipart/form-data.

Description of the change

Arguments that are instances of io.BufferedIOBase are sent as-is rather than being encoded as JSON. This allows robust use of binary arguments on both Python 2 and 3.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

looks good

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'NEWS.rst'
2--- NEWS.rst 2020-09-28 14:43:25 +0000
3+++ NEWS.rst 2021-01-04 11:27:11 +0000
4@@ -2,6 +2,15 @@
5 NEWS for lazr.restful
6 =====================
7
8+0.24.0
9+======
10+
11+Rework ``lazr.restful.testing.webservice.WebServiceCaller`` to send named
12+POSTs as ``multipart/form-data`` requests. Arguments that are instances of
13+``io.BufferedIOBase`` are sent as-is rather than being encoded as JSON,
14+allowing robust use of binary arguments on both Python 2 and 3
15+(bug 1116954).
16+
17 0.23.0 (2020-09-28)
18 ===================
19
20
21=== modified file 'setup.py'
22--- setup.py 2020-08-21 16:16:09 +0000
23+++ setup.py 2021-01-04 11:27:11 +0000
24@@ -61,7 +61,7 @@
25 'pytz',
26 'setuptools',
27 'simplejson>=2.1.0',
28- 'six',
29+ 'six>=1.12.0',
30 'testtools',
31 'van.testing',
32 'wsgiref; python_version < "3"',
33
34=== modified file 'src/lazr/restful/example/base/tests/hostedfile.txt'
35--- src/lazr/restful/example/base/tests/hostedfile.txt 2020-09-07 09:54:10 +0000
36+++ src/lazr/restful/example/base/tests/hostedfile.txt 2021-01-04 11:27:11 +0000
37@@ -118,15 +118,22 @@
38 real web service to define a more complex named operation that
39 manipulates uploaded files.
40
41- >>> print(webservice.named_post(greens_url, 'replace_cover',
42- ... cover='\x01\x02'))
43+ >>> import io
44+ >>> print(webservice.named_post(
45+ ... greens_url, 'replace_cover',
46+ ... cover=io.BytesIO(b'\x01\x02\r\n\x81\r\x82\n')))
47 HTTP/1.1 200 Ok
48 ...
49- >>> print(webservice.get(greens_cover))
50+ >>> result = webservice.get(greens_cover)
51+ >>> print(result)
52 HTTP/1.1 303 See Other
53 ...
54 Location: http://cookbooks.dev/devel/filemanager/2
55 ...
56+ >>> filemanager_url = result.getheader('location')
57+ >>> response = webservice.get(filemanager_url)
58+ >>> response.body == b'\x01\x02\r\n\x81\r\x82\n'
59+ True
60
61 Deleting a cover (with DELETE) disables the redirect.
62
63
64=== modified file 'src/lazr/restful/testing/webservice.py'
65--- src/lazr/restful/testing/webservice.py 2020-09-28 08:59:17 +0000
66+++ src/lazr/restful/testing/webservice.py 2021-01-04 11:27:11 +0000
67@@ -21,7 +21,10 @@
68 'WebServiceTestPublication',
69 ]
70
71-from io import BytesIO
72+from email.utils import quote as email_quote
73+import io
74+import random
75+import re
76 import simplejson
77 import sys
78 from types import ModuleType
79@@ -32,7 +35,6 @@
80 import six
81 from six.moves.urllib.parse import (
82 quote,
83- urlencode,
84 urljoin,
85 )
86 from zope.component import (
87@@ -83,10 +85,10 @@
88 if environ is not None:
89 test_environ.update(environ)
90 if body is None:
91- body_instream = BytesIO(b'')
92+ body_instream = io.BytesIO(b'')
93 else:
94 test_environ['CONTENT_LENGTH'] = len(body)
95- body_instream = BytesIO(body)
96+ body_instream = io.BytesIO(body)
97 request = config.createRequest(body_instream, test_environ)
98
99 request.processInputs()
100@@ -348,15 +350,103 @@
101 return self._make_request_with_entity_body(
102 path, 'POST', media_type, data, headers, api_version=api_version)
103
104- def _convertArgs(self, operation_name, args):
105- """Encode and convert keyword arguments."""
106- args['ws.op'] = operation_name
107- # To be properly marshalled all values must be strings or converted to
108- # JSON.
109- for key, value in args.items():
110- if not isinstance(value, six.string_types):
111- args[key] = simplejson.dumps(value)
112- return urlencode(args)
113+ def _makeBoundary(self, all_parts):
114+ """Make a random boundary that does not appear in `all_parts`."""
115+ _width = len(repr(sys.maxsize - 1))
116+ _fmt = '%%0%dd' % _width
117+ token = random.randrange(sys.maxsize)
118+ boundary = ('=' * 15) + (_fmt % token) + '=='
119+ if all_parts is None:
120+ return boundary
121+ b = boundary
122+ counter = 0
123+ while True:
124+ pattern = ('^--' + re.escape(b) + '(--)?$').encode('ascii')
125+ if not re.search(pattern, all_parts, flags=re.MULTILINE):
126+ break
127+ b = boundary + '.' + str(counter)
128+ counter += 1
129+ return b
130+
131+ def _writeHeaders(self, buf, headers):
132+ """Write MIME headers to a file object."""
133+ for key, value in headers:
134+ buf.write(key.encode('UTF-8'))
135+ buf.write(b': ')
136+ buf.write(value.encode('UTF-8'))
137+ buf.write(b'\r\n')
138+ buf.write(b'\r\n')
139+
140+ def _writeBoundary(self, buf, boundary, closing=False):
141+ """Write a multipart boundary to a file object."""
142+ buf.write(b'--')
143+ buf.write(boundary.encode('UTF-8'))
144+ if closing:
145+ buf.write(b'--')
146+ buf.write(b'\r\n')
147+
148+ def _convertArgs(self, args):
149+ """Encode and convert keyword arguments to multipart/form-data.
150+
151+ This is very loosely based on the email module in the Python
152+ standard library. However, that module doesn't really support
153+ directly embedding binary data in a form: various versions of Python
154+ have mangled line separators in different ways, and none of them get
155+ it quite right. Since we only need a tiny subset of MIME here, it's
156+ easier to implement it ourselves.
157+
158+ Binary argument values are supported, but must be passed as
159+ instances of `io.BufferedIOBase` (e.g. `io.BytesIO`).
160+
161+ :return: a tuple of two elements: the Content-Type of the message,
162+ and the entire encoded message as a byte string.
163+ """
164+ # Generate the subparts first so that we can calculate a safe boundary.
165+ encoded_parts = []
166+ for name, value in args.items():
167+ buf = io.BytesIO()
168+ if isinstance(value, io.BufferedIOBase):
169+ ctype = 'application/octet-stream'
170+ # RFC 7578 says that the filename parameter isn't mandatory
171+ # in our case, but zope.publisher only considers a field to
172+ # be a file upload if a filename was passed in.
173+ cdisp = 'form-data; name="%s"; filename="%s"' % (
174+ email_quote(name), email_quote(name))
175+ else:
176+ ctype = 'text/plain; charset="utf-8"'
177+ cdisp = 'form-data; name="%s"' % email_quote(name)
178+ self._writeHeaders(buf, [
179+ ('MIME-Version', '1.0'),
180+ ('Content-Type', ctype),
181+ ('Content-Disposition', cdisp),
182+ ])
183+ if isinstance(value, io.BufferedIOBase):
184+ buf.write(value.read())
185+ else:
186+ if not isinstance(value, six.string_types):
187+ value = simplejson.dumps(value)
188+ lines = re.split(r'\r\n|\r|\n', value)
189+ for line in lines[:-1]:
190+ buf.write(six.ensure_binary(line))
191+ buf.write(b'\r\n')
192+ buf.write(six.ensure_binary(lines[-1]))
193+ encoded_parts.append(buf.getvalue())
194+
195+ # Create a suitable boundary.
196+ boundary = self._makeBoundary(b'\r\n'.join(encoded_parts))
197+
198+ # Now we can write the multipart headers, followed by all the parts.
199+ buf = io.BytesIO()
200+ self._writeHeaders(buf, [])
201+ for encoded_part in encoded_parts:
202+ self._writeBoundary(buf, boundary)
203+ buf.write(encoded_part)
204+ buf.write(b'\r\n')
205+ self._writeBoundary(buf, boundary, closing=True)
206+
207+ media_type = (
208+ 'multipart/form-data; boundary="%s"' % email_quote(boundary))
209+ return media_type, buf.getvalue()
210
211 def named_get(self, path_or_url, operation_name, headers=None,
212 api_version=None, **kwargs):
213@@ -368,9 +458,10 @@
214
215 def named_post(self, path, operation_name, headers=None,
216 api_version=None, **kwargs):
217- data = self._convertArgs(operation_name, kwargs)
218- return self.post(path, 'application/x-www-form-urlencoded', data,
219- headers, api_version=api_version)
220+ kwargs['ws.op'] = operation_name
221+ media_type, data = self._convertArgs(kwargs)
222+ return self.post(path, media_type, data, headers,
223+ api_version=api_version)
224
225 def patch(self, path, media_type, data, headers=None,
226 api_version=None):

Subscribers

People subscribed via source and target branches