Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(testing): simulate multipart file upload #2141

Open
wants to merge 59 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
d37beaf
Enhancement #2124: added the samesite parameter to unset_cookie.
TigreModerata Jan 16, 2023
f89c31c
Added new option to documentation in cookies.srt
TigreModerata Jan 16, 2023
32daa63
Added tests, CookiesUnsetSameSite class and test_unset_cookies_samesi…
TigreModerata Jan 17, 2023
2b48c6b
Merge branch 'master' into #2124_Enhancement
TigreModerata Jan 17, 2023
6b9b198
Removed unused import logging from test_cookies
TigreModerata Jan 17, 2023
0a17b79
Reverted changes to docs/changes4.0.0.rst
TigreModerata Jan 18, 2023
187aa4f
Reverted changes to docs/changes4.0.0.rst
TigreModerata Jan 18, 2023
9d2fe9e
Added 'files' parameter to _simulate_request; Added tests for file up…
TigreModerata Feb 9, 2023
2b9d0e0
Fixed nested mixed request problems
TigreModerata Feb 9, 2023
835b062
clean up
TigreModerata Feb 10, 2023
3e28a94
updated docstrings
TigreModerata Feb 10, 2023
7920cdd
Fixes
TigreModerata Feb 10, 2023
72d54ee
Merge branch 'master' into SimulateMultipartFile#1010
TigreModerata Feb 10, 2023
ded1ac0
Fixes2
TigreModerata Feb 10, 2023
66d347b
Merge remote-tracking branch 'originTM/SimulateMultipartFile#1010' in…
TigreModerata Feb 10, 2023
574e667
Fixes3
TigreModerata Feb 10, 2023
ffa8d9f
Fixes4
TigreModerata Feb 10, 2023
eadb1f3
urllib3 in minitest requirements (?)
TigreModerata Feb 10, 2023
7493b92
minor fix 5
TigreModerata Feb 10, 2023
c49e369
minor fix 6
TigreModerata Feb 10, 2023
235ed3e
Removed urllib3; creating encoded bodystring in _encode_files.
TigreModerata Feb 12, 2023
94ae979
blued
TigreModerata Feb 12, 2023
c9d4f1b
formatting corrections
TigreModerata Feb 12, 2023
9a9e58d
Corrections after comments
TigreModerata Feb 12, 2023
608efb3
Added test_upload_fileobj
TigreModerata Feb 12, 2023
5c68868
typo fom-data
TigreModerata Feb 13, 2023
f052dca
removed conditional where object type bytes is not possible
TigreModerata Feb 19, 2023
b527de7
added fct testing null data value
TigreModerata Feb 19, 2023
1465241
another unneccesay if removed
TigreModerata Feb 19, 2023
e4cb4c9
Update falcon/testing/client.py
TigreModerata Mar 5, 2023
dba5eb9
Update falcon/testing/client.py
TigreModerata Mar 5, 2023
51ea53e
Update falcon/testing/client.py
TigreModerata Mar 5, 2023
8a66346
typo
TigreModerata Mar 5, 2023
3041583
check for datatype in json changed
TigreModerata Mar 5, 2023
77f350b
Corrections after code-review (part1)
TigreModerata Mar 5, 2023
4bc299a
blued
TigreModerata Mar 5, 2023
df37dbd
unnecessary import removed
TigreModerata Mar 5, 2023
2149b0c
Handling of new data parameter;
TigreModerata Mar 11, 2023
6f8b3ae
blued
TigreModerata Mar 11, 2023
632317c
utf-8 corrected
TigreModerata Mar 11, 2023
3b2edd8
more tests
TigreModerata Mar 11, 2023
bfa6a4c
test for string data
TigreModerata Mar 11, 2023
e1d9834
testing bool and float data types
TigreModerata Mar 11, 2023
514e936
blue & pep8
TigreModerata Mar 11, 2023
dc74456
Docstrings updated
TigreModerata Mar 12, 2023
6d8fd96
data string (not json) treated like body
TigreModerata Mar 26, 2023
eeba35d
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Jun 5, 2023
aef3e0d
Update 4.0.0.rst
vytas7 Jul 2, 2023
3c2b4fc
chore(requirements): remove extraneous whitespace from `mintest`
vytas7 Jul 3, 2023
8665a34
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Jul 11, 2023
1b7a207
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Jul 12, 2023
4572ee8
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Jul 19, 2023
62275cc
refactor: restore some stuff from master, temp remove 1 file for now
vytas7 Jul 19, 2023
5872d3d
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Dec 27, 2023
6845e5a
feat(testing): add a new parameter to simulate form
vytas7 Dec 29, 2023
93fad43
fix(testing): fix a regression wrt passing json to simulate_request
vytas7 Dec 29, 2023
3af2fcc
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Mar 3, 2024
6ae4d06
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 May 7, 2024
6ede18d
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Aug 30, 2024
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
1 change: 0 additions & 1 deletion docs/changes/4.0.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ Changes to Supported Platforms
- CPython 3.11 is now fully supported. (`#2072 <https://github.com/falconry/falcon/issues/2072>`__)
- End-of-life Python 3.5 & 3.6 are no longer supported. (`#2074 <https://github.com/falconry/falcon/pull/2074>`__)


