Skip to content

Commit 99f73e6

Browse files
Validate parameter types are supported by encodings.
1 parent e866a69 commit 99f73e6

4 files changed

Lines changed: 178 additions & 34 deletions

File tree

coreapi/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ class ValidationError(Exception):
4848
pass
4949

5050

51+
class InvalidLinkError(Exception):
52+
pass
53+
54+
5155
class ErrorMessage(Exception):
5256
"""
5357
Raised when the transition returns an error message.

coreapi/transports/http.py

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# coding: utf-8
22
from __future__ import unicode_literals
33
from collections import OrderedDict
4+
from coreapi import exceptions, utils
45
from coreapi.compat import is_file, urlparse
56
from coreapi.document import Document, Object, Link, Array, Error
6-
from coreapi.exceptions import ErrorMessage
77
from coreapi.transports.base import BaseTransport
8-
from coreapi.utils import negotiate_decoder
98
import collections
109
import requests
1110
import itypes
@@ -14,8 +13,8 @@
1413
import uritemplate
1514

1615

17-
Params = collections.namedtuple('Params', ['path', 'query', 'body', 'data', 'files'])
18-
empty_params = Params({}, {}, None, {}, {})
16+
Params = collections.namedtuple('Params', ['path', 'query', 'data', 'files'])
17+
empty_params = Params({}, {}, {}, {})
1918

2019

2120
class ForceMultiPartDict(dict):
@@ -35,9 +34,9 @@ def _get_method(action):
3534
return action.upper()
3635

3736

38-
def _get_params(method, fields, params=None):
37+
def _get_params(method, encoding, fields, params=None):
3938
"""
40-
Separate the params into their location types.
39+
Separate the params into the various types.
4140
"""
4241
if params is None:
4342
return empty_params
@@ -46,7 +45,6 @@ def _get_params(method, fields, params=None):
4645

4746
path = {}
4847
query = {}
49-
body = None
5048
data = {}
5149
files = {}
5250

@@ -58,18 +56,18 @@ def _get_params(method, fields, params=None):
5856
location = field_map[key].location
5957

6058
if location == 'path':
61-
path[key] = value
59+
path[key] = utils.validate_path_param(value, name=key)
6260
elif location == 'query':
63-
query[key] = value
61+
query[key] = utils.validate_query_param(value, name=key)
6462
elif location == 'body':
65-
body = value
63+
data = utils.validate_body_param(value, encoding=encoding, name=key)
6664
elif location == 'form':
6765
if is_file(value):
68-
files[key] = value
66+
files[key] = utils.validate_form_files(value, encoding=encoding, name=key)
6967
else:
70-
data[key] = value
68+
data[key] = utils.validate_form_data(value, encoding=encoding, name=key)
7169

72-
return Params(path, query, body, data, files)
70+
return Params(path, query, data, files)
7371

7472

7573
def _get_encoding(encoding, method):
@@ -113,18 +111,31 @@ def _get_headers(url, decoders, credentials=None):
113111
return headers
114112

115113

116-
def _get_content_type(file_obj):
114+
def _get_upload_content_type(file_obj):
117115
"""
118116
When a raw file upload is made, determine a content-type where possible.
119117
"""
120118
name = getattr(file_obj, 'name', None)
121119
if name is not None:
122120
content_type, encoding = mimetypes.guess_type(name)
123121
else:
124-
content_type = None
122+
content_type = 'application/octet-stream'
125123
return content_type
126124

127125

126+
def _get_upload_content_disposition(file_obj):
127+
"""
128+
When a raw file upload is made, determine a content-type where possible.
129+
"""
130+
name = getattr(file_obj, 'name', None)
131+
if name is not None:
132+
filename = os.path.basename(file_obj.name)
133+
content_disposition = 'attachment; filename="%s"' % filename
134+
else:
135+
content_disposition = 'attachment'
136+
return content_disposition
137+
138+
128139
def _build_http_request(session, url, method, headers=None, encoding=None, params=empty_params):
129140
"""
130141
Make an HTTP request and return an HTTP response.
@@ -136,28 +147,19 @@ def _build_http_request(session, url, method, headers=None, encoding=None, param
136147
if params.query:
137148
opts['params'] = params.query
138149

139-
if (params.body is not None) or params.data or params.files:
150+
if params.data or params.files:
140151
if encoding == 'application/json':
141-
if params.body is not None:
142-
opts['json'] = params.body
143-
else:
144-
opts['json'] = params.data
152+
opts['json'] = params.data
145153
elif encoding == 'multipart/form-data':
146154
opts['data'] = params.data
147155
opts['files'] = ForceMultiPartDict(params.files)
148156
elif encoding == 'application/x-www-form-urlencoded':
149157
opts['data'] = params.data
150158
elif encoding == 'application/octet-stream':
151-
opts['data'] = params.body
152-
content_type = _get_content_type(params.body)
153-
if content_type:
154-
opts['headers']['content-type'] = content_type
155-
156-
if hasattr(params.body, 'name'):
157-
filename = os.path.basename(params.body.name)
158-
content_disposition = 'attachment; filename="%s"' % filename
159-
else:
160-
content_disposition = 'attachment'
159+
opts['data'] = params.data
160+
content_type = _get_upload_content_type(params.data)
161+
content_disposition = _get_upload_content_disposition(params.data)
162+
opts['headers']['content-type'] = content_type
161163
opts['headers']['content-disposition'] = content_disposition
162164

163165
request = requests.Request(method, url, **opts)
@@ -214,7 +216,7 @@ def _decode_result(response, decoders, force_codec=False):
214216
codec = decoders[0]
215217
else:
216218
content_type = response.headers.get('content-type')
217-
codec = negotiate_decoder(decoders, content_type)
219+
codec = utils.negotiate_decoder(decoders, content_type)
218220

219221
options = {
220222
'base_url': response.url
@@ -290,7 +292,7 @@ def transition(self, link, decoders, params=None, link_ancestors=None, force_cod
290292
session = self._session
291293
method = _get_method(link.action)
292294
encoding = _get_encoding(link.encoding, method)
293-
params = _get_params(method, link.fields, params)
295+
params = _get_params(method, encoding, link.fields, params)
294296
url = _get_url(link.url, params.path)
295297
headers = _get_headers(url, decoders, self.credentials)
296298
headers.update(self.headers)
@@ -309,6 +311,6 @@ def transition(self, link, decoders, params=None, link_ancestors=None, force_cod
309311
result = _handle_inplace_replacements(result, link, link_ancestors)
310312

311313
if isinstance(result, Error):
312-
raise ErrorMessage(result)
314+
raise exceptions.ErrorMessage(result)
313315

314316
return result

coreapi/utils.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from coreapi import exceptions
2-
from coreapi.compat import urlparse
2+
from coreapi.compat import string_types, urlparse
33

44

55
def determine_transport(transports, url):
@@ -69,3 +69,90 @@ def negotiate_encoder(encoders, accept=None):
6969

7070
msg = "Unsupported media in Accept header '%s'" % accept
7171
raise exceptions.NotAcceptable(msg)
72+
73+
74+
def validate_path_param(value, name):
75+
value = _validate_form_primative(value, name, _allow_list=False)
76+
if not value:
77+
msg = 'Parameter %s: May not be empty.'
78+
raise exceptions.ValidationError(msg % name)
79+
return value
80+
81+
82+
def validate_query_param(value, name):
83+
return _validate_form_primative(value, name)
84+
85+
86+
def validate_body_param(value, encoding, name):
87+
if encoding == 'application/json':
88+
return _validate_json_data(value, name)
89+
elif encoding in ('multipart/form', 'application/x-www-form-urlencoded'):
90+
if not isinstance(value, dict):
91+
msg = 'Parameter %s: Must be an object.'
92+
raise exceptions.ValidationError(msg % name)
93+
return {
94+
item_key: _validate_form_primative(item_val, name)
95+
for item_key, item_val in value.items()
96+
}
97+
_unsupported_encoding(encoding)
98+
99+
100+
def validate_form_data(value, encoding, name):
101+
if encoding == 'application/json':
102+
return _validate_json_data(value, name)
103+
elif encoding in ('multipart/form', 'application/x-www-form-urlencoded'):
104+
return _validate_form_primative(value, name)
105+
_unsupported_encoding(encoding)
106+
107+
108+
def validate_form_files(value, encoding, name):
109+
if encoding == 'multipart/form':
110+
return value
111+
elif encoding in ('application/json', 'application/x-www-form-urlencoded'):
112+
msg = 'Parameter %s: File uploads not supported.'
113+
raise exceptions.ValidationError(msg % name)
114+
_unsupported_encoding(encoding)
115+
116+
117+
def _validate_form_primative(value, name, _allow_list=True):
118+
"""
119+
Parameters in query parameters or form data should be basic types, that
120+
have a simple string representation. A list of basic types is also valid.
121+
"""
122+
if isinstance(value, string_types):
123+
return value
124+
elif isinstance(value, bool) or (value is None):
125+
return {True: 'true', False: 'false', None: ''}[value]
126+
elif isinstance(value, (int, float)):
127+
return "%s" % value
128+
elif _allow_list and isinstance(value, (list, tuple)):
129+
return [
130+
_validate_form_primative(item, name, _allow_list=False)
131+
for item in value
132+
]
133+
134+
msg = 'Parameter %s: Must be a primative type.'
135+
raise exceptions.ValidationError(msg % name)
136+
137+
138+
def _validate_json_data(value, name):
139+
if (value is None) or isinstance(value, string_types + (bool, int, float)):
140+
return value
141+
elif isinstance(value, (list, tuple)):
142+
return [_validate_json_data(item, name) for item in value]
143+
elif isinstance(value, dict):
144+
return {
145+
item_key: _validate_json_data(item_val, name)
146+
for item_key, item_val in value.items()
147+
}
148+
149+
msg = 'Parameter %s: Must be a JSON primative.'
150+
raise exceptions.ValidationError(msg % name)
151+
152+
153+
def _unsupported_encoding(encoding):
154+
if not encoding:
155+
msg = 'Link has no encoding, but includes "body" or "form" parameters.'
156+
raise exceptions.InvalidLinkError(msg)
157+
msg = 'Link has unsupported encoding "%s".'
158+
raise exceptions.InvalidLinkError(msg % encoding)

tests/test_utils.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from coreapi import exceptions, utils
2+
import datetime
3+
import pytest
4+
5+
6+
def test_validate_path_param():
7+
assert utils.validate_path_param(1, name='example') == '1'
8+
assert utils.validate_path_param(True, name='example') == 'true'
9+
with pytest.raises(exceptions.ValidationError):
10+
utils.validate_path_param(None, name='example')
11+
with pytest.raises(exceptions.ValidationError):
12+
utils.validate_path_param('', name='example')
13+
with pytest.raises(exceptions.ValidationError):
14+
utils.validate_path_param({}, name='example')
15+
with pytest.raises(exceptions.ValidationError):
16+
utils.validate_path_param([], name='example')
17+
18+
19+
def test_validate_query_param():
20+
assert utils.validate_query_param(1, name='example') == '1'
21+
assert utils.validate_query_param(True, name='example') == 'true'
22+
assert utils.validate_query_param(None, name='example') == ''
23+
assert utils.validate_query_param('', name='example') == ''
24+
assert utils.validate_query_param([1, 2, 3], name='example') == ['1', '2', '3']
25+
with pytest.raises(exceptions.ValidationError):
26+
utils.validate_query_param({}, name='example')
27+
with pytest.raises(exceptions.ValidationError):
28+
utils.validate_query_param([1, 2, {}], name='example')
29+
30+
31+
def test_validate_form_data():
32+
data = {
33+
'string': 'abc',
34+
'integer': 123,
35+
'number': 123.456,
36+
'boolean': True,
37+
'null': None,
38+
'array': [1, 2, 3],
39+
'object': {'a': 1, 'b': 2, 'c': 3}
40+
}
41+
assert utils.validate_form_data(data, 'application/json', name='example') == data
42+
with pytest.raises(exceptions.ValidationError):
43+
data = datetime.datetime.now()
44+
utils.validate_form_data(data, 'application/json', name='example')
45+
46+
assert utils.validate_form_data(123, 'application/x-www-form-urlencoded', name='example') == '123'
47+
48+
with pytest.raises(exceptions.InvalidLinkError):
49+
assert utils.validate_form_data(123, 'invalid/media-type', name='example')
50+
with pytest.raises(exceptions.InvalidLinkError):
51+
assert utils.validate_form_data(123, '', name='example')

0 commit comments

Comments
 (0)