openapi: Fix validate_against_openapi_schema nested object validation.

We had a bug in `validate_against_openapi_schema` that prevented it
from correctly inspecting nested arrays.

Fix the bug and address all the exceptions, either via
EXCLUDE_PROPERTIES or fixing them when simple.  Also add a test case
for nested verification.
This commit is contained in:
orientor
2020-06-20 22:55:32 +05:30
committed by Tim Abbott
parent f98d244ed6
commit fbf647283b
4 changed files with 269 additions and 32 deletions

View File

@@ -12,6 +12,18 @@ OPENAPI_SPEC_PATH = os.path.abspath(os.path.join(
# The validator will ignore these keys when they appear in the "content" # The validator will ignore these keys when they appear in the "content"
# passed. # passed.
EXCLUDE_PROPERTIES = { EXCLUDE_PROPERTIES = {
'/attachments': {
'get': {
# messages is a small opaque object; should be easy to document
'200': ['messages'],
}
},
'/events': {
'get': {
# Array with opaque object
'200': ['events']
}
},
'/register': { '/register': {
'post': { 'post': {
'200': ['max_message_id', 'realm_emoji', 'pointer'], '200': ['max_message_id', 'realm_emoji', 'pointer'],
@@ -26,12 +38,22 @@ EXCLUDE_PROPERTIES = {
'realm_name_in_notifications', 'presence_enabled'], 'realm_name_in_notifications', 'presence_enabled'],
}, },
}, },
'/streams': {
'get': {
# Some responses contain undocumented keys
'200': ['is_default'],
}
},
'/zulip-outgoing-webhook': { '/zulip-outgoing-webhook': {
'post': { 'post': {
'200': ['result', 'msg', 'message'], '200': ['result', 'msg', 'message'],
}, },
}, },
'/users': {
'get': {
'200': ['delivery_email'],
}
},
'/users/me': { '/users/me': {
'get': { 'get': {
# Some responses contain undocumented keys # Some responses contain undocumented keys
@@ -55,11 +77,17 @@ EXCLUDE_PROPERTIES = {
} }
}, },
'/messages': { '/messages': {
'get': {
# Some responses contain undocumented keys and
# 'user' is opaque
'200': ['last_edit_timestamp', 'display_recipient',
'match_content', 'match_subject', 'user'],
},
'post': { 'post': {
# Extraneous # Extraneous
'200': ['deliver_at'], '200': ['deliver_at'],
} }
} },
} }
# A list of endpoint-methods such that the endpoint # A list of endpoint-methods such that the endpoint
@@ -261,33 +289,41 @@ def validate_against_openapi_schema(content: Dict[str, Any], endpoint: str,
def validate_array(content: List[Any], schema: Dict[str, Any], exclusion_list: List[str]) -> None: def validate_array(content: List[Any], schema: Dict[str, Any], exclusion_list: List[str]) -> None:
valid_types: List[type] = [] valid_types: List[type] = []
object_schema: Optional[Dict[str, Any]] = None
array_schema: Optional[Dict[str, Any]] = None
if 'oneOf' in schema['items']: if 'oneOf' in schema['items']:
for valid_type in schema['items']['oneOf']: for oneof_schema in schema['items']['oneOf']:
valid_types.append(to_python_type(valid_type['type'])) if oneof_schema['type'] == 'array':
array_schema = oneof_schema
elif oneof_schema['type'] == 'object':
object_schema = oneof_schema
valid_types.append(to_python_type(oneof_schema['type']))
else: else:
valid_types.append(to_python_type(schema['items']['type'])) valid_types.append(to_python_type(schema['items']['type']))
if schema['items']['type'] == 'array':
array_schema = schema['items']
elif schema['items']['type'] == 'object':
object_schema = schema['items']
for item in content: for item in content:
if type(item) not in valid_types: if type(item) not in valid_types:
raise SchemaError('Wrong data type in array') raise SchemaError('Wrong data type in array')
# We can directly check for objects and arrays as # We can directly check for objects and arrays as
# there are no mixed arrays consisting of objects # there are no mixed arrays consisting of objects
# and arrays. # and arrays.
if 'object' in valid_types: if type(item) == dict:
if 'oneOf' not in schema['items']: assert object_schema is not None
if 'properties' not in schema['items']: if 'properties' not in object_schema:
raise SchemaError('Opaque object in array') raise SchemaError('Opaque object in array')
validate_object(item, schema['items'], exclusion_list) validate_object(item, object_schema, exclusion_list)
continue if type(item) == list:
# If the object was not an opaque object then assert(array_schema is not None)
# the continue statement above should have validate_array(item, array_schema, exclusion_list)
# been executed.
if type(item) is dict:
raise SchemaError('Opaque object in array')
if 'items' in schema['items']:
validate_array(item, schema['items'], exclusion_list)
def validate_object(content: Dict[str, Any], schema: Dict[str, Any], exclusion_list: List[str]) -> None: def validate_object(content: Dict[str, Any], schema: Dict[str, Any], exclusion_list: List[str]) -> None:
for key, value in content.items(): for key, value in content.items():
object_schema: Optional[Dict[str, Any]] = None
array_schema: Optional[Dict[str, Any]] = None
if key in exclusion_list: if key in exclusion_list:
continue continue
# Check that the key is defined in the schema # Check that the key is defined in the schema
@@ -299,8 +335,17 @@ def validate_object(content: Dict[str, Any], schema: Dict[str, Any], exclusion_l
if 'oneOf' in schema['properties'][key]: if 'oneOf' in schema['properties'][key]:
for types in schema['properties'][key]['oneOf']: for types in schema['properties'][key]['oneOf']:
expected_type.append(to_python_type(types['type'])) expected_type.append(to_python_type(types['type']))
if types['type'] == 'object':
object_schema = types
elif types['type'] == 'array':
array_schema = types
else: else:
expected_type.append(to_python_type(schema['properties'][key]['type'])) expected_type.append(to_python_type(schema['properties'][key]['type']))
if schema['properties'][key]['type'] == 'object':
object_schema = schema['properties'][key]
elif schema['properties'][key]['type'] == 'array':
array_schema = schema['properties'][key]
actual_type = type(value) actual_type = type(value)
# We have only define nullable property if it is nullable # We have only define nullable property if it is nullable
if value is None and 'nullable' in schema['properties'][key]: if value is None and 'nullable' in schema['properties'][key]:
@@ -308,14 +353,17 @@ def validate_object(content: Dict[str, Any], schema: Dict[str, Any], exclusion_l
if actual_type not in expected_type: if actual_type not in expected_type:
raise SchemaError('Expected type {} for key "{}", but actually ' raise SchemaError('Expected type {} for key "{}", but actually '
'got {}'.format(expected_type, key, actual_type)) 'got {}'.format(expected_type, key, actual_type))
if expected_type is list: if actual_type == list:
validate_array(value, schema['properties'][key], exclusion_list) assert array_schema is not None
if 'properties' in schema['properties'][key]: validate_array(value, array_schema, exclusion_list)
validate_object(value, schema['properties'][key], exclusion_list) if actual_type == dict:
continue assert object_schema is not None
if 'properties' in object_schema:
validate_object(value, object_schema, exclusion_list)
continue
if 'additionalProperties' in schema['properties'][key]: if 'additionalProperties' in schema['properties'][key]:
for child_keys in value: for child_keys in value:
if type(value[child_keys]) is list: if type(value[child_keys]) == list:
validate_array(value[child_keys], validate_array(value[child_keys],
schema['properties'][key]['additionalProperties'], exclusion_list) schema['properties'][key]['additionalProperties'], exclusion_list)
continue continue
@@ -324,7 +372,7 @@ def validate_object(content: Dict[str, Any], schema: Dict[str, Any], exclusion_l
continue continue
# If the object is not opaque then continue statements # If the object is not opaque then continue statements
# will be executed above and this will be skipped # will be executed above and this will be skipped
if expected_type is dict: if actual_type == dict:
raise SchemaError(f'Opaque object "{key}"') raise SchemaError(f'Opaque object "{key}"')
# Check that at least all the required keys are present # Check that at least all the required keys are present
if 'required' in schema: if 'required' in schema:

136
zerver/openapi/testing.yaml Normal file
View File

@@ -0,0 +1,136 @@
test1:
responses:
'200':
content:
application/json:
schema:
properties:
top_array:
type: array
items:
oneOf:
- type: object
properties:
obj:
oneOf:
- type: array
items:
type: string
- type: object
properties:
str3:
type: string
- type: array
items:
type: object
properties:
str1:
type: string
str2:
type: string
example:
{
"top_array": [
{
"obj": {
"str3": "test"
}
},
[
{
"str1": "success",
"str2": "success"
}
]
]
}
test2:
responses:
'200':
content:
application/json:
schema:
properties:
top_array:
type: array
items:
oneOf:
- type: object
properties:
obj:
oneOf:
- type: array
items:
type: string
- type: object
properties:
str3:
type: string
- type: array
items:
type: object
properties:
str1:
type: string
str2:
type: string
example:
{
"top_array": [
{
"obj": {
"str3": "test",
"str4": "extraneous"
}
},
[
{
"str1": "success",
"str2": "success"
}
]
]
}
test3:
responses:
'200':
content:
application/json:
schema:
properties:
top_array:
type: array
items:
oneOf:
- type: object
properties:
obj:
oneOf:
- type: array
items:
type: string
- type: object
- type: array
items:
type: object
properties:
str1:
type: string
str2:
type: string
example:
{
"top_array": [
{
"obj": {
"str3": "test"
}
},
[
{
"str1": "success",
"str2": "success"
}
]
]
}

View File

@@ -386,9 +386,13 @@ paths:
description: | description: |
Size of the file in bytes. Size of the file in bytes.
create_time: create_time:
type: integer # TODO: Make this value always return integers.
oneOf:
- type: integer
- type: number
description: | description: |
Time when the attachment was uploaded. Time when the attachment was uploaded as a UNIX timestamp
multiplied by 1000 (matching the format of getTime() in JavaScript).
messages: messages:
type: array type: array
description: | description: |
@@ -593,8 +597,12 @@ paths:
properties: properties:
avatar_url: avatar_url:
type: string type: string
nullable: true
description: | description: |
The URL of the user's avatar. The URL of the user's avatar. Can be null only if client_gravatar was passed,
which means that the user has not uploaded an avatar in Zulip, and the
client should compute the gravatar URL by hashing the
user's email address itself for this user.
client: client:
type: string type: string
description: | description: |
@@ -643,9 +651,15 @@ paths:
type: object type: object
properties: properties:
emoji_code: emoji_code:
type: integer oneOf:
- type: string
- type: integer
description: | description: |
An encoded version of the emoji's unicode codepoint. A unique identifier, defining the specific emoji codepoint requested,
within the namespace of the `reaction_type`.
For example, for `unicode_emoji`, this will be an encoding of the
unicode codepoint.
emoji_name: emoji_name:
type: string type: string
description: | description: |
@@ -653,8 +667,14 @@ paths:
reaction_type: reaction_type:
type: string type: string
description: | description: |
If the reaction uses a [custom emoji](/help/add-custom-emoji), One of the following values:
`reaction_type` will be set to `realm_emoji`.
* `unicode_emoji`: Unicode emoji (`emoji_code` will be its unicode
codepoint).
* `realm_emoji`: [Custom emoji](/help/add-custom-emoji).
(`emoji_code` will be its ID).
* `zulip_extra_emoji`: Special emoji included with Zulip. Exists to
namespace the `zulip` emoji.
user_id: user_id:
type: integer type: integer
description: | description: |
@@ -2425,6 +2445,8 @@ paths:
Intended to help clients determine whether they need to display Intended to help clients determine whether they need to display
UI like the "more topics" widget that would suggest the stream UI like the "more topics" widget that would suggest the stream
has older history that can be accessed. has older history that can be accessed.
Null is used for streams with no message history.
stream_weekly_traffic: stream_weekly_traffic:
type: integer type: integer
nullable: true nullable: true
@@ -3918,12 +3940,15 @@ paths:
assumption, as we may change that behavior in the future. assumption, as we may change that behavior in the future.
first_message_id: first_message_id:
type: integer type: integer
nullable: true
description: | description: |
The id of the first message in the stream. The id of the first message in the stream.
Intended to help clients determine whether they need to display Intended to help clients determine whether they need to display
UI like the "more topics" widget that would suggest the stream UI like the "more topics" widget that would suggest the stream
has older history that can be accessed. has older history that can be accessed.
Null is used for streams with no message history.
is_announcement_only: is_announcement_only:
type: boolean type: boolean
description: | description: |
@@ -5053,7 +5078,7 @@ components:
name: emoji_code name: emoji_code
in: query in: query
description: | description: |
A unique ID, defining the specific emoji codepoint requested, A unique identifier, defining the specific emoji codepoint requested,
within the namespace of the `reaction_type`. within the namespace of the `reaction_type`.
For most API clients, you won't need this, but it's important For most API clients, you won't need this, but it's important

View File

@@ -1,4 +1,5 @@
import inspect import inspect
import os
import re import re
import sys import sys
from collections import abc from collections import abc
@@ -18,6 +19,7 @@ from typing import (
from unittest import mock from unittest import mock
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import yaml
from django.http import HttpResponse from django.http import HttpResponse
from zerver.lib.request import _REQ, arguments_map from zerver.lib.request import _REQ, arguments_map
@@ -163,6 +165,32 @@ class OpenAPIToolsTest(ZulipTestCase):
TEST_RESPONSE_SUCCESS) TEST_RESPONSE_SUCCESS)
finally: finally:
openapi.EXCLUDE_PROPERTIES = exclude_properties openapi.EXCLUDE_PROPERTIES = exclude_properties
test_dict: Dict[str, Any] = {}
# Check that validate_against_openapi_schema correctly
# descends into 'deep' objects and arrays. Test 1 should
# pass, Test 2 has a 'deep' extraneous key and Test 3 has a
# 'deep' opaque object. Also the parameters are a heterogenous
# mix of arrays and objects to verify that our descent logic
# correctly gets to the the deeply nested objects.
with open(os.path.join(os.path.dirname(OPENAPI_SPEC_PATH),
"testing.yaml")) as test_file:
test_dict = yaml.safe_load(test_file)
openapi_spec.spec()['paths']['testing'] = test_dict
try:
validate_against_openapi_schema((test_dict['test1']['responses']['200']['content']
['application/json']['example']),
'testing', 'test1', '200')
with self.assertRaises(SchemaError, msg = 'Extraneous key "str4" in response\'s content'):
validate_against_openapi_schema((test_dict['test2']['responses']['200']
['content']['application/json']['example']),
'testing', 'test2', '200')
with self.assertRaises(SchemaError, msg = 'Opaque object "obj"'):
validate_against_openapi_schema((test_dict['test3']['responses']['200']
['content']['application/json']['example']),
'testing', 'test3', '200')
finally:
openapi_spec.spec()['paths'].pop('testing', None)
def test_to_python_type(self) -> None: def test_to_python_type(self) -> None:
TYPES = { TYPES = {