Merge "README-SERIOUSLY.bestpractices.md: Speling correetions."
[asterisk/asterisk.git] / rest-api-templates / swagger_model.py
1
2 # Asterisk -- An open source telephony toolkit.
3 #
4 # Copyright (C) 2013, Digium, Inc.
5 #
6 # David M. Lee, II <dlee@digium.com>
7 #
8 # See http://www.asterisk.org for more information about
9 # the Asterisk project. Please do not directly contact
10 # any of the maintainers of this project for assistance;
11 # the project provides a web site, mailing lists and IRC
12 # channels for your use.
13 #
14 # This program is free software, distributed under the terms of
15 # the GNU General Public License Version 2. See the LICENSE file
16 # at the top of the source tree.
17 #
18
19 """Swagger data model objects.
20
21 These objects should map directly to the Swagger api-docs, without a lot of
22 additional fields. In the process of translation, it should also validate the
23 model for consistency against the Swagger spec (i.e., fail if fields are
24 missing, or have incorrect values).
25
26 See https://github.com/wordnik/swagger-core/wiki/API-Declaration for the spec.
27 """
28
29 from __future__ import print_function
30 import json
31 import os.path
32 import pprint
33 import re
34 import sys
35 import traceback
36
37 # We don't fully support Swagger 1.2, but we need it for subtyping
38 SWAGGER_VERSIONS = ["1.1", "1.2"]
39
40 SWAGGER_PRIMITIVES = [
41     'void',
42     'string',
43     'boolean',
44     'number',
45     'int',
46     'long',
47     'double',
48     'float',
49     'Date',
50 ]
51
52
53 class Stringify(object):
54     """Simple mix-in to make the repr of the model classes more meaningful.
55     """
56     def __repr__(self):
57         return "%s(%s)" % (self.__class__, pprint.saferepr(self.__dict__))
58
59
60 def compare_versions(lhs, rhs):
61     '''Performs a lexicographical comparison between two version numbers.
62
63     This properly handles simple major.minor.whatever.sure.why.not version
64     numbers, but fails miserably if there's any letters in there.
65
66     For reference:
67       1.0 == 1.0
68       1.0 < 1.0.1
69       1.2 < 1.10
70
71     @param lhs Left hand side of the comparison
72     @param rhs Right hand side of the comparison
73     @return  < 0 if lhs  < rhs
74     @return == 0 if lhs == rhs
75     @return  > 0 if lhs  > rhs
76     '''
77     lhs = [int(v) for v in lhs.split('.')]
78     rhs = [int(v) for v in rhs.split('.')]
79     return (lhs > rhs) - (lhs < rhs)
80
81
82 class ParsingContext(object):
83     """Context information for parsing.
84
85     This object is immutable. To change contexts (like adding an item to the
86     stack), use the next() and next_stack() functions to build a new one.
87     """
88
89     def __init__(self, swagger_version, stack):
90         self.__swagger_version = swagger_version
91         self.__stack = stack
92
93     def __repr__(self):
94         return "ParsingContext(swagger_version=%s, stack=%s)" % (
95             self.swagger_version, self.stack)
96
97     def get_swagger_version(self):
98         return self.__swagger_version
99
100     def get_stack(self):
101         return self.__stack
102
103     swagger_version = property(get_swagger_version)
104
105     stack = property(get_stack)
106
107     def version_less_than(self, ver):
108         return compare_versions(self.swagger_version, ver) < 0
109
110     def next_stack(self, json, id_field):
111         """Returns a new item pushed to the stack.
112
113         @param json: Current JSON object.
114         @param id_field: Field identifying this object.
115         @return New context with additional item in the stack.
116         """
117         if not id_field in json:
118             raise SwaggerError("Missing id_field: %s" % id_field, self)
119         new_stack = self.stack + ['%s=%s' % (id_field, str(json[id_field]))]
120         return ParsingContext(self.swagger_version, new_stack)
121
122     def next(self, version=None, stack=None):
123         if version is None:
124             version = self.version
125         if stack is None:
126             stack = self.stack
127         return ParsingContext(version, stack)
128
129
130 class SwaggerError(Exception):
131     """Raised when an error is encountered mapping the JSON objects into the
132     model.
133     """
134
135     def __init__(self, msg, context, cause=None):
136         """Ctor.
137
138         @param msg: String message for the error.
139         @param context: ParsingContext object
140         @param cause: Optional exception that caused this one.
141         """
142         super(Exception, self).__init__(msg, context, cause)
143
144
145 class SwaggerPostProcessor(object):
146     """Post processing interface for model objects. This processor can add
147     fields to model objects for additional information to use in the
148     templates.
149     """
150     def process_resource_api(self, resource_api, context):
151         """Post process a ResourceApi object.
152
153         @param resource_api: ResourceApi object.
154         @param context: Current context in the API.
155         """
156         pass
157
158     def process_api(self, api, context):
159         """Post process an Api object.
160
161         @param api: Api object.
162         @param context: Current context in the API.
163         """
164         pass
165
166     def process_operation(self, operation, context):
167         """Post process a Operation object.
168
169         @param operation: Operation object.
170         @param context: Current context in the API.
171         """
172         pass
173
174     def process_parameter(self, parameter, context):
175         """Post process a Parameter object.
176
177         @param parameter: Parameter object.
178         @param context: Current context in the API.
179         """
180         pass
181
182     def process_model(self, model, context):
183         """Post process a Model object.
184
185         @param model: Model object.
186         @param context: Current context in the API.
187         """
188         pass
189
190     def process_property(self, property, context):
191         """Post process a Property object.
192
193         @param property: Property object.
194         @param context: Current context in the API.
195         """
196         pass
197
198     def process_type(self, swagger_type, context):
199         """Post process a SwaggerType object.
200
201         @param swagger_type: ResourceListing object.
202         @param context: Current context in the API.
203         """
204         pass
205
206     def process_resource_listing(self, resource_listing, context):
207         """Post process the overall ResourceListing object.
208
209         @param resource_listing: ResourceListing object.
210         @param context: Current context in the API.
211         """
212         pass
213
214
215 class AllowableRange(Stringify):
216     """Model of a allowableValues of type RANGE
217
218     See https://github.com/wordnik/swagger-core/wiki/datatypes#complex-types
219     """
220     def __init__(self, min_value, max_value):
221         self.min_value = min_value
222         self.max_value = max_value
223
224     def to_wiki(self):
225         return "Allowed range: Min: {0}; Max: {1}".format(self.min_value, self.max_value)
226
227
228 class AllowableList(Stringify):
229     """Model of a allowableValues of type LIST
230
231     See https://github.com/wordnik/swagger-core/wiki/datatypes#complex-types
232     """
233     def __init__(self, values):
234         self.values = values
235
236     def to_wiki(self):
237         return "Allowed values: {0}".format(", ".join(self.values))
238
239
240 def load_allowable_values(json, context):
241     """Parse a JSON allowableValues object.
242
243     This returns None, AllowableList or AllowableRange, depending on the
244     valueType in the JSON. If the valueType is not recognized, a SwaggerError
245     is raised.
246     """
247     if not json:
248         return None
249
250     if not 'valueType' in json:
251         raise SwaggerError("Missing valueType field", context)
252
253     value_type = json['valueType']
254
255     if value_type == 'RANGE':
256         if not 'min' in json and not 'max' in json:
257             raise SwaggerError("Missing fields min/max", context)
258         return AllowableRange(json.get('min'), json.get('max'))
259     if value_type == 'LIST':
260         if not 'values' in json:
261             raise SwaggerError("Missing field values", context)
262         return AllowableList(json['values'])
263     raise SwaggerError("Unkown valueType %s" % value_type, context)
264
265
266 class Parameter(Stringify):
267     """Model of an operation's parameter.
268
269     See https://github.com/wordnik/swagger-core/wiki/parameters
270     """
271
272     required_fields = ['name', 'paramType', 'dataType']
273
274     def __init__(self):
275         self.param_type = None
276         self.name = None
277         self.description = None
278         self.data_type = None
279         self.required = None
280         self.allowable_values = None
281         self.allow_multiple = None
282
283     def load(self, parameter_json, processor, context):
284         context = context.next_stack(parameter_json, 'name')
285         validate_required_fields(parameter_json, self.required_fields, context)
286         self.name = parameter_json.get('name')
287         self.param_type = parameter_json.get('paramType')
288         self.description = parameter_json.get('description') or ''
289         self.data_type = parameter_json.get('dataType')
290         self.required = parameter_json.get('required') or False
291         self.default_value = parameter_json.get('defaultValue')
292         self.allowable_values = load_allowable_values(
293             parameter_json.get('allowableValues'), context)
294         self.allow_multiple = parameter_json.get('allowMultiple') or False
295         processor.process_parameter(self, context)
296         if parameter_json.get('allowedValues'):
297             raise SwaggerError(
298                 "Field 'allowedValues' invalid; use 'allowableValues'",
299                 context)
300         return self
301
302     def is_type(self, other_type):
303         return self.param_type == other_type
304
305
306 class ErrorResponse(Stringify):
307     """Model of an error response.
308
309     See https://github.com/wordnik/swagger-core/wiki/errors
310     """
311
312     required_fields = ['code', 'reason']
313
314     def __init__(self):
315         self.code = None
316         self.reason = None
317
318     def load(self, err_json, processor, context):
319         context = context.next_stack(err_json, 'code')
320         validate_required_fields(err_json, self.required_fields, context)
321         self.code = err_json.get('code')
322         self.reason = err_json.get('reason')
323         return self
324
325
326 class SwaggerType(Stringify):
327     """Model of a data type.
328     """
329
330     def __init__(self):
331         self.name = None
332         self.is_discriminator = None
333         self.is_list = None
334         self.singular_name = None
335         self.is_primitive = None
336         self.is_binary = None
337
338     def load(self, type_name, processor, context):
339         # Some common errors
340         if type_name == 'integer':
341             raise SwaggerError("The type for integer should be 'int'", context)
342
343         self.name = type_name
344         type_param = get_list_parameter_type(self.name)
345         self.is_list = type_param is not None
346         if self.is_list:
347             self.singular_name = type_param
348         else:
349             self.singular_name = self.name
350         self.is_primitive = self.singular_name in SWAGGER_PRIMITIVES
351         self.is_binary = (self.singular_name == 'binary')
352         processor.process_type(self, context)
353         return self
354
355
356 class Operation(Stringify):
357     """Model of an operation on an API
358
359     See https://github.com/wordnik/swagger-core/wiki/API-Declaration#apis
360     """
361
362     required_fields = ['httpMethod', 'nickname', 'responseClass', 'summary']
363
364     def __init__(self):
365         self.http_method = None
366         self.nickname = None
367         self.response_class = None
368         self.parameters = []
369         self.summary = None
370         self.notes = None
371         self.error_responses = []
372
373     def load(self, op_json, processor, context):
374         context = context.next_stack(op_json, 'nickname')
375         validate_required_fields(op_json, self.required_fields, context)
376         self.http_method = op_json.get('httpMethod')
377         self.nickname = op_json.get('nickname')
378         response_class = op_json.get('responseClass')
379         self.response_class = response_class and SwaggerType().load(
380             response_class, processor, context)
381
382         # Specifying WebSocket URL's is our own extension
383         self.is_websocket = op_json.get('upgrade') == 'websocket'
384         self.is_req = not self.is_websocket
385
386         if self.is_websocket:
387             self.websocket_protocol = op_json.get('websocketProtocol')
388             if self.http_method != 'GET':
389                 raise SwaggerError(
390                     "upgrade: websocket is only valid on GET operations",
391                     context)
392
393         params_json = op_json.get('parameters') or []
394         self.parameters = [
395             Parameter().load(j, processor, context) for j in params_json]
396         self.query_parameters = [
397             p for p in self.parameters if p.is_type('query')]
398         self.has_query_parameters = self.query_parameters and True
399         self.path_parameters = [
400             p for p in self.parameters if p.is_type('path')]
401         self.has_path_parameters = self.path_parameters and True
402         self.header_parameters = [
403             p for p in self.parameters if p.is_type('header')]
404         self.has_header_parameters = self.header_parameters and True
405         self.has_parameters = self.has_query_parameters or \
406             self.has_path_parameters or self.has_header_parameters
407         self.is_binary_response = self.response_class.is_binary
408
409         # Body param is different, since there's at most one
410         self.body_parameter = [
411             p for p in self.parameters if p.is_type('body')]
412         if len(self.body_parameter) > 1:
413             raise SwaggerError("Cannot have more than one body param", context)
414         self.body_parameter = self.body_parameter and self.body_parameter[0]
415         self.has_body_parameter = self.body_parameter and True
416
417         self.summary = op_json.get('summary')
418         self.notes = op_json.get('notes')
419         err_json = op_json.get('errorResponses') or []
420         self.error_responses = [
421             ErrorResponse().load(j, processor, context) for j in err_json]
422         self.has_error_responses = self.error_responses != []
423         processor.process_operation(self, context)
424         return self
425
426
427 class Api(Stringify):
428     """Model of a single API in an API declaration.
429
430     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
431     """
432
433     required_fields = ['path', 'operations']
434
435     def __init__(self,):
436         self.path = None
437         self.description = None
438         self.operations = []
439
440     def load(self, api_json, processor, context):
441         context = context.next_stack(api_json, 'path')
442         validate_required_fields(api_json, self.required_fields, context)
443         self.path = api_json.get('path')
444         self.description = api_json.get('description')
445         op_json = api_json.get('operations')
446         self.operations = [
447             Operation().load(j, processor, context) for j in op_json]
448         self.has_websocket = any(op.is_websocket for op in self.operations)
449         processor.process_api(self, context)
450         return self
451
452
453 def get_list_parameter_type(type_string):
454     """Returns the type parameter if the given type_string is List[].
455
456     @param type_string: Type string to parse
457     @returns Type parameter of the list, or None if not a List.
458     """
459     list_match = re.match('^List\[(.*)\]$', type_string)
460     return list_match and list_match.group(1)
461
462
463 class Property(Stringify):
464     """Model of a Swagger property.
465
466     See https://github.com/wordnik/swagger-core/wiki/datatypes
467     """
468
469     required_fields = ['type']
470
471     def __init__(self, name):
472         self.name = name
473         self.type = None
474         self.description = None
475         self.required = None
476
477     def load(self, property_json, processor, context):
478         validate_required_fields(property_json, self.required_fields, context)
479         # Bit of a hack, but properties do not self-identify
480         context = context.next_stack({'name': self.name}, 'name')
481         self.description = property_json.get('description') or ''
482         self.required = property_json.get('required') or False
483
484         type = property_json.get('type')
485         self.type = type and SwaggerType().load(type, processor, context)
486
487         processor.process_property(self, context)
488         return self
489
490
491 class Model(Stringify):
492     """Model of a Swagger model.
493
494     See https://github.com/wordnik/swagger-core/wiki/datatypes
495     """
496
497     required_fields = ['description', 'properties']
498
499     def __init__(self):
500         self.id = None
501         self.subtypes = []
502         self.__subtype_types = []
503         self.notes = None
504         self.description = None
505         self.__properties = None
506         self.__discriminator = None
507         self.__extends_type = None
508
509     def load(self, id, model_json, processor, context):
510         context = context.next_stack(model_json, 'id')
511         validate_required_fields(model_json, self.required_fields, context)
512         # The duplication of the model's id is required by the Swagger spec.
513         self.id = model_json.get('id')
514         if id != self.id:
515             raise SwaggerError("Model id doesn't match name", context)
516         self.subtypes = model_json.get('subTypes') or []
517         if self.subtypes and context.version_less_than("1.2"):
518             raise SwaggerError("Type extension support added in Swagger 1.2",
519                                context)
520         self.description = model_json.get('description')
521         props = model_json.get('properties').items() or []
522         self.__properties = [
523             Property(k).load(j, processor, context) for (k, j) in props]
524         self.__properties = sorted(self.__properties, key=lambda p: p.name)
525
526         discriminator = model_json.get('discriminator')
527
528         if discriminator:
529             if context.version_less_than("1.2"):
530                 raise SwaggerError("Discriminator support added in Swagger 1.2",
531                                    context)
532
533             discr_props = [p for p in self.__properties if p.name == discriminator]
534             if not discr_props:
535                 raise SwaggerError(
536                     "Discriminator '%s' does not name a property of '%s'" % (
537                         discriminator, self.id),
538                     context)
539
540             self.__discriminator = discr_props[0]
541
542         self.model_json = json.dumps(model_json,
543                                      indent=2, separators=(',', ': '))
544
545         processor.process_model(self, context)
546         return self
547
548     def extends(self):
549         return self.__extends_type and self.__extends_type.id
550
551     def set_extends_type(self, extends_type):
552         self.__extends_type = extends_type
553
554     def set_subtype_types(self, subtype_types):
555         self.__subtype_types = subtype_types
556
557     def discriminator(self):
558         """Returns the discriminator, digging through base types if needed.
559         """
560         return self.__discriminator or \
561             self.__extends_type and self.__extends_type.discriminator()
562
563     def properties(self):
564         base_props = []
565         if self.__extends_type:
566             base_props = self.__extends_type.properties()
567         return base_props + self.__properties
568
569     def has_properties(self):
570         return len(self.properties()) > 0
571
572     def all_subtypes(self):
573         """Returns the full list of all subtypes, including sub-subtypes.
574         """
575         res = self.__subtype_types + \
576               [subsubtypes for subtype in self.__subtype_types
577                for subsubtypes in subtype.all_subtypes()]
578         return sorted(res, key=lambda m: m.id)
579
580     def has_subtypes(self):
581         """Returns True if type has any subtypes.
582         """
583         return len(self.subtypes) > 0
584
585
586 class ApiDeclaration(Stringify):
587     """Model class for an API Declaration.
588
589     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
590     """
591
592     required_fields = [
593         'swaggerVersion', '_author', '_copyright', 'apiVersion', 'basePath',
594         'resourcePath', 'apis', 'models'
595     ]
596
597     def __init__(self):
598         self.swagger_version = None
599         self.author = None
600         self.copyright = None
601         self.api_version = None
602         self.base_path = None
603         self.resource_path = None
604         self.apis = []
605         self.models = []
606
607     def load_file(self, api_declaration_file, processor):
608         context = ParsingContext(None, [api_declaration_file])
609         try:
610             return self.__load_file(api_declaration_file, processor, context)
611         except SwaggerError:
612             raise
613         except Exception as e:
614             print("Error: ", traceback.format_exc(), file=sys.stderr)
615             raise SwaggerError(
616                 "Error loading %s" % api_declaration_file, context, e)
617
618     def __load_file(self, api_declaration_file, processor, context):
619         with open(api_declaration_file) as fp:
620             self.load(json.load(fp), processor, context)
621
622         expected_resource_path = '/api-docs/' + \
623             os.path.basename(api_declaration_file) \
624             .replace(".json", ".{format}")
625
626         if self.resource_path != expected_resource_path:
627             print("%s != %s" % (self.resource_path, expected_resource_path),
628                 file=sys.stderr)
629             raise SwaggerError("resourcePath has incorrect value", context)
630
631         return self
632
633     def load(self, api_decl_json, processor, context):
634         """Loads a resource from a single Swagger resource.json file.
635         """
636         # If the version doesn't match, all bets are off.
637         self.swagger_version = api_decl_json.get('swaggerVersion')
638         context = context.next(version=self.swagger_version)
639         if not self.swagger_version in SWAGGER_VERSIONS:
640             raise SwaggerError(
641                 "Unsupported Swagger version %s" % self.swagger_version, context)
642
643         validate_required_fields(api_decl_json, self.required_fields, context)
644
645         self.author = api_decl_json.get('_author')
646         self.copyright = api_decl_json.get('_copyright')
647         self.api_version = api_decl_json.get('apiVersion')
648         self.base_path = api_decl_json.get('basePath')
649         self.resource_path = api_decl_json.get('resourcePath')
650         self.requires_modules = api_decl_json.get('requiresModules') or []
651         api_json = api_decl_json.get('apis') or []
652         self.apis = [
653             Api().load(j, processor, context) for j in api_json]
654         paths = set()
655         for api in self.apis:
656             if api.path in paths:
657                 raise SwaggerError("API with duplicated path: %s" % api.path, context)
658             paths.add(api.path)
659         self.has_websocket = any(api.has_websocket for api in self.apis)
660         models = api_decl_json.get('models').items() or []
661         self.models = [Model().load(id, json, processor, context)
662                        for (id, json) in models]
663         self.models = sorted(self.models, key=lambda m: m.id)
664         # Now link all base/extended types
665         model_dict = dict((m.id, m) for m in self.models)
666         for m in self.models:
667             def link_subtype(name):
668                 res = model_dict.get(name)
669                 if not res:
670                     raise SwaggerError("%s has non-existing subtype %s",
671                                        m.id, name)
672                 res.set_extends_type(m)
673                 return res;
674             if m.subtypes:
675                 m.set_subtype_types([
676                     link_subtype(subtype) for subtype in m.subtypes])
677         return self
678
679
680 class ResourceApi(Stringify):
681     """Model of an API listing in the resources.json file.
682     """
683
684     required_fields = ['path', 'description']
685
686     def __init__(self):
687         self.path = None
688         self.description = None
689         self.api_declaration = None
690
691     def load(self, api_json, processor, context):
692         context = context.next_stack(api_json, 'path')
693         validate_required_fields(api_json, self.required_fields, context)
694         self.path = api_json['path'].replace('{format}', 'json')
695         self.description = api_json['description']
696
697         if not self.path or self.path[0] != '/':
698             raise SwaggerError("Path must start with /", context)
699         processor.process_resource_api(self, context)
700         return self
701
702     def load_api_declaration(self, base_dir, processor):
703         self.file = (base_dir + self.path)
704         self.api_declaration = ApiDeclaration().load_file(self.file, processor)
705         processor.process_resource_api(self, [self.file])
706
707
708 class ResourceListing(Stringify):
709     """Model of Swagger's resources.json file.
710     """
711
712     required_fields = ['apiVersion', 'basePath', 'apis']
713
714     def __init__(self):
715         self.swagger_version = None
716         self.api_version = None
717         self.base_path = None
718         self.apis = None
719
720     def load_file(self, resource_file, processor):
721         context = ParsingContext(None, [resource_file])
722         try:
723             return self.__load_file(resource_file, processor, context)
724         except SwaggerError:
725             raise
726         except Exception as e:
727             print("Error: ", traceback.format_exc(), file=sys.stderr)
728             raise SwaggerError(
729                 "Error loading %s" % resource_file, context, e)
730
731     def __load_file(self, resource_file, processor, context):
732         with open(resource_file) as fp:
733             return self.load(json.load(fp), processor, context)
734
735     def load(self, resources_json, processor, context):
736         # If the version doesn't match, all bets are off.
737         self.swagger_version = resources_json.get('swaggerVersion')
738         if not self.swagger_version in SWAGGER_VERSIONS:
739             raise SwaggerError(
740                 "Unsupported Swagger version %s" % self.swagger_version, context)
741
742         validate_required_fields(resources_json, self.required_fields, context)
743         self.api_version = resources_json['apiVersion']
744         self.base_path = resources_json['basePath']
745         apis_json = resources_json['apis']
746         self.apis = [
747             ResourceApi().load(j, processor, context) for j in apis_json]
748         processor.process_resource_listing(self, context)
749         return self
750
751
752 def validate_required_fields(json, required_fields, context):
753     """Checks a JSON object for a set of required fields.
754
755     If any required field is missing, a SwaggerError is raised.
756
757     @param json: JSON object to check.
758     @param required_fields: List of required fields.
759     @param context: Current context in the API.
760     """
761     missing_fields = [f for f in required_fields if not f in json]
762
763     if missing_fields:
764         raise SwaggerError(
765             "Missing fields: %s" % ', '.join(missing_fields), context)