diff --git a/openapi_core/deserializing/parameters/deserializers.py b/openapi_core/deserializing/parameters/deserializers.py index 5dd91350..348eac95 100644 --- a/openapi_core/deserializing/parameters/deserializers.py +++ b/openapi_core/deserializing/parameters/deserializers.py @@ -1,3 +1,5 @@ +import warnings + from openapi_core.deserializing.exceptions import DeserializeError from openapi_core.deserializing.parameters.exceptions import ( EmptyParameterValue, @@ -7,19 +9,28 @@ class PrimitiveDeserializer(object): - def __init__(self, param, deserializer_callable): - self.param = param + def __init__(self, param_or_header, deserializer_callable): + self.param_or_header = param_or_header self.deserializer_callable = deserializer_callable - self.aslist = get_aslist(self.param) - self.explode = get_explode(self.param) - self.style = get_style(self.param) + self.aslist = get_aslist(self.param_or_header) + self.explode = get_explode(self.param_or_header) + self.style = get_style(self.param_or_header) def __call__(self, value): - if (self.param['in'] == 'query' and value == "" and - not self.param.getkey('allowEmptyValue', False)): - raise EmptyParameterValue( - value, self.style, self.param['name']) + # if "in" not defined then it's a Header + if 'allowEmptyValue' in self.param_or_header: + warnings.warn( + "Use of allowEmptyValue property is deprecated", + DeprecationWarning, + ) + allow_empty_values = self.param_or_header.getkey( + 'allowEmptyValue', False) + location_name = self.param_or_header.getkey('in', 'header') + if (location_name == 'query' and value == "" and + not allow_empty_values): + name = self.param_or_header.getkey('name', 'header') + raise EmptyParameterValue(value, self.style, name) if not self.aslist or self.explode: return value diff --git a/openapi_core/exceptions.py b/openapi_core/exceptions.py index cfcf39d4..bdc5eba0 100644 --- a/openapi_core/exceptions.py +++ b/openapi_core/exceptions.py @@ -6,6 +6,32 @@ class OpenAPIError(Exception): pass +class OpenAPIHeaderError(OpenAPIError): + pass + + +class MissingHeaderError(OpenAPIHeaderError): + """Missing header error""" + pass + + +@attr.s(hash=True) +class MissingHeader(MissingHeaderError): + name = attr.ib() + + def __str__(self): + return "Missing header (without default value): {0}".format( + self.name) + + +@attr.s(hash=True) +class MissingRequiredHeader(MissingHeaderError): + name = attr.ib() + + def __str__(self): + return "Missing required header: {0}".format(self.name) + + class OpenAPIParameterError(OpenAPIError): pass diff --git a/openapi_core/schema/parameters.py b/openapi_core/schema/parameters.py index cfc5ca19..38d8d9ff 100644 --- a/openapi_core/schema/parameters.py +++ b/openapi_core/schema/parameters.py @@ -1,34 +1,54 @@ from __future__ import division -def get_aslist(param): - """Checks if parameter is described as list for simpler scenarios""" +def get_aslist(param_or_header): + """Checks if parameter/header is described as list for simpler scenarios""" # if schema is not defined it's a complex scenario - if 'schema' not in param: + if 'schema' not in param_or_header: return False - param_schema = param / 'schema' - schema_type = param_schema.getkey('type', 'any') + schema = param_or_header / 'schema' + schema_type = schema.getkey('type', 'any') # TODO: resolve for 'any' schema type return schema_type in ['array', 'object'] -def get_style(param): - """Checks parameter style for simpler scenarios""" - if 'style' in param: - return param['style'] +def get_style(param_or_header): + """Checks parameter/header style for simpler scenarios""" + if 'style' in param_or_header: + return param_or_header['style'] + + # if "in" not defined then it's a Header + location = param_or_header.getkey('in', 'header') # determine default return ( - 'simple' if param['in'] in ['path', 'header'] else 'form' + 'simple' if location in ['path', 'header'] else 'form' ) -def get_explode(param): - """Checks parameter explode for simpler scenarios""" - if 'explode' in param: - return param['explode'] +def get_explode(param_or_header): + """Checks parameter/header explode for simpler scenarios""" + if 'explode' in param_or_header: + return param_or_header['explode'] # determine default - style = get_style(param) + style = get_style(param_or_header) return style == 'form' + + +def get_value(param_or_header, location, name=None): + """Returns parameter/header value from specific location""" + name = name or param_or_header['name'] + + if name not in location: + raise KeyError + + aslist = get_aslist(param_or_header) + explode = get_explode(param_or_header) + if aslist and explode: + if hasattr(location, 'getall'): + return location.getall(name) + return location.getlist(name) + + return location[name] diff --git a/openapi_core/testing/responses.py b/openapi_core/testing/responses.py index af96d0b0..55b92f67 100644 --- a/openapi_core/testing/responses.py +++ b/openapi_core/testing/responses.py @@ -5,9 +5,12 @@ class MockResponseFactory(object): @classmethod - def create(cls, data, status_code=200, mimetype='application/json'): + def create( + cls, data, status_code=200, headers=None, + mimetype='application/json'): return OpenAPIResponse( data=data, status_code=status_code, + headers=headers or {}, mimetype=mimetype, ) diff --git a/openapi_core/validation/request/validators.py b/openapi_core/validation/request/validators.py index f19e86e3..3c62d4a0 100644 --- a/openapi_core/validation/request/validators.py +++ b/openapi_core/validation/request/validators.py @@ -5,9 +5,6 @@ from openapi_core.casting.schemas.exceptions import CastError from openapi_core.deserializing.exceptions import DeserializeError -from openapi_core.deserializing.parameters.factories import ( - ParameterDeserializersFactory, -) from openapi_core.exceptions import ( MissingRequiredParameter, MissingParameter, MissingRequiredRequestBody, MissingRequestBody, @@ -46,10 +43,6 @@ def schema_unmarshallers_factory(self): def security_provider_factory(self): return SecurityProviderFactory() - @property - def parameter_deserializers_factory(self): - return ParameterDeserializersFactory() - def validate(self, request): try: path, operation, _, path_result, _ = self._find_path(request) @@ -177,35 +170,23 @@ def _get_parameters(self, request, params): return RequestParameters(**locations), errors def _get_parameter(self, param, request): - if param.getkey('deprecated', False): + name = param['name'] + deprecated = param.getkey('deprecated', False) + if deprecated: warnings.warn( - "{0} parameter is deprecated".format(param['name']), + "{0} parameter is deprecated".format(name), DeprecationWarning, ) + param_location = param['in'] + location = request.parameters[param_location] try: - raw_value = self._get_parameter_value(param, request) - except MissingParameter: - if 'schema' not in param: - raise - schema = param / 'schema' - if 'default' not in schema: - raise - casted = schema['default'] - else: - # Simple scenario - if 'content' not in param: - deserialised = self._deserialise_parameter(param, raw_value) - schema = param / 'schema' - # Complex scenario - else: - content = param / 'content' - mimetype, media_type = next(content.items()) - deserialised = self._deserialise_data(mimetype, raw_value) - schema = media_type / 'schema' - casted = self._cast(schema, deserialised) - unmarshalled = self._unmarshal(schema, casted) - return unmarshalled + return self._get_param_or_header_value(param, location) + except KeyError: + required = param.getkey('required', False) + if required: + raise MissingRequiredParameter(name) + raise MissingParameter(name) def _get_body(self, request, operation): if 'requestBody' not in operation: @@ -280,7 +261,3 @@ def _get_body_value(self, request_body, request): raise MissingRequiredRequestBody(request) raise MissingRequestBody(request) return request.body - - def _deserialise_parameter(self, param, value): - deserializer = self.parameter_deserializers_factory.create(param) - return deserializer(value) diff --git a/openapi_core/validation/response/datatypes.py b/openapi_core/validation/response/datatypes.py index f55fc170..b2722ee3 100644 --- a/openapi_core/validation/response/datatypes.py +++ b/openapi_core/validation/response/datatypes.py @@ -1,5 +1,6 @@ """OpenAPI core validation response datatypes module""" import attr +from werkzeug.datastructures import Headers from openapi_core.validation.datatypes import BaseValidationResult @@ -13,14 +14,15 @@ class OpenAPIResponse(object): The response body, as string. status_code The status code as integer. + headers + Response headers as Headers. mimetype Lowercase content type without charset. """ - data = attr.ib() status_code = attr.ib() - mimetype = attr.ib() + headers = attr.ib(factory=Headers, converter=Headers) @attr.s diff --git a/openapi_core/validation/response/validators.py b/openapi_core/validation/response/validators.py index f1cdb0c3..5f2045e2 100644 --- a/openapi_core/validation/response/validators.py +++ b/openapi_core/validation/response/validators.py @@ -1,9 +1,12 @@ """OpenAPI core validation response validators module""" from __future__ import division +import warnings from openapi_core.casting.schemas.exceptions import CastError from openapi_core.deserializing.exceptions import DeserializeError -from openapi_core.exceptions import MissingResponseContent +from openapi_core.exceptions import ( + MissingHeader, MissingRequiredHeader, MissingResponseContent, +) from openapi_core.templating.media_types.exceptions import MediaTypeFinderError from openapi_core.templating.paths.exceptions import PathError from openapi_core.templating.responses.exceptions import ResponseFinderError @@ -117,12 +120,48 @@ def _get_data(self, response, operation_response): return data, [] def _get_headers(self, response, operation_response): - errors = [] + if 'headers' not in operation_response: + return {}, [] - # @todo: implement - headers = {} + headers = operation_response / 'headers' - return headers, errors + errors = [] + validated = {} + for name, header in headers.items(): + # ignore Content-Type header + if name.lower() == "content-type": + continue + try: + value = self._get_header(name, header, response) + except MissingHeader: + continue + except ( + MissingRequiredHeader, DeserializeError, + CastError, ValidateError, UnmarshalError, + ) as exc: + errors.append(exc) + continue + else: + validated[name] = value + + return validated, errors + + def _get_header(self, name, header, response): + deprecated = header.getkey('deprecated', False) + if deprecated: + warnings.warn( + "{0} header is deprecated".format(name), + DeprecationWarning, + ) + + try: + return self._get_param_or_header_value( + header, response.headers, name=name) + except KeyError: + required = header.getkey('required', False) + if required: + raise MissingRequiredHeader(name) + raise MissingHeader(name) def _get_data_value(self, response): if not response.data: diff --git a/openapi_core/validation/validators.py b/openapi_core/validation/validators.py index 55a3f109..2b2cf8fd 100644 --- a/openapi_core/validation/validators.py +++ b/openapi_core/validation/validators.py @@ -5,6 +5,10 @@ from openapi_core.deserializing.media_types.factories import ( MediaTypeDeserializersFactory, ) +from openapi_core.deserializing.parameters.factories import ( + ParameterDeserializersFactory, +) +from openapi_core.schema.parameters import get_value from openapi_core.templating.paths.finders import PathFinder from openapi_core.unmarshalling.schemas.util import build_format_checker @@ -36,6 +40,10 @@ def media_type_deserializers_factory(self): return MediaTypeDeserializersFactory( self.custom_media_type_deserializers) + @property + def parameter_deserializers_factory(self): + return ParameterDeserializersFactory() + @property def schema_unmarshallers_factory(self): raise NotImplementedError @@ -52,6 +60,10 @@ def _deserialise_data(self, mimetype, value): deserializer = self.media_type_deserializers_factory.create(mimetype) return deserializer(value) + def _deserialise_parameter(self, param, value): + deserializer = self.parameter_deserializers_factory.create(param) + return deserializer(value) + def _cast(self, schema, value): caster = self.schema_casters_factory.create(schema) return caster(value) @@ -59,3 +71,29 @@ def _cast(self, schema, value): def _unmarshal(self, schema, value): unmarshaller = self.schema_unmarshallers_factory.create(schema) return unmarshaller(value) + + def _get_param_or_header_value(self, param_or_header, location, name=None): + try: + raw_value = get_value(param_or_header, location, name=name) + except KeyError: + if 'schema' not in param_or_header: + raise + schema = param_or_header / 'schema' + if 'default' not in schema: + raise + casted = schema['default'] + else: + # Simple scenario + if 'content' not in param_or_header: + deserialised = self._deserialise_parameter( + param_or_header, raw_value) + schema = param_or_header / 'schema' + # Complex scenario + else: + content = param_or_header / 'content' + mimetype, media_type = next(content.items()) + deserialised = self._deserialise_data(mimetype, raw_value) + schema = media_type / 'schema' + casted = self._cast(schema, deserialised) + unmarshalled = self._unmarshal(schema, casted) + return unmarshalled diff --git a/tests/integration/data/v3.0/petstore.yaml b/tests/integration/data/v3.0/petstore.yaml index fe10a175..0d65e957 100644 --- a/tests/integration/data/v3.0/petstore.yaml +++ b/tests/integration/data/v3.0/petstore.yaml @@ -41,6 +41,7 @@ paths: style: form description: How many items to return at one time (max 100) required: true + deprecated: true schema: type: integer format: int32 @@ -226,6 +227,13 @@ paths: responses: '200': description: Null response + headers: + x-delete-confirm: + description: Confirmation automation + deprecated: true + schema: + type: boolean + required: true default: $ref: "#/components/responses/ErrorResponse" components: @@ -430,6 +438,10 @@ components: PetsResponse: description: An paged array of pets headers: + content-type: + description: Content type + schema: + type: string x-next: description: A link to the next page of responses schema: diff --git a/tests/integration/schema/test_spec.py b/tests/integration/schema/test_spec.py index 8773eb07..d402e180 100644 --- a/tests/integration/schema/test_spec.py +++ b/tests/integration/schema/test_spec.py @@ -190,13 +190,13 @@ def test_spec(self, spec, spec_dict): assert schema.getkey('required') == schema_spec.get( 'required') + content = parameter.get('content', {}) content_spec = parameter_spec.get('content') - assert bool(content_spec) == bool(parameter.content) + assert bool(content_spec) == bool(content) if not content_spec: continue - content = parameter.get('content', {}) for mimetype, media_type in iteritems(content): media_spec = parameter_spec['content'][mimetype] schema = media_type.get('schema') diff --git a/tests/integration/validation/test_petstore.py b/tests/integration/validation/test_petstore.py index 486b158c..cb589210 100644 --- a/tests/integration/validation/test_petstore.py +++ b/tests/integration/validation/test_petstore.py @@ -12,7 +12,9 @@ EmptyParameterValue, ) from openapi_core.extensions.models.models import BaseModel -from openapi_core.exceptions import MissingRequiredParameter +from openapi_core.exceptions import ( + MissingRequiredHeader, MissingRequiredParameter, +) from openapi_core.shortcuts import ( create_spec, validate_parameters, validate_body, validate_data, ) @@ -69,7 +71,8 @@ def test_get_pets(self, spec, response_validator): path_pattern=path_pattern, args=query_params, ) - parameters = validate_parameters(spec, request) + with pytest.warns(DeprecationWarning): + parameters = validate_parameters(spec, request) body = validate_body(spec, request) assert parameters == RequestParameters( @@ -85,13 +88,20 @@ def test_get_pets(self, spec, response_validator): 'data': [], } data = json.dumps(data_json) - response = MockResponse(data) + headers = { + 'Content-Type': 'application/json', + 'x-next': 'next-url', + } + response = MockResponse(data, headers=headers) response_result = response_validator.validate(request, response) assert response_result.errors == [] assert isinstance(response_result.data, BaseModel) assert response_result.data.data == [] + assert response_result.headers == { + 'x-next': 'next-url', + } def test_get_pets_response(self, spec, response_validator): host_url = 'http://petstore.swagger.io/v1' @@ -371,6 +381,34 @@ def test_get_pets_empty_value(self, spec): assert body is None + def test_get_pets_allow_empty_value(self, spec): + host_url = 'http://petstore.swagger.io/v1' + path_pattern = '/v1/pets' + query_params = { + 'limit': 20, + 'search': '', + } + + request = MockRequest( + host_url, 'GET', '/pets', + path_pattern=path_pattern, args=query_params, + ) + + with pytest.warns(DeprecationWarning): + parameters = validate_parameters(spec, request) + + assert parameters == RequestParameters( + query={ + 'page': 1, + 'limit': 20, + 'search': '', + } + ) + + body = validate_body(spec, request) + + assert body is None + def test_get_pets_none_value(self, spec): host_url = 'http://petstore.swagger.io/v1' path_pattern = '/v1/pets' @@ -1303,3 +1341,28 @@ def test_delete_tags_no_requestbody( assert parameters == RequestParameters() assert body is None + + def test_delete_tags_raises_missing_required_response_header( + self, spec, response_validator): + host_url = 'http://petstore.swagger.io/v1' + path_pattern = '/v1/tags' + request = MockRequest( + host_url, 'DELETE', '/tags', + path_pattern=path_pattern, + ) + + parameters = validate_parameters(spec, request) + body = validate_body(spec, request) + + assert parameters == RequestParameters() + assert body is None + + data = None + response = MockResponse(data, status_code=200) + + with pytest.warns(DeprecationWarning): + response_result = response_validator.validate(request, response) + assert response_result.errors == [ + MissingRequiredHeader(name='x-delete-confirm'), + ] + assert response_result.data is None