Skip to content

Commit fda4615

Browse files
authored
Merge pull request #10 from p1c2u/feature/responses-validation
Responses validation
2 parents 3fe1e6e + 82b0288 commit fda4615

File tree

12 files changed

+477
-29
lines changed

12 files changed

+477
-29
lines changed

README.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,31 @@ or specify request wrapper class for shortcuts
103103
validated_body = validate_body(
104104
spec, request, wrapper_class=FlaskOpenAPIRequest)
105105
106+
You can also validate responses
107+
108+
.. code-block:: python
109+
110+
from openapi_core.validators import ResponseValidator
111+
112+
validator = ResponseValidator(spec)
113+
result = validator.validate(request, response)
114+
115+
# raise errors if response invalid
116+
result.raise_for_errors()
117+
118+
# get list of errors
119+
errors = result.errors
120+
121+
and unmarshal response data from validation result
122+
123+
.. code-block:: python
124+
125+
# get headers
126+
validated_headers = result.headers
127+
128+
# get data
129+
validated_data = result.data
130+
106131
Related projects
107132
================
108133
* `openapi-spec-validator <https://github.com/p1c2u/openapi-spec-validator>`__

openapi_core/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,9 @@ class InvalidContentType(OpenAPIBodyError):
6969
pass
7070

7171

72+
class InvalidResponse(OpenAPIMappingError):
73+
pass
74+
75+
7276
class InvalidValue(OpenAPIMappingError):
7377
pass

openapi_core/operations.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55

66
from six import iteritems
77

8+
from openapi_core.exceptions import InvalidResponse
89
from openapi_core.parameters import ParametersGenerator
910
from openapi_core.request_bodies import RequestBodyFactory
11+
from openapi_core.responses import ResponsesGenerator
1012

1113
log = logging.getLogger(__name__)
1214

@@ -15,10 +17,11 @@ class Operation(object):
1517
"""Represents an OpenAPI Operation."""
1618