Copy link
Member

Choose a reason for hiding this comment

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

Is there any particular reason behind this newline removal?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't know what happened... was that me?

Copy link
Member

Choose a reason for hiding this comment

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

I think it must have been you, yes. It is coming from you changeset, but it might be some artefact of merging things back and forth on your side, not necessarily something you actively did.

.. towncrier release notes start

Contributors to this Release
Expand Down
238 changes: 232 additions & 6 deletions falcon/testing/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,20 @@
import datetime as dt
import inspect
import json as json_module
import os
import time
from typing import Dict
from typing import Optional
from typing import Sequence
from typing import Union
from urllib.parse import urlencode
import warnings
import wsgiref.validate

from falcon.asgi_spec import ScopeType
from falcon.constants import COMBINED_METHODS
from falcon.constants import MEDIA_JSON
from falcon.errors import CompatibilityError
from falcon.constants import MEDIA_JSON, MEDIA_URLENCODED
from falcon.errors import CompatibilityError, HTTPBadRequest
from falcon.testing import helpers
from falcon.testing.srmock import StartResponseMock
from falcon.util import async_to_sync
Expand Down Expand Up @@ -95,7 +97,7 @@ class Cookie:
or ``None`` if not specified.
max_age (int): The lifetime of the cookie in seconds, or
``None`` if not specified.
secure (bool): Whether or not the cookie may only only be
secure (bool): Whether or not the cookie may only be
transmitted from the client via HTTPS.
http_only (bool): Whether or not the cookie may only be
included in unscripted requests from the client.
Expand Down Expand Up @@ -437,6 +439,8 @@ def simulate_request(
content_type=None,
body=None,
json=None,
files=None,
data=None,
file_wrapper=None,
wsgierrors=None,
params=None,
Expand Down Expand Up @@ -528,6 +532,30 @@ def simulate_request(
overrides `body` and sets the Content-Type header to
``'application/json'``, overriding any value specified by either
the `content_type` or `headers` arguments.

Note:
Can only be used if data and files are null, otherwise an exception
is thrown.

files(dict): same as the files parameter in requests,
dictionary of ``'name': file-like-objects`` (or ``{'name': file-tuple}``)
for multipart encoding upload.
``file-tuple``: can be a 2-tuple ``('filename', fileobj)`` or a
3-tuple ``('filename', fileobj, 'content_type')``
where ``'content-type'`` is a string defining the content
type of the given file.

Note:
If both data and json are present, an exception is thrown.
To pass additional form-data with files, use data.

data : list of tuples, dict or (b)string, with additional data
to be passed with files (or alone if files is null), to be treated
as urlencoded form data.

Note:
If both data and json are present, an exception is thrown.

file_wrapper (callable): Callable that returns an iterable,
to be used as the value for *wsgi.file_wrapper* in the
WSGI environ (default: ``None``). This can be used to test
Expand Down Expand Up @@ -575,6 +603,8 @@ def simulate_request(
content_type=content_type,
body=body,
json=json,
files=files,
data=data,
params=params,
params_csv=params_csv,
protocol=protocol,
Expand All @@ -598,6 +628,8 @@ def simulate_request(
headers,
body,
json,
files,
data,
extras,
)

Expand All @@ -622,7 +654,7 @@ def simulate_request(
# NOTE(vytas): Even given the duct tape nature of overriding
# arbitrary environ variables, changing the method can potentially
# be very confusing, particularly when using specialized
# simulate_get/post/patch etc methods.
# simulate_get/post/patch etc. methods.
raise ValueError(
'WSGI environ extras may not override the request method. '
'Please use the method parameter.'
Expand Down Expand Up @@ -651,6 +683,8 @@ async def _simulate_request_asgi(
content_type=None,
body=None,
json=None,
files=None,
data=None,
params=None,
params_csv=True,
protocol='http',
Expand Down Expand Up @@ -736,6 +770,29 @@ async def _simulate_request_asgi(
overrides `body` and sets the Content-Type header to
``'application/json'``, overriding any value specified by either
the `content_type` or `headers` arguments.

Note:
Can only be used if data and files are null, otherwise an exception
is thrown.

files(dict): same as the files parameter in requests,
vytas7 marked this conversation as resolved.
Show resolved Hide resolved
dictionary of ``'name': file-like-objects`` (or ``{'name': file-tuple}``)
for multipart encoding upload.
``file-tuple``: can be a 2-tuple ``('filename', fileobj)`` or a
3-tuple ``('filename', fileobj, 'content_type')``,
where ``'content-type'`` is a string defining the content
type of the given file.

Mote:
If both files and json are present, an exception is thrown. To pass
additional form-data with files, use data.

data : list of tuples, dict or (b)string with additional data to be passed with
files (or alone if files is null), to be treated as urlencoded form data.

Note:
If both data and json are present, an exception is thrown.

host(str): A string to use for the hostname part of the fully
qualified request URL (default: 'falconframework.org')
remote_addr (str): A string to use as the remote IP address for the
Expand Down Expand Up @@ -774,6 +831,8 @@ async def _simulate_request_asgi(
headers,
body,
json,
files,
data,
extras,
)

Expand Down Expand Up @@ -2133,8 +2192,163 @@ async def __aexit__(self, exc_type, exc, tb):
await self._task_req


def _prepare_data_fields(data, boundary=None, urlenc=False):
"""Prepare data fields for request body.

Args:
data: dict or list of tuples with json data from the request

Returns: list of 2-tuples (field-name(str), value(bytes))

"""
urlresult = []
body_part = b''
if isinstance(data, (str, bytes)) or hasattr(data, 'read'):
fields = list(json_module.loads(data).items())
Copy link
Member

Choose a reason for hiding this comment

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

so we are assuming that if provided as string it's a json.

this would mean that we cannot replace deprecate body with data.

let's see @vytas7 opinion

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ok, I hadn't really understood what you guys meant by replacing the body... but ok, that should be an easy fix. I'll try to implement it, then you can use whatever works best.

elif isinstance(data, dict):
fields = list(data.items())
else:
fields = list(data)

# Append data to the other multipart parts
for field, val in fields:
if isinstance(val, str) or not hasattr(val, '__iter__'):
val = [val]
# if no files are passed, make urlencoded form
if urlenc:
for v in val:
if v:
urlresult.append(
(
field.encode('utf-8') if isinstance(field, str) else field,
v.encode('utf-8') if isinstance(v, str) else v,
)
)
# if files and data are passed, concat data to files body like in requests
else:
for v in val:
body_part += (
f'Content-Disposition: form-data; name={field}; '
f'\r\n\r\n'.encode()
)
if v:
if not isinstance(v, bytes):
v = str(v)
body_part += v.encode('utf-8') if isinstance(v, str) else v
body_part += b'\r\n--' + boundary.encode() + b'\r\n'
else:
body_part += b'\r\n--' + boundary.encode() + b'\r\n'

return body_part if not urlenc else urlencode(urlresult, doseq=True)


def _prepare_files(k, v):
"""Prepare file attributes for body of request form.

Args:
k: (str), file-name
v: fileobj or tuple (filename, data, content_type?)

Returns: file_name, file_data, file_content_type

"""
file_content_type = None
if not v:
raise ValueError(f'No file provided for {k}')
if isinstance(v, (tuple, list)):
if len(v) == 2:
file_name, file_data = v
else:
file_name, file_data, file_content_type = v
TigreModerata marked this conversation as resolved.
Show resolved Hide resolved
if (
len(v) == 3
and file_content_type
and file_content_type.startswith('multipart/mixed')
):
file_data, new_header = _encode_files(json_module.loads(file_data.decode()))
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure if we need to allow this custom implementation that allows for nested file send.

This is assuming quite a bit of things, in particular that file_data is a byte string that represent json, this is then de-serialized and re-encoded as files.

seems quite complex and custom.

Maybe a better could be to have a way of creating a multipart body that can be embedded into the main request as plain bytes?

so you would pass a nested body as something like this

files = {
  'x': 'other stuff',
  'nested': (None, make_multipart(...), 'multipart/mixed')
}

Copy link
Member

Choose a reason for hiding this comment

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

I don't think we should support nested multipart forms at all, at least not in this issue. There is a FAQ item on how to parse them that probably already gives too much attention to the topic, it is a super legacy edge case. One can construct the payload in other ways; if they have such legacy code, probably there is already some existing test to create such a payload.

file_content_type = 'multipart/mixed; ' + (
new_header['Content-Type'].split('; ')[1]
)
else:
# if v is not a tuple or iterable it has to be a filelike obj
name = getattr(v, 'name', None)
if name and isinstance(name, str) and name[0] != '<' and name[-1] != '>':
file_name = os.path.basename(name)
else:
file_name = k
file_data = v
if hasattr(file_data, 'read'):
file_data = file_data.read()
return file_name, file_data, file_content_type


def _make_boundary():
"""
Create random boundary to be used in multipart/form-data with files.
"""
boundary = os.urandom(16).hex()
return boundary


def _encode_files(files, data=None):
"""Build the body for a multipart/form-data request.

Will successfully encode files when passed as a dict or a list of
tuples. ``data`` fields are added first.
The tuples may be 2-tuples (filename, fileobj) or
3-tuples (filename, fileobj, contentype).
Allows for content_type = ``multipart/mixed`` for submission of nested files

Returns: (encoded body string, headers dict)
"""
boundary = _make_boundary()
body_string = b'--' + boundary.encode() + b'\r\n'
header = {'Content-Type': 'multipart/form-data; boundary=' + boundary}

# Deal with the files tuples
if not isinstance(files, (dict, list)):
raise ValueError('cannot encode objects that are not 2-tuples')
elif isinstance(files, dict):
files = list(files.items())

for (k, v) in files:
file_name, file_data, file_content_type = _prepare_files(k, v)
if not file_data:
continue

body_string += f'Content-Disposition: form-data; name={k}; '.encode()
body_string += (
f'filename={file_name}\r\n'.encode() if file_name else '\r\n'.encode()
)
body_string += (
f'Content-Type: {file_content_type or "text/plain"}\r\n\r\n'.encode()
)
body_string += (
file_data.encode('utf-8') if isinstance(file_data, str) else file_data
)
body_string += b'\r\n--' + boundary.encode() + b'\r\n'

# Handle whatever json data gets passed along with files
if data:
body_string += _prepare_data_fields(data, boundary)

body_string = body_string[:-2] + b'--\r\n'

return body_string, header


def _prepare_sim_args(
path, query_string, params, params_csv, content_type, headers, body, json, extras
path,
query_string,
params,
params_csv,
content_type,
headers,
body,
json,
files,
data,
extras,
):
if not path.startswith('/'):
raise ValueError("path must start with '/'")
Expand Down Expand Up @@ -2163,7 +2377,19 @@ def _prepare_sim_args(
headers = headers or {}
headers['Content-Type'] = content_type

if json is not None:
if files or data:
if json:
raise HTTPBadRequest(
description='Cannot process both json and (files or data) args'
)
elif files:
body, headers = _encode_files(files, data)
else:
body = _prepare_data_fields(data, None, True)
headers = headers or {}
headers['Content-Type'] = MEDIA_URLENCODED

elif json is not None:
body = json_module.dumps(json, ensure_ascii=False)
headers = headers or {}
headers['Content-Type'] = MEDIA_JSON
Expand Down
1 change: 1 addition & 0 deletions requirements/mintest
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ pytest
pyyaml
requests
ujson

1 change: 0 additions & 1 deletion tests/test_media_multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@
b'--boundary--\r\n'
)


EXAMPLES = {
'5b11af82ab65407ba8cdccf37d2a9c4f': EXAMPLE1,
'---------------------------1574247108204320607285918568': EXAMPLE2,
Expand Down
Loading