Skip to content

Commit abd1211

Browse files
anton-bAnton Benkevich
andauthored
Move parsing, loading and normalisation logic for the definitions to the alsdkdefs package (#92)
Add common validation point Improve validation Co-authored-by: Anton Benkevich <[email protected]>
1 parent 5e29a96 commit abd1211

File tree

4 files changed

+306
-49
lines changed

4 files changed

+306
-49
lines changed

alsdkdefs/__init__.py

Lines changed: 271 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,284 @@
11
import os
2+
from os.path import join as pjoin
23
import glob
4+
import collections
5+
import jsonschema
6+
from urllib.parse import urlparse
7+
from urllib.request import urlopen
8+
import yaml
9+
import yaml.resolver
10+
from functools import reduce, lru_cache
11+
import requests
12+
import json
13+
import re
14+
15+
OPENAPI_SCHEMA_URL = 'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json'
16+
17+
18+
class AlertLogicOpenApiValidationException(Exception):
19+
pass
20+
21+
22+
class OpenAPIKeyWord:
23+
OPENAPI = "openapi"
24+
INFO = "info"
25+
TITLE = "title"
26+
27+
SERVERS = "servers"
28+
URL = "url"
29+
SUMMARY = "summary"
30+
DESCRIPTION = "description"
31+
VARIABLES = "variables"
32+
REF = "$ref"
33+
REQUEST_BODY_NAME = "x-alertlogic-request-body-name"
34+
RESPONSES = "responses"
35+
PATHS = "paths"
36+
OPERATION_ID = "operationId"
37+
PARAMETERS = "parameters"
38+
REQUEST_BODY = "requestBody"
39+
IN = "in"
40+
PATH = "path"
41+
QUERY = "query"
42+
HEADER = "header"
43+
COOKIE = "cookie"
44+
BODY = "body"
45+
NAME = "name"
46+
REQUIRED = "required"
47+
SCHEMA = "schema"
48+
TYPE = "type"
49+
STRING = "string"
50+
OBJECT = "object"
51+
ITEMS = "items"
52+
ALL_OF = "allOf"
53+
ONE_OF = "oneOf"
54+
ANY_OF = "anyOf"
55+
BOOLEAN = "boolean"
56+
INTEGER = "integer"
57+
ARRAY = "array"
58+
NUMBER = "number"
59+
FORMAT = "format"
60+
ENUM = "enum"
61+
SECURITY = "security"
62+
COMPONENTS = "components"
63+
SCHEMAS = "schemas"
64+
PROPERTIES = "properties"
65+
REQUIRED = "required"
66+
CONTENT = "content"
67+
DEFAULT = "default"
68+
ENCODING = "encoding"
69+
EXPLODE = "explode"
70+
STYLE = "style"
71+
PARAMETER_STYLE_MATRIX = "matrix"
72+
PARAMETER_STYLE_LABEL = "label"
73+
PARAMETER_STYLE_FORM = "form"
74+
PARAMETER_STYLE_SIMPLE = "simple"
75+
PARAMETER_STYLE_SPACE_DELIMITED = "spaceDelimited"
76+
PARAMETER_STYLE_PIPE_DELIMITED = "pipeDelimited"
77+
PARAMETER_STYLE_DEEP_OBJECT = "deepObject"
78+
DATA = "data"
79+
CONTENT_TYPE_PARAM = "content-type"
80+
CONTENT_TYPE_JSON = "application/json"
81+
CONTENT_TYPE_TEXT = "text/plain"
82+
CONTENT_TYPE_PYTHON_PARAM = "content_type"
83+
84+
RESPONSE = "response"
85+
EXCEPTIONS = "exceptions"
86+
JSON_CONTENT_TYPES = ["application/json", "alertlogic.com/json"]
87+
88+
SIMPLE_DATA_TYPES = [STRING, BOOLEAN, INTEGER, NUMBER]
89+
DATA_TYPES = [STRING, OBJECT, ARRAY, BOOLEAN, INTEGER, NUMBER]
90+
INDIRECT_TYPES = [ANY_OF, ONE_OF]
91+
92+
# Alert Logic specific extensions
93+
X_ALERTLOGIC_SCHEMA = "x-alertlogic-schema"
94+
X_ALERTLOGIC_SESSION_ENDPOINT = "x-alertlogic-session-endpoint"
95+
96+
97+
#
98+
# Dictionaries don't preserve the order. However, we want to guarantee
99+
# that loaded yaml files are in the exact same order to, at least
100+
# produce the documentation that matches spec's order
101+
#
102+
class _YamlOrderedLoader(yaml.SafeLoader):
103+
pass
104+
105+
106+
_YamlOrderedLoader.add_constructor(
107+
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
108+
lambda loader, node: collections.OrderedDict(loader.construct_pairs(node))
109+
)
3110

4111

5112
def get_apis_dir():
6-
return f"{os.path.dirname(__file__)}/apis"
113+
"""Get absolute apis directory path on the fs"""
114+
return f"{pjoin(os.path.dirname(__file__), 'apis')}"
115+
116+
117+
def load_service_spec(service_name, apis_dir=None, version=None):
118+
"""Loads a version of service from library apis directory, if version is not specified, latest is loaded"""
119+
service_api_dir = pjoin(apis_dir or get_apis_dir(), service_name)
120+
if not version:
121+
# Find the latest version of the service api spes
122+
version = 0
123+
search_pattern = pjoin(service_api_dir, f"{service_name}.v*.yaml")
124+
for file in glob.glob(search_pattern):
125+
file_name = os.path.basename(file)
126+
new_version = int(file_name.split(".")[1][1:])
127+
version = version > new_version and version or new_version
128+
else:
129+
version = version[:1] != "v" and version or version[1:]
130+
service_spec_file = f"file:///{pjoin(service_api_dir, service_name)}.v{version}.yaml"
131+
return load_spec(service_spec_file)
132+
133+
134+
def load_spec(uri):
135+
"""Loads spec out of RFC3986 URI, resolves refs, normalizes"""
136+
return normalize_spec(get_spec(uri), uri)
137+
138+
139+
def normalize_spec(spec, uri):
140+
"""Resolves refs, normalizes"""
141+
return __normalize_spec(__resolve_refs(uri, spec))
142+
143+
144+
def get_spec(uri):
145+
"""Loads spec out of RFC3986 URI, yaml's Reader detects encoding automatically"""
146+
parsed = urlparse(uri)
147+
if not parsed.scheme:
148+
uri = f"file://{uri}"
149+
with urlopen(uri) as stream:
150+
try:
151+
return yaml.load(stream, _YamlOrderedLoader)
152+
except:
153+
return json.loads(stream.read())
7154

8155

9156
def list_services():
157+
"""Lists services definitions available"""
10158
base_dir = get_apis_dir()
11159
return sorted(next(os.walk(base_dir))[1])
12160

13161

14162
def get_service_defs(service_name):
15-
service_dir = "/".join([get_apis_dir(), service_name])
16-
return glob.glob(f"{service_dir}/{service_name}.v*.yaml")
163+
"""Lists a service's definitions available"""
164+
service_dir = pjoin(get_apis_dir(), service_name)
165+
return glob.glob(f"{service_dir}/{service_name}.v*.yaml")
166+
167+
168+
@lru_cache()
169+
def get_openapi_schema():
170+
r = requests.get(OPENAPI_SCHEMA_URL)
171+
return r.json()
172+
173+
174+
def validate(spec, uri=None, schema=get_openapi_schema()):
175+
"""Validates input spec by trying to resolve all refs, normalize, validate against the
176+
OpenAPI schema and then to suit AL SDK standards"""
177+
178+
def _get_path_methods(path_obj):
179+
return filter(lambda op: op in
180+
['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'], path_obj)
181+
182+
def validate_operation_ids(spec):
183+
for path, path_obj in spec[OpenAPIKeyWord.PATHS].items():
184+
methods = _get_path_methods(path_obj)
185+
for method in methods:
186+
operationid = path_obj[method].get(OpenAPIKeyWord.OPERATION_ID, None)
187+
pattern = '^[a-z_]+$'
188+
base_msg = f"Path {path} method {method} operation {operationid}"
189+
if not operationid:
190+
raise AlertLogicOpenApiValidationException(f"{base_msg}: Missing operationId")
191+
if not re.match(pattern, operationid):
192+
raise AlertLogicOpenApiValidationException(f"{base_msg}: OperationId does not match {pattern}")
193+
194+
def validate_path_parameters(spec):
195+
for path, path_obj in spec[OpenAPIKeyWord.PATHS].items():
196+
def get_params_of_type(params_obj, tp):
197+
return map(lambda p: p['name'], filter(lambda p: p['in'] == tp, params_obj))
198+
199+
methods = _get_path_methods(path_obj)
200+
path_param_vars = re.findall('{(.*?)}', path)
201+
path_methods_parameters = __list_flatten(
202+
map(lambda m: get_params_of_type(path_obj[m].get(OpenAPIKeyWord.PARAMETERS, []), 'path'), methods))
203+
for path_param_var in path_param_vars:
204+
base_msg = f"Path {path} parameter {path_param_var}"
205+
if path_param_var not in path_methods_parameters:
206+
raise AlertLogicOpenApiValidationException(f"{base_msg}: Parameter defined in the path is missing"
207+
f" from parameters section")
208+
209+
def al_specific_validations(spec):
210+
checks = [validate_operation_ids(spec), validate_path_parameters(spec)]
211+
all(checks)
212+
213+
obj = normalize_spec(spec, uri)
214+
jsonschema.validate(obj, schema)
215+
return al_specific_validations(spec)
216+
217+
218+
# Private functions
219+
220+
def __list_flatten(l):
221+
return [item for sublist in l for item in sublist]
222+
223+
224+
def __resolve_refs(file_uri, spec):
225+
handlers = {'': get_spec, 'file': get_spec, 'http': get_spec, 'https': get_spec}
226+
resolver = jsonschema.RefResolver(file_uri, spec, handlers=handlers)
227+
228+
def _do_resolve(node):
229+
if isinstance(node, collections.abc.Mapping) and OpenAPIKeyWord.REF in node:
230+
with resolver.resolving(node[OpenAPIKeyWord.REF]) as resolved:
231+
return resolved
232+
elif isinstance(node, collections.abc.Mapping):
233+
for k, v in node.items():
234+
node[k] = _do_resolve(v)
235+
__normalize_node(node)
236+
elif isinstance(node, (list, tuple)):
237+
for i in range(len(node)):
238+
node[i] = _do_resolve(node[i])
239+
240+
return node
241+
242+
return _do_resolve(spec)
243+
244+
245+
def __normalize_spec(spec):
246+
for path in spec[OpenAPIKeyWord.PATHS].values():
247+
parameters = path.pop(OpenAPIKeyWord.PARAMETERS, [])
248+
for method in path.values():
249+
method.setdefault(OpenAPIKeyWord.PARAMETERS, [])
250+
method[OpenAPIKeyWord.PARAMETERS].extend(parameters)
251+
return spec
252+
253+
254+
def __normalize_node(node):
255+
if OpenAPIKeyWord.ALL_OF in node:
256+
__update_dict_no_replace(
257+
node,
258+
dict(reduce(__deep_merge, node.pop(OpenAPIKeyWord.ALL_OF)))
259+
)
260+
261+
262+
def __update_dict_no_replace(target, source):
263+
for key in source.keys():
264+
if key not in target:
265+
target[key] = source[key]
266+
267+
268+
def __deep_merge(target, source):
269+
# Merge source into the target
270+
for k in set(target.keys()).union(source.keys()):
271+
if k in target and k in source:
272+
if isinstance(target[k], dict) and isinstance(source[k], dict):
273+
yield (k, dict(__deep_merge(target[k], source[k])))
274+
elif type(target[k]) is list and type(source[k]) is list:
275+
# TODO: Handle arrays of objects
276+
yield (k, list(set(target[k] + source[k])))
277+
else:
278+
# If one of the values is not a dict,
279+
# value from target dict overrides the one in source
280+
yield (k, target[k])
281+
elif k in target:
282+
yield (k, target[k])
283+
else:
284+
yield (k, source[k])

scripts/validate_my_definition.py

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,50 @@
11
#!/usr/bin/env python3
22

3-
import jsonschema
4-
import yaml
53
from yaml import YAMLError
6-
from jsonschema.exceptions import ValidationError
7-
import requests
4+
from jsonschema.exceptions import ValidationError, RefResolutionError
85
from argparse import ArgumentParser
96
import glob
10-
from almdrlib.client import _YamlOrderedLoader
11-
OPENAPI_SCHEMA_URL = 'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json'
7+
from alsdkdefs import AlertLogicOpenApiValidationException
8+
import alsdkdefs
9+
import os
1210

1311

1412
def validate_definition(definition_file):
1513
print(f"Validating {definition_file}")
16-
with open(definition_file, "r") as f:
17-
spec = f.read()
18-
if spec:
19-
try:
20-
obj = yaml.load(spec, Loader=_YamlOrderedLoader)
21-
jsonschema.validate(obj, schema)
22-
except YAMLError as e:
23-
print(f"Validation has failed - failed to load YAML {e}")
24-
exit(1)
25-
except ValidationError as e:
26-
print(f"Validation has failed - schema validation has failed {e}")
27-
exit(1)
28-
except TypeError:
29-
print(f"Validation has failed - json schema trips over integer keys, please "
30-
f"validate your response codes are not integers, check also other keys are not integers"
31-
f"if any of it are, quote it, '200', '400' etc")
32-
exit(1)
33-
print("Validation passed")
34-
else:
35-
print("Input is empty")
14+
try:
15+
spec = alsdkdefs.load_spec(definition_file)
16+
alsdkdefs.validate(spec, definition_file)
17+
except YAMLError as e:
18+
print(f"Validation has failed - failed to load YAML {e}")
19+
exit(1)
20+
except ValidationError as e:
21+
print(f"Validation has failed - schema validation has failed {e}")
22+
exit(1)
23+
except RefResolutionError as e:
24+
print(f"Validation has failed - $ref resolution has failed {e}")
3625
exit(1)
26+
except AlertLogicOpenApiValidationException as e:
27+
print(f"Validation has failed - definition has failed AlertLogic specific check {e}")
28+
exit(1)
29+
except TypeError:
30+
print(f"Validation has failed - json schema trips over integer keys, please "
31+
f"validate your response codes are not integers, check also other keys are not integers"
32+
f"if any of it are, quote it, '200', '400' etc")
33+
exit(1)
34+
print("Validation passed")
3735

3836

3937
if __name__ == "__main__":
4038
parser = ArgumentParser(description="Validates OpenAPI YAML developed for Alert Logic SDK")
4139
parser.add_argument("-d", "--definitions_directory", dest="dir", default="doc/openapi/",
4240
help="Directory with definitions to test")
4341
options = parser.parse_args()
44-
r = requests.get(OPENAPI_SCHEMA_URL)
45-
schema = r.json()
46-
files = glob.glob(f"{options.dir}/*.v[1-9]*.yaml")
42+
if os.path.isabs(options.dir):
43+
search_dir = options.dir
44+
else:
45+
search_dir = os.path.abspath(options.dir)
46+
search_pattern = os.path.join(search_dir, '*.v[1-9]*.yaml')
47+
files = glob.glob(search_pattern)
4748
if files:
4849
for file in files:
4950
validate_definition(file)

scripts/validate_my_definition.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ fi
1818

1919
if $PYTHON -m venv $TEMP; then
2020
source $TEMP/bin/activate
21-
pip3 install requests jsonschema PyYaml alertlogic-sdk-python --no-cache-dir --ignore-requires-python
21+
pip3 install requests jsonschema PyYaml alertlogic-sdk-definitions --no-cache-dir --ignore-requires-python
2222
curl https://raw.githubusercontent.com/alertlogic/alertlogic-sdk-definitions/master/scripts/validate_my_definition.py -o $TEMP/validate_my_definition.py
2323
if [ $# -eq 0 ]
2424
then

0 commit comments

Comments
 (0)