Skip to content
Closed
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
181 changes: 181 additions & 0 deletions connexion/content_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import logging
import re

from jsonschema import ValidationError

from .exceptions import ExtraParameterProblem, BadRequestProblem
from .types import coerce_type
from .utils import is_null

logger = logging.getLogger(__name__)


class ContentHandlerFactory(object):

def __init__(self, validator, schema, strict_validation,
is_null_value_valid, consumes):
self.validator = validator
self.schema = schema
self.strict_validation = strict_validation
self.is_null_value_valid = is_null_value_valid
self.consumes = consumes
self._content_handlers = self._discover()

def _discover(self):
content_handlers = ContentHandler.discover_subclasses()
return {
name: cls(self.validator, self.schema,
self.strict_validation, self.is_null_value_valid)
for name, cls in content_handlers.items()
}

def get_handler(self, content_type):
match = None

if content_type is None:
return match

media_type = content_type.split(";", 1)[0]
if media_type not in self.consumes:
return None

try:
return self._content_handlers[media_type]
except KeyError:
pass

matches = [
(name, handler) for name, handler in self._content_handlers.items()
if handler.regex.match(content_type)
]
if len(matches) > 1:
logger.warning(f"Content could be handled by multiple validators: {matches}")

if matches:
name, handler = matches[0]
return handler


class ContentHandler(object):

def __init__(self, validator, schema, strict, is_null_value_valid):
self.schema = schema
self.strict_validation = strict
self.is_null_value_valid = is_null_value_valid
self.validator = validator
self.default = schema.get('default')

def validate_schema(self, data, url):
# type: (dict, AnyStr) -> Union[ConnexionResponse, None]
if is_null(data):
if self.default:
# TODO do we need to do this? If the spec is valid, this will pass
data = self.default
elif self.is_null_value_valid:
return

try:
self.validator.validate(data)
except ValidationError as exception:
error_path = '.'.join(str(item) for item in exception.path)
error_path_msg = " - '{path}'".format(path=error_path) \
if error_path else ""
logger.error(
"{url} validation error: {error}{error_path_msg}".format(
url=url, error=exception.message,
error_path_msg=error_path_msg),
extra={'validator': 'body'})
raise BadRequestProblem(detail="{message}{error_path_msg}".format(
message=exception.message,
error_path_msg=error_path_msg))

def deserialize(self, request):
return request.body
Comment on lines +92 to +93
Copy link
Contributor

Choose a reason for hiding this comment

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

So, do you think we could also add a def serialize(self, response): on ContentHandler maybe as part of making pluggable serialization in #1095? Or would we need separate classes for request and response?


def validate_request(self, request):
data = self.deserialize(request)
self.validate_schema(data, request.url)

@classmethod
def discover_subclasses(cls):
subclasses = {c.name: c for c in cls.__subclasses__()}
for s in cls.__subclasses__():
subclasses.update(s.discover_subclasses())
return subclasses


class StreamingContentHandler(ContentHandler):
name = "application/octet-stream"
regex = re.compile(r'^application\/octet-stream.*')

def validate_request(self, request):
# Don't validate, leave stream for user to read
pass


class TextPlainContentHandler(ContentHandler):
name = "text/plain"
regex = re.compile(r'^text\/plain.*')

def validate_request(self, request):
# Don't validate, leave stream for user to read
pass


class JSONContentHandler(ContentHandler):
name = "application/json"
regex = re.compile(r'^application\/json.*|^.*\+json$')
Copy link
Contributor

Choose a reason for hiding this comment

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

is_json_mimetype checks for application/json or application/.*\+json but this checks for application/json or .*+json. They should probably both check for the same thing. So, maybe is_json_mimetype should do the equivalent of this regex. It makes sense to be even more lenient and treat anything that ends in +json as json.

Copy link
Contributor

Choose a reason for hiding this comment

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

There are several places that said validation can occur for text/plain as well as application/json - does this need to check for that too?


def deserialize(self, request):
data = request.json
empty_body = not(request.body or request.form or request.files)
if data is None and not empty_body and not self.is_null_value_valid:
# Content-Type is json but actual body was not parsed
raise BadRequestProblem(detail="Request body is not valid JSON")
return data


def validate_parameter_list(request_params, spec_params):
request_params = set(request_params)
spec_params = set(spec_params)

return request_params.difference(spec_params)


class FormDataContentHandler(ContentHandler):
name = "application/x-www-form-urlencoded"
regex = re.compile(
r'^application\/x-www-form-urlencoded.*'
)

def _validate_formdata_parameter_list(self, request):
request_params = request.form.keys()
spec_params = self.schema.get('properties', {}).keys()
return validate_parameter_list(request_params, spec_params)

def deserialize(self, request):
data = dict(request.form.items()) or \
(request.body if len(request.body) > 0 else {})
data.update(dict.fromkeys(request.files, '')) # validator expects string..
logger.debug('%s validating schema...', request.url)

if self.strict_validation:
formdata_errors = self._validate_formdata_parameter_list(request)
if formdata_errors:
raise ExtraParameterProblem(formdata_errors, [])

if data:
props = self.schema.get("properties", {})
for k, param_defn in props.items():
if k in data:
data[k] = coerce_type(param_defn, data[k], 'requestBody', k)
Copy link
Contributor

Choose a reason for hiding this comment

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

This used to be in a try/except to catch TypeValidationError and return a problem for it. Is that handled elsewhere?

# XXX it's surprising to hide this in validation
request.form = data
Comment on lines +172 to +173
Copy link
Contributor

Choose a reason for hiding this comment

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

What is this doing? I don't see this in the old code. I see that request.form is overwritten in connexion.decorators.uri_parsing.AbstractURIParser for forms in parameters, and flask_api sets it in get_request when creating the ConnexionRequest, but aiohttp_api does not set form when creating ConnexionRequest.

Is this actually something specific to aiohttp? Flask extracts the form data but aiohttp does not?

Copy link
Contributor

Choose a reason for hiding this comment

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

To make this less surprising, are you thinking of creating a new decorator, maybe a Consumes decorator to mirror Produces, and do the deserialization there? It looks like you listed deserializtion early in the doc on ConnexionRequest. Plus, I guess that's partially what the uri_parser is doing.

One possible gotcha is that Deserialization and Validation are tightly coupled because you can discover validation errors when coercing types during deserializtion.

return data


class MultiPartFormDataContentHandler(FormDataContentHandler):
name = "multipart/form-data"
regex = re.compile(
r'^multipart\/form-data.*'
)
8 changes: 3 additions & 5 deletions connexion/decorators/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
import inflection
import six

from ..http_facts import FORM_CONTENT_TYPES
from ..lifecycle import ConnexionRequest # NOQA
from ..utils import all_json
from ..utils import is_form_mimetype, is_json_mimetype

try:
import builtins
Expand Down Expand Up @@ -73,7 +72,6 @@ def parameter_to_arg(operation, function, pythonic_params=False,
request context will be passed as that argument.
:type pass_context_arg_name: str|None
"""
consumes = operation.consumes

def sanitized(name):
return name and re.sub('^[^a-zA-Z_]+', '', re.sub('[^0-9a-zA-Z_]', '', name))
Expand All @@ -91,9 +89,9 @@ def wrapper(request):
logger.debug('Function Arguments: %s', arguments)
kwargs = {}

if all_json(consumes):
if is_json_mimetype(request.content_type):
request_body = request.json
elif consumes[0] in FORM_CONTENT_TYPES:
elif is_form_mimetype(request.content_type):
request_body = {sanitize(k): v for k, v in request.form.items()}
else:
request_body = request.body
Expand Down
Loading