Merge pull request #10 from p1c2u/feature/responses-validation

Responses validation
This commit is contained in:
A 2017-11-06 14:22:50 +00:00 committed by GitHub
commit fda4615057
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 477 additions and 29 deletions

View file

@ -103,6 +103,31 @@ or specify request wrapper class for shortcuts
validated_body = validate_body(
spec, request, wrapper_class=FlaskOpenAPIRequest)
You can also validate responses
.. code-block:: python
from openapi_core.validators import ResponseValidator
validator = ResponseValidator(spec)
result = validator.validate(request, response)
# raise errors if response invalid
result.raise_for_errors()
# get list of errors
errors = result.errors
and unmarshal response data from validation result
.. code-block:: python
# get headers
validated_headers = result.headers
# get data
validated_data = result.data
Related projects
================
* `openapi-spec-validator <https://github.com/p1c2u/openapi-spec-validator>`__

View file

@ -69,5 +69,9 @@ class InvalidContentType(OpenAPIBodyError):
pass
class InvalidResponse(OpenAPIMappingError):
pass
class InvalidValue(OpenAPIMappingError):
pass

View file

@ -5,8 +5,10 @@ from functools import lru_cache
from six import iteritems
from openapi_core.exceptions import InvalidResponse
from openapi_core.parameters import ParametersGenerator
from openapi_core.request_bodies import RequestBodyFactory
from openapi_core.responses import ResponsesGenerator
log = logging.getLogger(__name__)
@ -15,10 +17,11 @@ class Operation(object):
"""Represents an OpenAPI Operation."""
def __init__(
self, http_method, path_name, parameters, request_body=None,
deprecated=False, operation_id=None):
self, http_method, path_name, responses, parameters,
request_body=None, deprecated=False, operation_id=None):
self.http_method = http_method
self.path_name = path_name
self.responses = dict(responses)
self.parameters = dict(parameters)
self.request_body = request_body
self.deprecated = deprecated
@ -27,6 +30,21 @@ class Operation(object):
def __getitem__(self, name):
return self.parameters[name]
def get_response(self, http_status='default'):
try:
return self.responses[http_status]
except KeyError:
# try range
http_status_range = '{0}XX'.format(http_status[0])
if http_status_range in self.responses:
return self.responses[http_status_range]
if 'default' not in self.responses:
raise InvalidResponse(
"Unknown response http status {0}".format(http_status))
return self.responses['default']
class OperationsGenerator(object):
"""Represents an OpenAPI Operation in a service."""
@ -42,9 +60,12 @@ class OperationsGenerator(object):
continue
operation_deref = self.dereferencer.dereference(operation)
responses_spec = operation_deref['responses']
responses = self.responses_generator.generate(responses_spec)
deprecated = operation_deref.get('deprecated', False)
parameters_list = operation_deref.get('parameters', [])
parameters = self.parameters_generator.generate(parameters_list)
parameters = self.parameters_generator.generate_from_list(
parameters_list)
request_body = None
if 'requestBody' in operation_deref:
@ -55,11 +76,16 @@ class OperationsGenerator(object):
yield (
http_method,
Operation(
http_method, path_name, list(parameters),
http_method, path_name, responses, list(parameters),
request_body=request_body, deprecated=deprecated,
),
)
@property
@lru_cache()
def responses_generator(self):
return ResponsesGenerator(self.dereferencer, self.schemas_registry)
@property
@lru_cache()
def parameters_generator(self):

View file

