mirror of
https://github.com/zulip/zulip.git
synced 2025-11-17 20:32:18 +00:00
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:
@@ -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
136
zerver/openapi/testing.yaml
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user