openapi: Use reasonable variable names.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
Anders Kaseorg 2020-08-11 19:54:48 -07:00 committed by Tim Abbott
parent 1d1149903b
commit ff46de305a
3 changed files with 144 additions and 142 deletions

View File

@ -229,8 +229,8 @@ def generate_curl_example(endpoint: str, method: str,
lines = ["```curl"] lines = ["```curl"]
operation = endpoint + ":" + method.lower() operation = endpoint + ":" + method.lower()
operation_entry = openapi_spec.spec()['paths'][endpoint][method.lower()] operation_entry = openapi_spec.openapi()['paths'][endpoint][method.lower()]
global_security = openapi_spec.spec()['security'] global_security = openapi_spec.openapi()['security']
operation_params = operation_entry.get("parameters", []) operation_params = operation_entry.get("parameters", [])
operation_request_body = operation_entry.get("requestBody", None) operation_request_body = operation_entry.get("requestBody", None)

View File

@ -15,17 +15,23 @@ OPENAPI_SPEC_PATH = os.path.abspath(os.path.join(
# A list of endpoint-methods such that the endpoint # A list of endpoint-methods such that the endpoint
# has documentation but not with this particular method. # has documentation but not with this particular method.
EXCLUDE_UNDOCUMENTED_ENDPOINTS = {"/realm/emoji/{emoji_name}:delete", "/users:patch"} EXCLUDE_UNDOCUMENTED_ENDPOINTS = {
("/realm/emoji/{emoji_name}", "delete"),
("/users", "patch"),
}
# Consists of endpoints with some documentation remaining. # Consists of endpoints with some documentation remaining.
# These are skipped but return true as the validator cannot exclude objects # These are skipped but return true as the validator cannot exclude objects
EXCLUDE_DOCUMENTED_ENDPOINTS = {"/settings/notifications:patch"} EXCLUDE_DOCUMENTED_ENDPOINTS = {
("/settings/notifications", "patch"),
}
class OpenAPISpec(): class OpenAPISpec():
def __init__(self, path: str) -> None: def __init__(self, openapi_path: str) -> None:
self.path = path self.openapi_path = openapi_path
self.last_update: Optional[float] = None self.mtime: Optional[float] = None
self.data: Dict[str, Any] = {} self._openapi: Dict[str, Any] = {}
self.regex_dict: Dict[str, str] = {} self._endpoints_dict: Dict[str, str] = {}
self.core_data: Any = None self._request_validator: Optional[RequestValidator] = None
def check_reload(self) -> None: def check_reload(self) -> None:
# Because importing yamole (and in turn, yaml) takes # Because importing yamole (and in turn, yaml) takes
@ -40,21 +46,21 @@ class OpenAPISpec():
# corruption. # corruption.
from yamole import YamoleParser from yamole import YamoleParser
last_modified = os.path.getmtime(self.path) mtime = os.path.getmtime(self.openapi_path)
# Using == rather than >= to cover the corner case of users placing an # Using == rather than >= to cover the corner case of users placing an
# earlier version than the current one # earlier version than the current one
if self.last_update == last_modified: if self.mtime == mtime:
return return
with open(self.path) as f: with open(self.openapi_path) as f:
yaml_parser = YamoleParser(f) yamole_parser = YamoleParser(f)
self.data = yaml_parser.data self._openapi = yamole_parser.data
validator_spec = create_spec(self.data) spec = create_spec(self._openapi)
self.core_data = RequestValidator(validator_spec) self._request_validator = RequestValidator(spec)
self.create_regex_dict() self.create_endpoints_dict()
self.last_update = last_modified self.mtime = mtime
def create_regex_dict(self) -> None: def create_endpoints_dict(self) -> None:
# Algorithm description: # Algorithm description:
# We have 2 types of endpoints # We have 2 types of endpoints
# 1.with path arguments 2. without path arguments # 1.with path arguments 2. without path arguments
@ -74,115 +80,112 @@ class OpenAPISpec():
# such as email they must be substituted with proper regex. # such as email they must be substituted with proper regex.
email_regex = r'([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})' email_regex = r'([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})'
self.regex_dict = {} self._endpoints_dict = {}
for key in self.data['paths']: for endpoint in self._openapi['paths']:
if '{' not in key: if '{' not in endpoint:
continue continue
regex_key = '^' + key + '$' path_regex = '^' + endpoint + '$'
# Numeric arguments have id at their end # Numeric arguments have id at their end
# so find such arguments and replace them with numeric # so find such arguments and replace them with numeric
# regex # regex
regex_key = re.sub(r'{[^}]*id}', r'[0-9]*', regex_key) path_regex = re.sub(r'{[^}]*id}', r'[0-9]*', path_regex)
# Email arguments end with email # Email arguments end with email
regex_key = re.sub(r'{[^}]*email}', email_regex, regex_key) path_regex = re.sub(r'{[^}]*email}', email_regex, path_regex)
# All other types of arguments are supposed to be # All other types of arguments are supposed to be
# all-encompassing string. # all-encompassing string.
regex_key = re.sub(r'{[^}]*}', r'[^\/]*', regex_key) path_regex = re.sub(r'{[^}]*}', r'[^\/]*', path_regex)
regex_key = regex_key.replace(r'/', r'\/') path_regex = path_regex.replace(r'/', r'\/')
regex_key = fr'{regex_key}' self._endpoints_dict[path_regex] = endpoint
self.regex_dict[regex_key] = key
def spec(self) -> Dict[str, Any]: def openapi(self) -> Dict[str, Any]:
"""Reload the OpenAPI file if it has been modified after the last time """Reload the OpenAPI file if it has been modified after the last time
it was read, and then return the parsed data. it was read, and then return the parsed data.
""" """
self.check_reload() self.check_reload()
assert(len(self.data) > 0) assert(len(self._openapi) > 0)
return self.data return self._openapi
def regex_keys(self) -> Dict[str, str]: def endpoints_dict(self) -> Dict[str, str]:
"""Reload the OpenAPI file if it has been modified after the last time """Reload the OpenAPI file if it has been modified after the last time
it was read, and then return the parsed data. it was read, and then return the parsed data.
""" """
self.check_reload() self.check_reload()
assert(len(self.regex_dict) > 0) assert(len(self._endpoints_dict) > 0)
return self.regex_dict return self._endpoints_dict
def core_validator(self) -> Any: def request_validator(self) -> RequestValidator:
"""Reload the OpenAPI file if it has been modified after the last time """Reload the OpenAPI file if it has been modified after the last time
it was read, and then return the openapi_core validator object. Similar it was read, and then return the openapi_core validator object. Similar
to preceding functions. Used for proper access to OpenAPI objects. to preceding functions. Used for proper access to OpenAPI objects.
""" """
self.check_reload() self.check_reload()
return self.core_data assert self._request_validator is not None
return self._request_validator
class SchemaError(Exception): class SchemaError(Exception):
pass pass
openapi_spec = OpenAPISpec(OPENAPI_SPEC_PATH) openapi_spec = OpenAPISpec(OPENAPI_SPEC_PATH)
def get_schema(endpoint: str, method: str, response: str) -> Dict[str, Any]: def get_schema(endpoint: str, method: str, status_code: str) -> Dict[str, Any]:
if len(response) == 3 and ('oneOf' in (openapi_spec.spec())['paths'][endpoint] if len(status_code) == 3 and ('oneOf' in openapi_spec.openapi()['paths'][endpoint]
[method.lower()]['responses'][response]['content'] [method.lower()]['responses'][status_code]['content']
['application/json']['schema']): ['application/json']['schema']):
# Currently at places where multiple schemas are defined they only # Currently at places where multiple schemas are defined they only
# differ in example so either can be used. # differ in example so either can be used.
response += '_0' status_code += '_0'
if len(response) == 3: if len(status_code) == 3:
schema = (openapi_spec.spec()['paths'][endpoint][method.lower()]['responses'] schema = (openapi_spec.openapi()['paths'][endpoint][method.lower()]['responses']
[response]['content']['application/json']['schema']) [status_code]['content']['application/json']['schema'])
return schema return schema
else: else:
resp_code = int(response[4]) subschema_index = int(status_code[4])
response = response[0:3] status_code = status_code[0:3]
schema = (openapi_spec.spec()['paths'][endpoint][method.lower()]['responses'] schema = (openapi_spec.openapi()['paths'][endpoint][method.lower()]['responses']
[response]['content']['application/json']['schema']["oneOf"][resp_code]) [status_code]['content']['application/json']['schema']["oneOf"][subschema_index])
return schema return schema
def get_openapi_fixture(endpoint: str, method: str, def get_openapi_fixture(endpoint: str, method: str,
response: str='200') -> Dict[str, Any]: status_code: str='200') -> Dict[str, Any]:
"""Fetch a fixture from the full spec object. """Fetch a fixture from the full spec object.
""" """
return (get_schema(endpoint, method, response)['example']) return get_schema(endpoint, method, status_code)['example']
def get_openapi_description(endpoint: str, method: str) -> str: def get_openapi_description(endpoint: str, method: str) -> str:
"""Fetch a description from the full spec object. """Fetch a description from the full spec object.
""" """
description = openapi_spec.spec()['paths'][endpoint][method.lower()]['description'] return openapi_spec.openapi()['paths'][endpoint][method.lower()]['description']
return description
def get_openapi_paths() -> Set[str]: def get_openapi_paths() -> Set[str]:
return set(openapi_spec.spec()['paths'].keys()) return set(openapi_spec.openapi()['paths'].keys())
def get_openapi_parameters(endpoint: str, method: str, def get_openapi_parameters(endpoint: str, method: str,
include_url_parameters: bool=True) -> List[Dict[str, Any]]: include_url_parameters: bool=True) -> List[Dict[str, Any]]:
openapi_endpoint = openapi_spec.spec()['paths'][endpoint][method.lower()] operation = openapi_spec.openapi()['paths'][endpoint][method.lower()]
# We do a `.get()` for this last bit to distinguish documented # We do a `.get()` for this last bit to distinguish documented
# endpoints with no parameters (empty list) from undocumented # endpoints with no parameters (empty list) from undocumented
# endpoints (KeyError exception). # endpoints (KeyError exception).
parameters = openapi_endpoint.get('parameters', []) parameters = operation.get('parameters', [])
# Also, we skip parameters defined in the URL. # Also, we skip parameters defined in the URL.
if not include_url_parameters: if not include_url_parameters:
parameters = [parameter for parameter in parameters if parameters = [parameter for parameter in parameters if
parameter['in'] != 'path'] parameter['in'] != 'path']
return parameters return parameters
def get_openapi_return_values(endpoint: str, method: str, def get_openapi_return_values(endpoint: str, method: str) -> List[Dict[str, Any]]:
include_url_parameters: bool=True) -> List[Dict[str, Any]]: operation = openapi_spec.openapi()['paths'][endpoint][method.lower()]
openapi_endpoint = openapi_spec.spec()['paths'][endpoint][method.lower()] schema = operation['responses']['200']['content']['application/json']['schema']
response = openapi_endpoint['responses']['200']['content']['application/json']['schema']
# In cases where we have used oneOf, the schemas only differ in examples # In cases where we have used oneOf, the schemas only differ in examples
# So we can choose any. # So we can choose any.
if 'oneOf' in response: if 'oneOf' in schema:
response = response['oneOf'][0] schema = schema['oneOf'][0]
response = response['properties'] return schema['properties']
return response
def match_against_openapi_regex(endpoint: str) -> Optional[str]: def find_openapi_endpoint(path: str) -> Optional[str]:
for key in openapi_spec.regex_keys(): for path_regex, endpoint in openapi_spec.endpoints_dict().items():
matches = re.match(fr'{key}', endpoint) matches = re.match(path_regex, path)
if matches: if matches:
return openapi_spec.regex_keys()[key] return endpoint
return None return None
def get_event_type(event: Dict[str, Any]) -> str: def get_event_type(event: Dict[str, Any]) -> str:
@ -198,8 +201,8 @@ def fix_events(content: Dict[str, Any]) -> None:
for event in content['events']: for event in content['events']:
event.pop('user', None) event.pop('user', None)
def validate_against_openapi_schema(content: Dict[str, Any], endpoint: str, def validate_against_openapi_schema(content: Dict[str, Any], path: str,
method: str, response: str) -> bool: method: str, status_code: str) -> bool:
"""Compare a "content" dict with the defined schema for a specific method """Compare a "content" dict with the defined schema for a specific method
in an endpoint. Return true if validated and false if skipped. in an endpoint. Return true if validated and false if skipped.
""" """
@ -208,26 +211,27 @@ def validate_against_openapi_schema(content: Dict[str, Any], endpoint: str,
# hope to eliminate over time as we improve our API documentation. # hope to eliminate over time as we improve our API documentation.
# No 500 responses have been documented, so skip them # No 500 responses have been documented, so skip them
if response.startswith('5'): if status_code.startswith('5'):
return False return False
if endpoint not in openapi_spec.spec()['paths'].keys(): if path not in openapi_spec.openapi()['paths'].keys():
match = match_against_openapi_regex(endpoint) endpoint = find_openapi_endpoint(path)
# If it doesn't match it hasn't been documented yet. # If it doesn't match it hasn't been documented yet.
if match is None: if endpoint is None:
return False return False
endpoint = match else:
endpoint = path
# Excluded endpoint/methods # Excluded endpoint/methods
if endpoint + ':' + method in EXCLUDE_UNDOCUMENTED_ENDPOINTS: if (endpoint, method) in EXCLUDE_UNDOCUMENTED_ENDPOINTS:
return False return False
# Return true for endpoints with only response documentation remaining # Return true for endpoints with only response documentation remaining
if endpoint + ':' + method in EXCLUDE_DOCUMENTED_ENDPOINTS: if (endpoint, method) in EXCLUDE_DOCUMENTED_ENDPOINTS:
return True return True
# Check if the response matches its code # Check if the response matches its code
if response.startswith('2') and (content.get('result', 'success').lower() != 'success'): if status_code.startswith('2') and (content.get('result', 'success').lower() != 'success'):
raise SchemaError("Response is not 200 but is validating against 200 schema") raise SchemaError("Response is not 200 but is validating against 200 schema")
# Code is not declared but appears in various 400 responses. If # Code is not declared but appears in various 400 responses. If
# common, it can be added to 400 response schema # common, it can be added to 400 response schema
if response.startswith('4'): if status_code.startswith('4'):
# This return statement should ideally be not here. But since # This return statement should ideally be not here. But since
# we have not defined 400 responses for various paths this has # we have not defined 400 responses for various paths this has
# been added as all 400 have the same schema. When all 400 # been added as all 400 have the same schema. When all 400
@ -235,7 +239,7 @@ def validate_against_openapi_schema(content: Dict[str, Any], endpoint: str,
return True return True
# The actual work of validating that the response matches the # The actual work of validating that the response matches the
# schema is done via the third-party OAS30Validator. # schema is done via the third-party OAS30Validator.
schema = get_schema(endpoint, method, response) schema = get_schema(endpoint, method, status_code)
if endpoint == '/events' and method == 'get': if endpoint == '/events' and method == 'get':
# This a temporary function for checking only documented events # This a temporary function for checking only documented events
# as all events haven't been documented yet. # as all events haven't been documented yet.
@ -250,11 +254,11 @@ def validate_schema_array(schema: Dict[str, Any]) -> None:
Helper function for validate_schema Helper function for validate_schema
""" """
if 'oneOf' in schema['items']: if 'oneOf' in schema['items']:
for oneof_schema in schema['items']['oneOf']: for subschema in schema['items']['oneOf']:
if oneof_schema['type'] == 'array': if subschema['type'] == 'array':
validate_schema_array(oneof_schema) validate_schema_array(subschema)
elif oneof_schema['type'] == 'object': elif subschema['type'] == 'object':
validate_schema(oneof_schema) validate_schema(subschema)
else: else:
if schema['items']['type'] == 'array': if schema['items']['type'] == 'array':
validate_schema_array(schema['items']) validate_schema_array(schema['items'])
@ -272,18 +276,18 @@ def validate_schema(schema: Dict[str, Any]) -> None:
if 'additionalProperties' not in schema: if 'additionalProperties' not in schema:
raise SchemaError('additionalProperties needs to be defined for objects to make' + raise SchemaError('additionalProperties needs to be defined for objects to make' +
'sure they have no additional properties left to be documented.') 'sure they have no additional properties left to be documented.')
for key in schema.get('properties', dict()): for property_schema in schema.get('properties', dict()).values():
if 'oneOf' in schema['properties'][key]: if 'oneOf' in property_schema:
for types in schema['properties'][key]['oneOf']: for subschema in property_schema['oneOf']:
if types['type'] == 'object': if subschema['type'] == 'object':
validate_schema(types) validate_schema(subschema)
elif types['type'] == 'array': elif subschema['type'] == 'array':
validate_schema_array(types) validate_schema_array(subschema)
else: else:
if schema['properties'][key]['type'] == 'object': if property_schema['type'] == 'object':
validate_schema(schema['properties'][key]) validate_schema(property_schema)
elif schema['properties'][key]['type'] == 'array': elif property_schema['type'] == 'array':
validate_schema_array(schema['properties'][key]) validate_schema_array(property_schema)
if schema['additionalProperties']: if schema['additionalProperties']:
if schema['additionalProperties']['type'] == 'array': if schema['additionalProperties']['type'] == 'array':
validate_schema_array(schema['additionalProperties']) validate_schema_array(schema['additionalProperties'])
@ -314,14 +318,16 @@ def likely_deprecated_parameter(parameter_description: str) -> bool:
# Skip those JSON endpoints whose query parameters are different from # Skip those JSON endpoints whose query parameters are different from
# their `/api/v1` counterpart. This is a legacy code issue that we # their `/api/v1` counterpart. This is a legacy code issue that we
# plan to fix by changing the implementation. # plan to fix by changing the implementation.
SKIP_JSON = {'/fetch_api_key:post'} SKIP_JSON = {
('/fetch_api_key', 'post'),
}
def validate_request(url: str, method: str, data: Dict[str, Any], def validate_request(url: str, method: str, data: Dict[str, Any],
http_headers: Dict[str, Any], json_url: bool, http_headers: Dict[str, Any], json_url: bool,
status_code: str, intentionally_undocumented: bool=False) -> None: status_code: str, intentionally_undocumented: bool=False) -> None:
# Some JSON endpoints have different parameters compared to # Some JSON endpoints have different parameters compared to
# their `/api/v1` counterparts. # their `/api/v1` counterparts.
if json_url and url + ':' + method in SKIP_JSON: if json_url and (url, method) in SKIP_JSON:
return return
# TODO: Add support for file upload endpoints that lack the /json/ # TODO: Add support for file upload endpoints that lack the /json/
@ -333,7 +339,7 @@ def validate_request(url: str, method: str, data: Dict[str, Any],
# against the OpenAPI documentation. # against the OpenAPI documentation.
mock_request = MockRequest('http://localhost:9991/', method, '/api/v1' + url, mock_request = MockRequest('http://localhost:9991/', method, '/api/v1' + url,
headers=http_headers, args=data) headers=http_headers, args=data)
result = openapi_spec.core_validator().validate(mock_request) result = openapi_spec.request_validator().validate(mock_request)
if len(result.errors) != 0: if len(result.errors) != 0:
# Requests that do not validate against the OpenAPI spec must either: # Requests that do not validate against the OpenAPI spec must either:
# * Have returned a 400 (bad request) error # * Have returned a 400 (bad request) error

View File

@ -16,7 +16,6 @@ from typing import (
Tuple, Tuple,
Union, Union,
) )
from unittest import mock
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import yaml import yaml
@ -34,10 +33,10 @@ from zerver.openapi.openapi import (
OPENAPI_SPEC_PATH, OPENAPI_SPEC_PATH,
OpenAPISpec, OpenAPISpec,
SchemaError, SchemaError,
find_openapi_endpoint,
get_openapi_fixture, get_openapi_fixture,
get_openapi_parameters, get_openapi_parameters,
get_openapi_paths, get_openapi_paths,
match_against_openapi_regex,
openapi_spec, openapi_spec,
to_python_type, to_python_type,
validate_against_openapi_schema, validate_against_openapi_schema,
@ -156,7 +155,7 @@ class OpenAPIToolsTest(ZulipTestCase):
with open(os.path.join(os.path.dirname(OPENAPI_SPEC_PATH), with open(os.path.join(os.path.dirname(OPENAPI_SPEC_PATH),
"testing.yaml")) as test_file: "testing.yaml")) as test_file:
test_dict = yaml.safe_load(test_file) test_dict = yaml.safe_load(test_file)
openapi_spec.spec()['paths']['testing'] = test_dict openapi_spec.openapi()['paths']['testing'] = test_dict
try: try:
validate_against_openapi_schema((test_dict['test1']['responses']['200']['content'] validate_against_openapi_schema((test_dict['test1']['responses']['200']['content']
['application/json']['example']), ['application/json']['example']),
@ -170,7 +169,7 @@ class OpenAPIToolsTest(ZulipTestCase):
validate_schema((test_dict['test3']['responses']['200'] validate_schema((test_dict['test3']['responses']['200']
['content']['application/json']['schema'])) ['content']['application/json']['schema']))
finally: finally:
openapi_spec.spec()['paths'].pop('testing', None) openapi_spec.openapi()['paths'].pop('testing', None)
def test_to_python_type(self) -> None: def test_to_python_type(self) -> None:
TYPES = { TYPES = {
@ -188,18 +187,18 @@ class OpenAPIToolsTest(ZulipTestCase):
def test_live_reload(self) -> None: def test_live_reload(self) -> None:
# Force the reload by making the last update date < the file's last # Force the reload by making the last update date < the file's last
# modified date # modified date
openapi_spec.last_update = 0 openapi_spec.mtime = 0
get_openapi_fixture(TEST_ENDPOINT, TEST_METHOD) get_openapi_fixture(TEST_ENDPOINT, TEST_METHOD)
# Check that the file has been reloaded by verifying that the last # Check that the file has been reloaded by verifying that the last
# update date isn't zero anymore # update date isn't zero anymore
self.assertNotEqual(openapi_spec.last_update, 0) self.assertNotEqual(openapi_spec.mtime, 0)
# Now verify calling it again doesn't call reload # Now verify calling it again doesn't call reload
old_spec_dict = openapi_spec.spec() old_openapi = openapi_spec.openapi()
get_openapi_fixture(TEST_ENDPOINT, TEST_METHOD) get_openapi_fixture(TEST_ENDPOINT, TEST_METHOD)
new_spec_dict = openapi_spec.spec() new_openapi = openapi_spec.openapi()
self.assertIs(old_spec_dict, new_spec_dict) self.assertIs(old_openapi, new_openapi)
class OpenAPIArgumentsTest(ZulipTestCase): class OpenAPIArgumentsTest(ZulipTestCase):
# This will be filled during test_openapi_arguments: # This will be filled during test_openapi_arguments:
@ -908,7 +907,7 @@ class TestCurlExampleGeneration(ZulipTestCase):
] ]
self.assertEqual(generated_curl_example, expected_curl_example) self.assertEqual(generated_curl_example, expected_curl_example)
@patch("zerver.openapi.openapi.OpenAPISpec.spec") @patch("zerver.openapi.openapi.OpenAPISpec.openapi")
def test_generate_and_render_curl_with_default_examples(self, spec_mock: MagicMock) -> None: def test_generate_and_render_curl_with_default_examples(self, spec_mock: MagicMock) -> None:
spec_mock.return_value = self.spec_mock_without_examples spec_mock.return_value = self.spec_mock_without_examples
generated_curl_example = self.curl_example("/mark_stream_as_read", "POST") generated_curl_example = self.curl_example("/mark_stream_as_read", "POST")
@ -922,7 +921,7 @@ class TestCurlExampleGeneration(ZulipTestCase):
] ]
self.assertEqual(generated_curl_example, expected_curl_example) self.assertEqual(generated_curl_example, expected_curl_example)
@patch("zerver.openapi.openapi.OpenAPISpec.spec") @patch("zerver.openapi.openapi.OpenAPISpec.openapi")
def test_generate_and_render_curl_with_invalid_method(self, spec_mock: MagicMock) -> None: def test_generate_and_render_curl_with_invalid_method(self, spec_mock: MagicMock) -> None:
spec_mock.return_value = self.spec_mock_with_invalid_method spec_mock.return_value = self.spec_mock_with_invalid_method
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@ -945,7 +944,7 @@ class TestCurlExampleGeneration(ZulipTestCase):
] ]
self.assertEqual(generated_curl_example, expected_curl_example) self.assertEqual(generated_curl_example, expected_curl_example)
@patch("zerver.openapi.openapi.OpenAPISpec.spec") @patch("zerver.openapi.openapi.OpenAPISpec.openapi")
def test_generate_and_render_curl_with_object(self, spec_mock: MagicMock) -> None: def test_generate_and_render_curl_with_object(self, spec_mock: MagicMock) -> None:
spec_mock.return_value = self.spec_mock_using_object spec_mock.return_value = self.spec_mock_using_object
generated_curl_example = self.curl_example("/endpoint", "GET") generated_curl_example = self.curl_example("/endpoint", "GET")
@ -958,19 +957,19 @@ class TestCurlExampleGeneration(ZulipTestCase):
] ]
self.assertEqual(generated_curl_example, expected_curl_example) self.assertEqual(generated_curl_example, expected_curl_example)
@patch("zerver.openapi.openapi.OpenAPISpec.spec") @patch("zerver.openapi.openapi.OpenAPISpec.openapi")
def test_generate_and_render_curl_with_object_without_example(self, spec_mock: MagicMock) -> None: def test_generate_and_render_curl_with_object_without_example(self, spec_mock: MagicMock) -> None:
spec_mock.return_value = self.spec_mock_using_object_without_example spec_mock.return_value = self.spec_mock_using_object_without_example
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
self.curl_example("/endpoint", "GET") self.curl_example("/endpoint", "GET")
@patch("zerver.openapi.openapi.OpenAPISpec.spec") @patch("zerver.openapi.openapi.OpenAPISpec.openapi")
def test_generate_and_render_curl_with_array_without_example(self, spec_mock: MagicMock) -> None: def test_generate_and_render_curl_with_array_without_example(self, spec_mock: MagicMock) -> None:
spec_mock.return_value = self.spec_mock_using_array_without_example spec_mock.return_value = self.spec_mock_using_array_without_example
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
self.curl_example("/endpoint", "GET") self.curl_example("/endpoint", "GET")
@patch("zerver.openapi.openapi.OpenAPISpec.spec") @patch("zerver.openapi.openapi.OpenAPISpec.openapi")
def test_generate_and_render_curl_with_param_in_path(self, spec_mock: MagicMock) -> None: def test_generate_and_render_curl_with_param_in_path(self, spec_mock: MagicMock) -> None:
spec_mock.return_value = self.spec_mock_using_param_in_path spec_mock.return_value = self.spec_mock_using_param_in_path
generated_curl_example = self.curl_example("/endpoint/{param1}", "GET") generated_curl_example = self.curl_example("/endpoint/{param1}", "GET")
@ -1023,30 +1022,27 @@ class OpenAPIAttributesTest(ZulipTestCase):
VALID_TAGS = ["users", "server_and_organizations", "authentication", VALID_TAGS = ["users", "server_and_organizations", "authentication",
"real_time_events", "streams", "messages", "users", "real_time_events", "streams", "messages", "users",
"webhooks"] "webhooks"]
openapi_spec = OpenAPISpec(OPENAPI_SPEC_PATH).spec()["paths"] paths = OpenAPISpec(OPENAPI_SPEC_PATH).openapi()["paths"]
for path in openapi_spec: for path, path_item in paths.items():
if path in EXCLUDE: if path in EXCLUDE:
continue continue
for method in openapi_spec[path]: for method, operation in path_item.items():
# Check if every file has an operationId # Check if every file has an operationId
assert("operationId" in openapi_spec[path][method]) assert("operationId" in operation)
assert("tags" in openapi_spec[path][method]) assert("tags" in operation)
tag = openapi_spec[path][method]["tags"][0] tag = operation["tags"][0]
assert(tag in VALID_TAGS) assert(tag in VALID_TAGS)
for response in openapi_spec[path][method]['responses']: for status_code, response in operation['responses'].items():
response_schema = (openapi_spec[path][method]['responses'][response] schema = response['content']['application/json']['schema']
['content']['application/json']['schema']) if 'oneOf' in schema:
if 'oneOf' in response_schema: for subschema_index, subschema in enumerate(schema['oneOf']):
cnt = 0 validate_schema(subschema)
for entry in response_schema['oneOf']: assert(validate_against_openapi_schema(subschema['example'], path,
validate_schema(entry) method, status_code + '_' + str(subschema_index)))
assert(validate_against_openapi_schema(entry['example'], path,
method, response + '_' + str(cnt)))
cnt += 1
continue continue
validate_schema(response_schema) validate_schema(schema)
assert(validate_against_openapi_schema(response_schema['example'], path, assert(validate_against_openapi_schema(schema['example'], path,
method, response)) method, status_code))
class OpenAPIRegexTest(ZulipTestCase): class OpenAPIRegexTest(ZulipTestCase):
def test_regex(self) -> None: def test_regex(self) -> None:
@ -1056,18 +1052,18 @@ class OpenAPIRegexTest(ZulipTestCase):
""" """
# Some of the undocumentd endpoints which are very similar to # Some of the undocumentd endpoints which are very similar to
# some of the documented endpoints. # some of the documented endpoints.
assert(match_against_openapi_regex('/users/me/presence') is None) assert(find_openapi_endpoint('/users/me/presence') is None)
assert(match_against_openapi_regex('/users/me/subscriptions/23') is None) assert(find_openapi_endpoint('/users/me/subscriptions/23') is None)
assert(match_against_openapi_regex('/users/iago/subscriptions/23') is None) assert(find_openapi_endpoint('/users/iago/subscriptions/23') is None)
assert(match_against_openapi_regex('/messages/matches_narrow') is None) assert(find_openapi_endpoint('/messages/matches_narrow') is None)
# Making sure documented endpoints are matched correctly. # Making sure documented endpoints are matched correctly.
assert(match_against_openapi_regex('/users/23/subscriptions/21') == assert(find_openapi_endpoint('/users/23/subscriptions/21') ==
'/users/{user_id}/subscriptions/{stream_id}') '/users/{user_id}/subscriptions/{stream_id}')
assert(match_against_openapi_regex('/users/iago@zulip.com/presence') == assert(find_openapi_endpoint('/users/iago@zulip.com/presence') ==
'/users/{email}/presence') '/users/{email}/presence')
assert(match_against_openapi_regex('/messages/23') == assert(find_openapi_endpoint('/messages/23') ==
'/messages/{message_id}') '/messages/{message_id}')
assert(match_against_openapi_regex('/realm/emoji/realm_emoji_1') == assert(find_openapi_endpoint('/realm/emoji/realm_emoji_1') ==
'/realm/emoji/{emoji_name}') '/realm/emoji/{emoji_name}')
class OpenAPIRequestValidatorTest(ZulipTestCase): class OpenAPIRequestValidatorTest(ZulipTestCase):