@ -2,6 +2,8 @@
import logging
import warnings
from six import iteritems
from openapi_core.exceptions import (
EmptyValue, InvalidValueType, InvalidParameterValue,
)
@ -54,10 +56,12 @@ class ParametersGenerator(object):
self.dereferencer = dereferencer
self.schemas_registry = schemas_registry
def generate(self, paramters):
for parameter in paramters:
def generate(self, parameters):
for parameter_name, parameter in iteritems(parameters):
parameter_deref = self.dereferencer.dereference(parameter)
parameter_in = parameter_deref.get('in', 'header')
allow_empty_value = parameter_deref.get('allowEmptyValue')
required = parameter_deref.get('required', False)
@ -67,9 +71,33 @@ class ParametersGenerator(object):
schema, _ = self.schemas_registry.get_or_create(schema_spec)
yield (
parameter_deref['name'],
parameter_name,
Parameter(
parameter_deref['name'], parameter_deref['in'],
parameter_name, parameter_in,
schema=schema, required=required,
allow_empty_value=allow_empty_value,
),
)
def generate_from_list(self, parameters_list):
for parameter in parameters_list:
parameter_deref = self.dereferencer.dereference(parameter)
parameter_name = parameter_deref['name']
parameter_in = parameter_deref.get('in', 'header')
allow_empty_value = parameter_deref.get('allowEmptyValue')
required = parameter_deref.get('required', False)
schema_spec = parameter_deref.get('schema', None)
schema = None
if schema_spec:
schema, _ = self.schemas_registry.get_or_create(schema_spec)
yield (
parameter_name,
Parameter(
parameter_name, parameter_in,
schema=schema, required=required,
allow_empty_value=allow_empty_value,
),

62
openapi_core/responses.py Normal file
View file

@ -0,0 +1,62 @@
"""OpenAPI core responses module"""
from functools import lru_cache
from six import iteritems
from openapi_core.exceptions import InvalidContentType
from openapi_core.media_types import MediaTypeGenerator
from openapi_core.parameters import ParametersGenerator
class Response(object):
def __init__(
self, http_status, description, headers=None, content=None,
links=None):
self.http_status = http_status
self.description = description
self.headers = headers and dict(headers) or {}
self.content = content and dict(content) or {}
self.links = links and dict(links) or {}
def __getitem__(self, mimetype):
try:
return self.content[mimetype]
except KeyError:
raise InvalidContentType(
"Invalid mime type `{0}`".format(mimetype))
class ResponsesGenerator(object):
def __init__(self, dereferencer, schemas_registry):
self.dereferencer = dereferencer
self.schemas_registry = schemas_registry
def generate(self, responses):
for http_status, response in iteritems(responses):
description = response['description']
headers = response.get('headers')
content = response.get('content')
media_types = None
if content:
media_types = self.media_types_generator.generate(content)
parameters = None
if headers:
parameters = self.parameters_generator.generate(headers)
yield http_status, Response(
http_status, description,
content=media_types, headers=parameters)
@property
@lru_cache()
def media_types_generator(self):
return MediaTypeGenerator(self.dereferencer, self.schemas_registry)
@property
@lru_cache()
def parameters_generator(self):
return ParametersGenerator(self.dereferencer, self.schemas_registry)

View file

@ -2,7 +2,7 @@
from six import iteritems
from openapi_core.exceptions import (
OpenAPIMappingError, MissingParameter, MissingBody,
OpenAPIMappingError, MissingParameter, MissingBody, InvalidResponse,
)
@ -43,6 +43,14 @@ class RequestValidationResult(BaseValidationResult):
self.parameters = parameters or RequestParameters()
class ResponseValidationResult(BaseValidationResult):
def __init__(self, errors, data=None, headers=None):
super(ResponseValidationResult, self).__init__(errors)
self.data = data
self.headers = headers
class RequestValidator(object):
def __init__(self, spec):
@ -120,3 +128,63 @@ class RequestValidator(object):
raise MissingBody("Missing required request body")
return request.body
class ResponseValidator(object):
def __init__(self, spec):
self.spec = spec
def validate(self, request, response):
errors = []
data = None
headers = {}
try:
server = self.spec.get_server(request.full_url_pattern)
# don't process if server errors
except OpenAPIMappingError as exc:
errors.append(exc)
return ResponseValidationResult(errors, data, headers)
operation_pattern = request.full_url_pattern.replace(
server.default_url, '')
try:
operation = self.spec.get_operation(
operation_pattern, request.method)
# don't process if operation errors
except OpenAPIMappingError as exc:
errors.append(exc)
return ResponseValidationResult(errors, data, headers)
try:
operation_response = operation.get_response(str(response.status))
# don't process if invalid response status code
except InvalidResponse as exc:
errors.append(exc)
return ResponseValidationResult(errors, data, headers)
if operation_response.content:
try:
media_type = operation_response[response.mimetype]
except OpenAPIMappingError as exc:
errors.append(exc)
else:
try:
raw_data = self._get_raw_data(response)
except MissingBody as exc:
errors.append(exc)
else:
try:
data = media_type.unmarshal(raw_data)
except OpenAPIMappingError as exc:
errors.append(exc)
return ResponseValidationResult(errors, data, headers)
def _get_raw_data(self, response):
if not response.data:
raise MissingBody("Missing required response data")
return response.data

View file

@ -104,3 +104,38 @@ class FlaskOpenAPIRequest(BaseOpenAPIRequest):
@property
def mimetype(self):
return self.request.mimetype
class BaseOpenAPIResponse(object):
body = NotImplemented
status = NotImplemented
mimetype = NotImplemented
class MockResponse(BaseOpenAPIRequest):
def __init__(self, data, status=200, mimetype='application/json'):
self.data = data
self.status = status
self.mimetype = mimetype
class FlaskOpenAPIResponse(BaseOpenAPIResponse):
def __init__(self, response):
self.response = response
@property
def data(self):
return self.response.data
@property
def status(self):
return self.response.status
@property
def mimetype(self):
return self.response.mimetype

View file

@ -61,12 +61,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Pets"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
post:
summary: Create a pet
operationId: createPets
@ -164,9 +158,12 @@ components:
position:
$ref: "#/components/schemas/Position"
Pets:
type: array
items:
$ref: "#/components/schemas/Pet"
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Pet"
Error:
type: object
required:

View file

@ -9,8 +9,10 @@ from openapi_core.exceptions import (
)
from openapi_core.media_types import MediaType
from openapi_core.operations import Operation
from openapi_core.parameters import Parameter
from openapi_core.paths import Path
from openapi_core.request_bodies import RequestBody
from openapi_core.responses import Response
from openapi_core.schemas import Schema
from openapi_core.servers import Server, ServerVariable
from openapi_core.shortcuts import create_spec
@ -59,6 +61,60 @@ class TestPetstore(object):
assert operation.http_method == http_method
operation_spec = spec_dict['paths'][path_name][http_method]
responses_spec = operation_spec.get('responses')
for http_status, response in iteritems(operation.responses):
assert type(response) == Response
assert response.http_status == http_status
response_spec = responses_spec[http_status]
description_spec = response_spec['description']
assert response.description == description_spec
for mimetype, media_type in iteritems(response.content):
assert type(media_type) == MediaType
assert media_type.mimetype == mimetype
content_spec = response_spec['content'][mimetype]
schema_spec = content_spec.get('schema')
assert bool(schema_spec) == bool(media_type.schema)
if not schema_spec:
continue
# @todo: test with defererence
if '$ref' in schema_spec:
continue
assert type(media_type.schema) == Schema
assert media_type.schema.type == schema_spec['type']
assert media_type.schema.required == schema_spec.get(
'required', False)
for parameter_name, parameter in iteritems(
response.headers):
assert type(parameter) == Parameter
assert parameter.name == parameter_name
headers_spec = response_spec['headers']
parameter_spec = headers_spec[parameter_name]
schema_spec = parameter_spec.get('schema')
assert bool(schema_spec) == bool(parameter.schema)
if not schema_spec:
continue
# @todo: test with defererence
if '$ref' in schema_spec:
continue
assert type(parameter.schema) == Schema
assert parameter.schema.type == schema_spec['type']
assert parameter.schema.required == schema_spec.get(
'required', False)
request_body_spec = operation_spec.get('requestBody')
assert bool(request_body_spec) == bool(operation.request_body)

View file

@ -3,11 +3,11 @@ import pytest
from openapi_core.exceptions import (
InvalidServer, InvalidOperation, MissingParameter,
MissingBody, InvalidContentType,
MissingBody, InvalidContentType, InvalidResponse, InvalidMediaTypeValue,
)
from openapi_core.shortcuts import create_spec
from openapi_core.validators import RequestValidator
from openapi_core.wrappers import MockRequest
from openapi_core.validators import RequestValidator, ResponseValidator
from openapi_core.wrappers import MockRequest, MockResponse
class TestRequestValidator(object):
@ -155,3 +155,104 @@ class TestRequestValidator(object):
'petId': 1,
},
}
class TestResponseValidator(object):
host_url = 'http://petstore.swagger.io'
@pytest.fixture
def spec_dict(self, factory):
return factory.spec_from_file("data/v3.0/petstore.yaml")
@pytest.fixture
def spec(self, spec_dict):
return create_spec(spec_dict)
@pytest.fixture
def validator(self, spec):
return ResponseValidator(spec)
def test_invalid_server(self, validator):
request = MockRequest('http://petstore.invalid.net/v1', 'get', '/')
response = MockResponse('Not Found', status=404)
result = validator.validate(request, response)
assert len(result.errors) == 1
assert type(result.errors[0]) == InvalidServer
assert result.data is None
assert result.headers == {}
def test_invalid_operation(self, validator):
request = MockRequest(self.host_url, 'get', '/v1')
response = MockResponse('Not Found', status=404)
result = validator.validate(request, response)
assert len(result.errors) == 1
assert type(result.errors[0]) == InvalidOperation
assert result.data is None
assert result.headers == {}
def test_invalid_response(self, validator):
request = MockRequest(self.host_url, 'get', '/v1/pets')
response = MockResponse('Not Found', status=409)
result = validator.validate(request, response)
assert len(result.errors) == 1
assert type(result.errors[0]) == InvalidResponse
assert result.data is None
assert result.headers == {}
def test_invalid_content_type(self, validator):
request = MockRequest(self.host_url, 'get', '/v1/pets')
response = MockResponse('Not Found', mimetype='text/csv')
result = validator.validate(request, response)
assert len(result.errors) == 1
assert type(result.errors[0]) == InvalidContentType
assert result.data is None
assert result.headers == {}
def test_missing_body(self, validator):
request = MockRequest(self.host_url, 'get', '/v1/pets')
response = MockResponse(None)
result = validator.validate(request, response)
assert len(result.errors) == 1
assert type(result.errors[0]) == MissingBody
assert result.data is None
assert result.headers == {}
def test_invalid_media_type_value(self, validator):
request = MockRequest(self.host_url, 'get', '/v1/pets')
response = MockResponse('\{\}')
result = validator.validate(request, response)
assert len(result.errors) == 1
assert type(result.errors[0]) == InvalidMediaTypeValue
assert result.data is None
assert result.headers == {}
def test_get_pets(self, validator):
request = MockRequest(self.host_url, 'get', '/v1/pets')
response_json = {
'data': [
{
'id': 1,
},
],
}
response_data = json.dumps(response_json)
response = MockResponse(response_data)
result = validator.validate(request, response)
assert result.errors == []
assert result.data == response_json
assert result.headers == {}

