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
=== modified file 'NEWS.rst'
--- NEWS.rst 2020-09-28 14:43:25 +0000
+++ NEWS.rst 2021-01-04 11:27:11 +0000
@@ -2,6 +2,15 @@
2NEWS for lazr.restful2NEWS for lazr.restful
3=====================3=====================
44
50.24.0
6======
7
8Rework ``lazr.restful.testing.webservice.WebServiceCaller`` to send named
9POSTs as ``multipart/form-data`` requests. Arguments that are instances of
10``io.BufferedIOBase`` are sent as-is rather than being encoded as JSON,
11allowing robust use of binary arguments on both Python 2 and 3
12(bug 1116954).
13
50.23.0 (2020-09-28)140.23.0 (2020-09-28)
6===================15===================
716
817
=== modified file 'setup.py'
--- setup.py 2020-08-21 16:16:09 +0000
+++ setup.py 2021-01-04 11:27:11 +0000
@@ -61,7 +61,7 @@
61 'pytz',61 'pytz',
62 'setuptools',62 'setuptools',
63 'simplejson>=2.1.0',63 'simplejson>=2.1.0',
64 'six',64 'six>=1.12.0',
65 'testtools',65 'testtools',
66 'van.testing',66 'van.testing',
67 'wsgiref; python_version < "3"',67 'wsgiref; python_version < "3"',
6868
=== modified file 'src/lazr/restful/example/base/tests/hostedfile.txt'
--- src/lazr/restful/example/base/tests/hostedfile.txt 2020-09-07 09:54:10 +0000
+++ src/lazr/restful/example/base/tests/hostedfile.txt 2021-01-04 11:27:11 +0000
@@ -118,15 +118,22 @@
118real web service to define a more complex named operation that118real web service to define a more complex named operation that
119manipulates uploaded files.119manipulates uploaded files.
120120
121 >>> print(webservice.named_post(greens_url, 'replace_cover',121 >>> import io
122 ... cover='\x01\x02'))122 >>> print(webservice.named_post(
123 ... greens_url, 'replace_cover',
124 ... cover=io.BytesIO(b'\x01\x02\r\n\x81\r\x82\n')))
123 HTTP/1.1 200 Ok125 HTTP/1.1 200 Ok
124 ...126 ...
125 >>> print(webservice.get(greens_cover))127 >>> result = webservice.get(greens_cover)
128 >>> print(result)
126 HTTP/1.1 303 See Other129 HTTP/1.1 303 See Other
127 ...130 ...
128 Location: http://cookbooks.dev/devel/filemanager/2131 Location: http://cookbooks.dev/devel/filemanager/2
129 ...132 ...
133 >>> filemanager_url = result.getheader('location')
134 >>> response = webservice.get(filemanager_url)
135 >>> response.body == b'\x01\x02\r\n\x81\r\x82\n'
136 True
130137
131Deleting a cover (with DELETE) disables the redirect.138Deleting a cover (with DELETE) disables the redirect.
132139
133140
=== modified file 'src/lazr/restful/testing/webservice.py'
--- src/lazr/restful/testing/webservice.py 2020-09-28 08:59:17 +0000
+++ src/lazr/restful/testing/webservice.py 2021-01-04 11:27:11 +0000
@@ -21,7 +21,10 @@
21 'WebServiceTestPublication',21 'WebServiceTestPublication',
22 ]22 ]
2323
24from io import BytesIO24from email.utils import quote as email_quote
25import io
26import random
27import re
25import simplejson28import simplejson
26import sys29import sys
27from types import ModuleType30from types import ModuleType
@@ -32,7 +35,6 @@
32import six35import six
33from six.moves.urllib.parse import (36from six.moves.urllib.parse import (
34 quote,37 quote,
35 urlencode,
36 urljoin,38 urljoin,
37 )39 )
38from zope.component import (40from zope.component import (
@@ -83,10 +85,10 @@
83 if environ is not None:85 if environ is not None:
84 test_environ.update(environ)86 test_environ.update(environ)
85 if body is None:87 if body is None:
86 body_instream = BytesIO(b'')88 body_instream = io.BytesIO(b'')
87 else:89 else:
88 test_environ['CONTENT_LENGTH'] = len(body)90 test_environ['CONTENT_LENGTH'] = len(body)
89 body_instream = BytesIO(body)91 body_instream = io.BytesIO(body)
90 request = config.createRequest(body_instream, test_environ)92 request = config.createRequest(body_instream, test_environ)
9193
92 request.processInputs()94 request.processInputs()
@@ -348,15 +350,103 @@
348 return self._make_request_with_entity_body(350 return self._make_request_with_entity_body(
349 path, 'POST', media_type, data, headers, api_version=api_version)351 path, 'POST', media_type, data, headers, api_version=api_version)
350352
351 def _convertArgs(self, operation_name, args):353 def _makeBoundary(self, all_parts):
352 """Encode and convert keyword arguments."""354 """Make a random boundary that does not appear in `all_parts`."""
353 args['ws.op'] = operation_name355 _width = len(repr(sys.maxsize - 1))
354 # To be properly marshalled all values must be strings or converted to356 _fmt = '%%0%dd' % _width
355 # JSON.357 token = random.randrange(sys.maxsize)
356 for key, value in args.items():358 boundary = ('=' * 15) + (_fmt % token) + '=='
357 if not isinstance(value, six.string_types):359 if all_parts is None:
358 args[key] = simplejson.dumps(value)360 return boundary
359 return urlencode(args)361 b = boundary
362 counter = 0
363 while True:
364 pattern = ('^--' + re.escape(b) + '(--)?$').encode('ascii')
365 if not re.search(pattern, all_parts, flags=re.MULTILINE):
366 break
367 b = boundary + '.' + str(counter)
368 counter += 1
369 return b
370
371 def _writeHeaders(self, buf, headers):
372 """Write MIME headers to a file object."""
373 for key, value in headers:
374 buf.write(key.encode('UTF-8'))
375 buf.write(b': ')
376 buf.write(value.encode('UTF-8'))
377 buf.write(b'\r\n')
378 buf.write(b'\r\n')
379
380 def _writeBoundary(self, buf, boundary, closing=False):
381 """Write a multipart boundary to a file object."""
382 buf.write(b'--')
383 buf.write(boundary.encode('UTF-8'))
384 if closing:
385 buf.write(b'--')
386 buf.write(b'\r\n')
387
388 def _convertArgs(self, args):
389 """Encode and convert keyword arguments to multipart/form-data.
390
391 This is very loosely based on the email module in the Python
392 standard library. However, that module doesn't really support
393 directly embedding binary data in a form: various versions of Python
394 have mangled line separators in different ways, and none of them get
395 it quite right. Since we only need a tiny subset of MIME here, it's
396 easier to implement it ourselves.
397
398 Binary argument values are supported, but must be passed as
399 instances of `io.BufferedIOBase` (e.g. `io.BytesIO`).
400
401 :return: a tuple of two elements: the Content-Type of the message,
402 and the entire encoded message as a byte string.
403 """
404 # Generate the subparts first so that we can calculate a safe boundary.
405 encoded_parts = []
406 for name, value in args.items():
407 buf = io.BytesIO()
408 if isinstance(value, io.BufferedIOBase):
409 ctype = 'application/octet-stream'
410 # RFC 7578 says that the filename parameter isn't mandatory
411 # in our case, but zope.publisher only considers a field to
412 # be a file upload if a filename was passed in.
413 cdisp = 'form-data; name="%s"; filename="%s"' % (
414 email_quote(name), email_quote(name))
415 else:
416 ctype = 'text/plain; charset="utf-8"'
417 cdisp = 'form-data; name="%s"' % email_quote(name)
418 self._writeHeaders(buf, [
419 ('MIME-Version', '1.0'),
420 ('Content-Type', ctype),
421 ('Content-Disposition', cdisp),
422 ])
423 if isinstance(value, io.BufferedIOBase):
424 buf.write(value.read())
425 else:
426 if not isinstance(value, six.string_types):
427 value = simplejson.dumps(value)
428 lines = re.split(r'\r\n|\r|\n', value)
429 for line in lines[:-1]:
430 buf.write(six.ensure_binary(line))
431 buf.write(b'\r\n')
432 buf.write(six.ensure_binary(lines[-1]))
433 encoded_parts.append(buf.getvalue())
434
435 # Create a suitable boundary.
436 boundary = self._makeBoundary(b'\r\n'.join(encoded_parts))
437
438 # Now we can write the multipart headers, followed by all the parts.
439 buf = io.BytesIO()
440 self._writeHeaders(buf, [])
441 for encoded_part in encoded_parts:
442 self._writeBoundary(buf, boundary)
443 buf.write(encoded_part)
444 buf.write(b'\r\n')
445 self._writeBoundary(buf, boundary, closing=True)
446
447 media_type = (
448 'multipart/form-data; boundary="%s"' % email_quote(boundary))
449 return media_type, buf.getvalue()
360450
361 def named_get(self, path_or_url, operation_name, headers=None,451 def named_get(self, path_or_url, operation_name, headers=None,
362 api_version=None, **kwargs):452 api_version=None, **kwargs):
@@ -368,9 +458,10 @@
368458
369 def named_post(self, path, operation_name, headers=None,459 def named_post(self, path, operation_name, headers=None,
370 api_version=None, **kwargs):460 api_version=None, **kwargs):
371 data = self._convertArgs(operation_name, kwargs)461 kwargs['ws.op'] = operation_name
372 return self.post(path, 'application/x-www-form-urlencoded', data,462 media_type, data = self._convertArgs(kwargs)
373 headers, api_version=api_version)463 return self.post(path, media_type, data, headers,
464 api_version=api_version)
374465
375 def patch(self, path, media_type, data, headers=None,466 def patch(self, path, media_type, data, headers=None,
376 api_version=None):467 api_version=None):

Subscribers

People subscribed via source and target branches