11import os
2+ from os .path import join as pjoin
23import 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
5112def 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
9156def list_services ():
157+ """Lists services definitions available"""
10158 base_dir = get_apis_dir ()
11159 return sorted (next (os .walk (base_dir ))[1 ])
12160
13161
14162def 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 ])
0 commit comments