View file

@ -1,11 +1,11 @@
import pytest
from flask.wrappers import Request
from flask.wrappers import Request, Response
from werkzeug.datastructures import EnvironHeaders, ImmutableMultiDict
from werkzeug.routing import Map, Rule, Subdomain
from werkzeug.test import create_environ
from openapi_core.wrappers import FlaskOpenAPIRequest
from openapi_core.wrappers import FlaskOpenAPIRequest, FlaskOpenAPIResponse
class TestFlaskOpenAPIRequest(object):
@ -90,3 +90,22 @@ class TestFlaskOpenAPIRequest(object):
assert openapi_request.path_pattern == request.url_rule.rule
assert openapi_request.body == request.data
assert openapi_request.mimetype == request.mimetype
class TestFlaskOpenAPIResponse(object):
@pytest.fixture
def response_factory(self):
def create_response(body, status=200):
return Response('Not Found', status=404)
return create_response
def test_invalid_server(self, response_factory):
response = response_factory('Not Found', status=404)
openapi_response = FlaskOpenAPIResponse(response)
assert openapi_response.response == response
assert openapi_response.data == response.data
assert openapi_response.status == response.status
assert openapi_response.mimetype == response.mimetype

View file

@ -7,14 +7,41 @@ from openapi_core.operations import Operation
class TestSchemas(object):
@pytest.fixture
def oepration(self):
def operation(self):
parameters = {
'parameter_1': mock.sentinel.parameter_1,
'parameter_2': mock.sentinel.parameter_2,
}
return Operation('get', '/path', parameters=parameters)
return Operation('get', '/path', {}, parameters=parameters)
@property
def test_iteritems(self, oepration):
for name in oepration.parameters.keys():
assert oepration[name] == oepration.parameters[name]
def test_iteritems(self, operation):
for name in operation.parameters.keys():
assert operation[name] == operation.parameters[name]
class TestResponses(object):
@pytest.fixture
def operation(self):
responses = {
'200': mock.sentinel.response_200,
'299': mock.sentinel.response_299,
'2XX': mock.sentinel.response_2XX,
'default': mock.sentinel.response_default,
}
return Operation('get', '/path', responses, parameters={})
def test_default(self, operation):
response = operation.get_response()
assert response == operation.responses['default']
def test_range(self, operation):
response = operation.get_response('201')
assert response == operation.responses['2XX']
def test_exact(self, operation):
response = operation.get_response('200')
assert response == operation.responses['200']