1719
def __init__(
18-
self, http_method, path_name, parameters, request_body=None,
19-
deprecated=False, operation_id=None):
20+
self, http_method, path_name, responses, parameters,
21+
request_body=None, deprecated=False, operation_id=None):
2022
self.http_method = http_method
2123
self.path_name = path_name
24+
self.responses = dict(responses)
2225
self.parameters = dict(parameters)
2326
self.request_body = request_body
2427
self.deprecated = deprecated
@@ -27,6 +30,21 @@ def __init__(
2730
def __getitem__(self, name):
2831
return self.parameters[name]
2932

33+
def get_response(self, http_status='default'):
34+
try:
35+
return self.responses[http_status]
36+
except KeyError:
37+
# try range
38+
http_status_range = '{0}XX'.format(http_status[0])
39+
if http_status_range in self.responses:
40+
return self.responses[http_status_range]
41+
42+
if 'default' not in self.responses:
43+
raise InvalidResponse(
44+
"Unknown response http status {0}".format(http_status))
45+
46+
return self.responses['default']
47+
3048

3149
class OperationsGenerator(object):
3250
"""Represents an OpenAPI Operation in a service."""
@@ -42,9 +60,12 @@ def generate(self, path_name, path):
4260
continue
4361

4462
operation_deref = self.dereferencer.dereference(operation)
63+
responses_spec = operation_deref['responses']
64+
responses = self.responses_generator.generate(responses_spec)
4565
deprecated = operation_deref.get('deprecated', False)
4666
parameters_list = operation_deref.get('parameters', [])
47-
parameters = self.parameters_generator.generate(parameters_list)
67+
parameters = self.parameters_generator.generate_from_list(
68+
parameters_list)
4869

4970
request_body = None
5071
if 'requestBody' in operation_deref:
@@ -55,11 +76,16 @@ def generate(self, path_name, path):
5576
yield (
5677
http_method,
5778
Operation(
58-
http_method, path_name, list(parameters),
79+
http_method, path_name, responses, list(parameters),
5980
request_body=request_body, deprecated=deprecated,
6081
),
6182
)
6283

84+
@property
85+
@lru_cache()
86+
def responses_generator(self):
87+
return ResponsesGenerator(self.dereferencer, self.schemas_registry)
88+
6389
@property
6490
@lru_cache()
6591
def parameters_generator(self):

openapi_core/parameters.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import logging
33
import warnings
44

5+
from six import iteritems
6+
57
from openapi_core.exceptions import (
68
EmptyValue, InvalidValueType, InvalidParameterValue,
79
)
@@ -54,10 +56,36 @@ def __init__(self, dereferencer, schemas_registry):
5456
self.dereferencer = dereferencer
5557
self.schemas_registry = schemas_registry
5658

57-
def generate(self, paramters):
58-
for parameter in paramters:
59+
def generate(self, parameters):
60+
for parameter_name, parameter in iteritems(parameters):
61+
parameter_deref = self.dereferencer.dereference(parameter)
62+
63+
parameter_in = parameter_deref.get('in', 'header')
64+
65+
allow_empty_value = parameter_deref.get('allowEmptyValue')
66+
required = parameter_deref.get('required', False)
67+
68+
schema_spec = parameter_deref.get('schema', None)
69+
schema = None
70+
if schema_spec:
71+
schema, _ = self.schemas_registry.get_or_create(schema_spec)
72+
73+
yield (
74+
parameter_name,
75+
Parameter(
76+
parameter_name, parameter_in,
77+
schema=schema, required=required,
78+
allow_empty_value=allow_empty_value,
79+
),
80+
)
81+
82+
def generate_from_list(self, parameters_list):
83+
for parameter in parameters_list:
5984
parameter_deref = self.dereferencer.dereference(parameter)
6085

86+
parameter_name = parameter_deref['name']
87+
parameter_in = parameter_deref.get('in', 'header')
88+
6189
allow_empty_value = parameter_deref.get('allowEmptyValue')
6290
required = parameter_deref.get('required', False)
6391

@@ -67,9 +95,9 @@ def generate(self, paramters):
6795
schema, _ = self.schemas_registry.get_or_create(schema_spec)
6896

6997
yield (
70-
parameter_deref['name'],
98+
parameter_name,
7199
Parameter(
72-
parameter_deref['name'], parameter_deref['in'],
100+
parameter_name, parameter_in,
73101
schema=schema, required=required,
74102
allow_empty_value=allow_empty_value,
75103
),

openapi_core/responses.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""OpenAPI core responses module"""
2+
from functools import lru_cache
3+
4+
from six import iteritems
5+
6+
from openapi_core.exceptions import InvalidContentType
7+
from openapi_core.media_types import MediaTypeGenerator
8+
from openapi_core.parameters import ParametersGenerator
9+
10+
11+
class Response(object):
12+
13+
def __init__(
14+
self, http_status, description, headers=None, content=None,
15+
links=None):
16+
self.http_status = http_status
17+
self.description = description
18+
self.headers = headers and dict(headers) or {}
19+
self.content = content and dict(content) or {}
20+
self.links = links and dict(links) or {}
21+
22+
def __getitem__(self, mimetype):
23+
try:
24+
return self.content[mimetype]
25+
except KeyError:
26+
raise InvalidContentType(
27+
"Invalid mime type `{0}`".format(mimetype))
28+
29+
30+
class ResponsesGenerator(object):
31+
32+
def __init__(self, dereferencer, schemas_registry):
33+
self.dereferencer = dereferencer
34+
self.schemas_registry = schemas_registry
35+
36+
def generate(self, responses):
37+
for http_status, response in iteritems(responses):
38+
description = response['description']
39+
headers = response.get('headers')
40+
content = response.get('content')
41+
42+
media_types = None
43+
if content:
44+
media_types = self.media_types_generator.generate(content)
45+
46+
parameters = None
47+
if headers:
48+
parameters = self.parameters_generator.generate(headers)
49+
50+
yield http_status, Response(
51+
http_status, description,
52+
content=media_types, headers=parameters)
53+
54+
@property
55+
@lru_cache()
56+
def media_types_generator(self):
57+
return MediaTypeGenerator(self.dereferencer, self.schemas_registry)
58+
59+
@property
60+
@lru_cache()
61+
def parameters_generator(self):
62+
return ParametersGenerator(self.dereferencer, self.schemas_registry)

openapi_core/validators.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from six import iteritems
33

44
from openapi_core.exceptions import (
5-
OpenAPIMappingError, MissingParameter, MissingBody,
5+
OpenAPIMappingError, MissingParameter, MissingBody, InvalidResponse,
66
)
77

88

@@ -43,6 +43,14 @@ def __init__(self, errors, body=None, parameters=None):
4343
self.parameters = parameters or RequestParameters()
4444

4545

46+
class ResponseValidationResult(BaseValidationResult):
47+
48+
def __init__(self, errors, data=None, headers=None):
49+
super(ResponseValidationResult, self).__init__(errors)
50+
self.data = data
51+
self.headers = headers
52+
53+
4654
class RequestValidator(object):
4755

4856
def __init__(self, spec):
@@ -120,3 +128,63 @@ def _get_raw_body(self, request):
120128
raise MissingBody("Missing required request body")
121129

122130
return request.body
131+
132+
133+
class ResponseValidator(object):
134+
135+
def __init__(self, spec):
136+
self.spec = spec
137+
138+
def validate(self, request, response):
139+
errors = []
140+
data = None
141+
headers = {}
142+
143+
try:
144+
server = self.spec.get_server(request.full_url_pattern)
145+
# don't process if server errors
146+
except OpenAPIMappingError as exc:
147+
errors.append(exc)
148+
return ResponseValidationResult(errors, data, headers)
149+
150+
operation_pattern = request.full_url_pattern.replace(
151+
server.default_url, '')
152+
153+
try:
154+
operation = self.spec.get_operation(
155+
operation_pattern, request.method)
156+
# don't process if operation errors
157+
except OpenAPIMappingError as exc:
158+
errors.append(exc)
159+
return ResponseValidationResult(errors, data, headers)
160+
161+
try:
162+
operation_response = operation.get_response(str(response.status))
163+
# don't process if invalid response status code
164+
except InvalidResponse as exc:
165+
errors.append(exc)
166+
return ResponseValidationResult(errors, data, headers)
167+
168+
if operation_response.content:
169+
try:
170+
media_type = operation_response[response.mimetype]
171+
except OpenAPIMappingError as exc:
172+
errors.append(exc)
173+
else:
174+
try:
175+
raw_data = self._get_raw_data(response)
176+
except MissingBody as exc:
177+
errors.append(exc)
178+
else:
179+
try:
180+
data = media_type.unmarshal(raw_data)
181+
except OpenAPIMappingError as exc:
182+
errors.append(exc)
183+
184+
return ResponseValidationResult(errors, data, headers)
185+
186+
def _get_raw_data(self, response):
187+
if not response.data:
188+
raise MissingBody("Missing required response data")
189+
190+
return response.data

openapi_core/wrappers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,38 @@ def body(self):
104104
@property
105105
def mimetype(self):
106106
return self.request.mimetype
107+
108+
109+
class BaseOpenAPIResponse(object):
110+
111+
body = NotImplemented
112+
status = NotImplemented
113+
114+
mimetype = NotImplemented
115+
116+
117+
class MockResponse(BaseOpenAPIRequest):
118+
119+
def __init__(self, data, status=200, mimetype='application/json'):
120+
self.data = data
121+
122+
self.status = status
123+
self.mimetype = mimetype
124+
125+
126+
class FlaskOpenAPIResponse(BaseOpenAPIResponse):
127+
128+
def __init__(self, response):
129+
self.response = response
130+
131+
@property
132+
def data(self):
133+
return self.response.data
134+
135+
@property
136+
def status(self):
137+
return self.response.status
138+
139+
@property
140+
def mimetype(self):
141+
return self.response.mimetype

tests/integration/data/v3.0/petstore.yaml

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,6 @@ paths:
6161
application/json:
6262
schema:
6363
$ref: "#/components/schemas/Pets"
64-
default:
65-
description: unexpected error
66-
content:
67-
application/json:
68-
schema:
69-
$ref: "#/components/schemas/Error"
7064
post:
7165
summary: Create a pet
7266
operationId: createPets
@@ -164,9 +158,12 @@ components:
164158
position:
165159
$ref: "#/components/schemas/Position"
166160
Pets:
167-
type: array
168-
items:
169-
$ref: "#/components/schemas/Pet"
161+
type: object
162+
properties:
163+
data:
164+
type: array
165+
items:
166+
$ref: "#/components/schemas/Pet"
170167
Error:
171168
type: object
172169
required:

0 commit comments

Comments
 (0)