ARI: Add the ability to download the media associated with a stored recording
[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 import json
30 import os.path
31 import pprint
32 import re
33 import sys
34 import traceback
35
36 # We don't fully support Swagger 1.2, but we need it for subtyping
37 SWAGGER_VERSIONS = ["1.1", "1.2"]
38
39 SWAGGER_PRIMITIVES = [
40     'void',
41     'string',
42     'boolean',
43     'number',
44     'int',
45     'long',
46     'double',
47     'float',
48     'Date',
49 ]
50
51
52 class Stringify(object):
53     """Simple mix-in to make the repr of the model classes more meaningful.
54     """
55     def __repr__(self):
56         return "%s(%s)" % (self.__class__, pprint.saferepr(self.__dict__))
57
58
59 def compare_versions(lhs, rhs):
60     '''Performs a lexicographical comparison between two version numbers.
61
62     This properly handles simple major.minor.whatever.sure.why.not version
63     numbers, but fails miserably if there's any letters in there.
64
65     For reference:
66       1.0 == 1.0
67       1.0 < 1.0.1
68       1.2 < 1.10
69
70     @param lhs Left hand side of the comparison
71     @param rhs Right hand side of the comparison
72     @return  < 0 if lhs  < rhs
73     @return == 0 if lhs == rhs
74     @return  > 0 if lhs  > rhs
75     '''
76     lhs = [int(v) for v in lhs.split('.')]
77     rhs = [int(v) for v in rhs.split('.')]
78     return cmp(lhs, rhs)
79
80
81 class ParsingContext(object):
82     """Context information for parsing.
83
84     This object is immutable. To change contexts (like adding an item to the
85     stack), use the next() and next_stack() functions to build a new one.
86     """
87
88     def __init__(self, swagger_version, stack):
89         self.__swagger_version = swagger_version
90         self.__stack = stack
91
92     def __repr__(self):
93         return "ParsingContext(swagger_version=%s, stack=%s)" % (
94             self.swagger_version, self.stack)
95
96     def get_swagger_version(self):
97         return self.__swagger_version
98
99     def get_stack(self):
100         return self.__stack
101
102     swagger_version = property(get_swagger_version)
103
104     stack = property(get_stack)
105
106     def version_less_than(self, ver):
107         return compare_versions(self.swagger_version, ver) < 0
108
109     def next_stack(self, json, id_field):
110         """Returns a new item pushed to the stack.
111
112         @param json: Current JSON object.
113         @param id_field: Field identifying this object.
114         @return New context with additional item in the stack.
115         """
116         if not id_field in json:
117             raise SwaggerError("Missing id_field: %s" % id_field, self)
118         new_stack = self.stack + ['%s=%s' % (id_field, str(json[id_field]))]
119         return ParsingContext(self.swagger_version, new_stack)
120
121     def next(self, version=None, stack=None):
122         if version is None:
123             version = self.version
124         if stack is None:
125             stack = self.stack
126         return ParsingContext(version, stack)
127
128
129 class SwaggerError(Exception):
130     """Raised when an error is encountered mapping the JSON objects into the
131     model.
132     """
133
134     def __init__(self, msg, context, cause=None):
135         """Ctor.
136
137         @param msg: String message for the error.
138         @param context: ParsingContext object
139         @param cause: Optional exception that caused this one.
140         """
141         super(Exception, self).__init__(msg, context, cause)
142
143
144 class SwaggerPostProcessor(object):
145     """Post processing interface for model objects. This processor can add
146     fields to model objects for additional information to use in the
147     templates.
148     """
149     def process_resource_api(self, resource_api, context):
150         """Post process a ResourceApi object.
151
152         @param resource_api: ResourceApi object.
153         @param context: Current context in the API.
154         """
155         pass
156
157     def process_api(self, api, context):
158         """Post process an Api object.
159
160         @param api: Api object.
161         @param context: Current context in the API.
162         """
163         pass
164
165     def process_operation(self, operation, context):
166         """Post process a Operation object.
167
168         @param operation: Operation object.
169         @param context: Current context in the API.
170         """
171         pass
172
173     def process_parameter(self, parameter, context):
174         """Post process a Parameter object.
175
176         @param parameter: Parameter object.
177         @param context: Current context in the API.
178         """
179         pass
180
181     def process_model(self, model, context):
182         """Post process a Model object.
183
184         @param model: Model object.
185         @param context: Current context in the API.
186         """
187         pass
188
189     def process_property(self, property, context):
190         """Post process a Property object.
191
192         @param property: Property object.
193         @param context: Current context in the API.
194         """
195         pass
196
197     def process_type(self, swagger_type, context):
198         """Post process a SwaggerType object.
199
200         @param swagger_type: ResourceListing object.
201         @param context: Current context in the API.
202         """
203         pass
204
205     def process_resource_listing(self, resource_listing, context):
206         """Post process the overall ResourceListing object.
207
208         @param resource_listing: ResourceListing object.
209         @param context: Current context in the API.
210         """
211         pass
212
213
214 class AllowableRange(Stringify):
215     """Model of a allowableValues of type RANGE
216
217     See https://github.com/wordnik/swagger-core/wiki/datatypes#complex-types
218     """
219     def __init__(self, min_value, max_value):
220         self.min_value = min_value
221         self.max_value = max_value
222
223     def to_wiki(self):
224         return "Allowed range: Min: {0}; Max: {1}".format(self.min_value, self.max_value)
225
226
227 class AllowableList(Stringify):
228     """Model of a allowableValues of type LIST
229
230     See https://github.com/wordnik/swagger-core/wiki/datatypes#complex-types
231     """
232     def __init__(self, values):
233         self.values = values
234
235     def to_wiki(self):
236         return "Allowed values: {0}".format(", ".join(self.values))
237
238
239 def load_allowable_values(json, context):
240     """Parse a JSON allowableValues object.
241
242     This returns None, AllowableList or AllowableRange, depending on the
243     valueType in the JSON. If the valueType is not recognized, a SwaggerError
244     is raised.
245     """
246     if not json:
247         return None
248
249     if not 'valueType' in json:
250         raise SwaggerError("Missing valueType field", context)
251
252     value_type = json['valueType']
253
254     if value_type == 'RANGE':
255         if not 'min' in json and not 'max' in json:
256             raise SwaggerError("Missing fields min/max", context)
257         return AllowableRange(json.get('min'), json.get('max'))
258     if value_type == 'LIST':
259         if not 'values' in json:
260             raise SwaggerError("Missing field values", context)
261         return AllowableList(json['values'])
262     raise SwaggerError("Unkown valueType %s" % value_type, context)
263
264
265 class Parameter(Stringify):
266     """Model of an operation's parameter.
267
268     See https://github.com/wordnik/swagger-core/wiki/parameters
269     """
270
271     required_fields = ['name', 'paramType', 'dataType']
272
273     def __init__(self):
274         self.param_type = None
275         self.name = None
276         self.description = None
277         self.data_type = None
278         self.required = None
279         self.allowable_values = None
280         self.allow_multiple = None
281
282     def load(self, parameter_json, processor, context):
283         context = context.next_stack(parameter_json, 'name')
284         validate_required_fields(parameter_json, self.required_fields, context)
285         self.name = parameter_json.get('name')
286         self.param_type = parameter_json.get('paramType')
287         self.description = parameter_json.get('description') or ''
288         self.data_type = parameter_json.get('dataType')
289         self.required = parameter_json.get('required') or False
290         self.default_value = parameter_json.get('defaultValue')
291         self.allowable_values = load_allowable_values(
292             parameter_json.get('allowableValues'), context)
293         self.allow_multiple = parameter_json.get('allowMultiple') or False
294         processor.process_parameter(self, context)
295         if parameter_json.get('allowedValues'):
296             raise SwaggerError(
297                 "Field 'allowedValues' invalid; use 'allowableValues'",
298                 context)
299         return self
300
301     def is_type(self, other_type):
302         return self.param_type == other_type
303
304
305 class ErrorResponse(Stringify):
306     """Model of an error response.
307
308     See https://github.com/wordnik/swagger-core/wiki/errors
309     """
310
311     required_fields = ['code', 'reason']
312
313     def __init__(self):
314         self.code = None
315         self.reason = None
316
317     def load(self, err_json, processor, context):
318         context = context.next_stack(err_json, 'code')
319         validate_required_fields(err_json, self.required_fields, context)
320         self.code = err_json.get('code')
321         self.reason = err_json.get('reason')
322         return self
323
324
325 class SwaggerType(Stringify):
326     """Model of a data type.
327     """
328
329     def __init__(self):
330         self.name = None
331         self.is_discriminator = None
332         self.is_list = None
333         self.singular_name = None
334         self.is_primitive = None
335         self.is_binary = None
336
337     def load(self, type_name, processor, context):
338         # Some common errors
339         if type_name == 'integer':
340             raise SwaggerError("The type for integer should be 'int'", context)
341
342         self.name = type_name
343         type_param = get_list_parameter_type(self.name)
344         self.is_list = type_param is not None
345         if self.is_list:
346             self.singular_name = type_param
347         else:
348             self.singular_name = self.name
349         self.is_primitive = self.singular_name in SWAGGER_PRIMITIVES
350         self.is_binary = (self.singular_name == 'binary')
351         processor.process_type(self, context)
352         return self
353
354
355 class Operation(Stringify):
356     """Model of an operation on an API
357
358     See https://github.com/wordnik/swagger-core/wiki/API-Declaration#apis
359     """
360
361     required_fields = ['httpMethod', 'nickname', 'responseClass', 'summary']
362
363     def __init__(self):
364         self.http_method = None
365         self.nickname = None
366         self.response_class = None
367         self.parameters = []
368         self.summary = None
369         self.notes = None
370         self.error_responses = []
371
372     def load(self, op_json, processor, context):
373         context = context.next_stack(op_json, 'nickname')
374         validate_required_fields(op_json, self.required_fields, context)
375         self.http_method = op_json.get('httpMethod')
376         self.nickname = op_json.get('nickname')
377         response_class = op_json.get('responseClass')
378         self.response_class = response_class and SwaggerType().load(
379             response_class, processor, context)
380
381         # Specifying WebSocket URL's is our own extension
382         self.is_websocket = op_json.get('upgrade') == 'websocket'
383         self.is_req = not self.is_websocket
384
385         if self.is_websocket:
386             self.websocket_protocol = op_json.get('websocketProtocol')
387             if self.http_method != 'GET':
388                 raise SwaggerError(
389                     "upgrade: websocket is only valid on GET operations",
390                     context)
391
392         params_json = op_json.get('parameters') or []
393         self.parameters = [
394             Parameter().load(j, processor, context) for j in params_json]
395         self.query_parameters = [
396             p for p in self.parameters if p.is_type('query')]
397         self.has_query_parameters = self.query_parameters and True
398         self.path_parameters = [
399             p for p in self.parameters if p.is_type('path')]
400         self.has_path_parameters = self.path_parameters and True
401         self.header_parameters = [
402             p for p in self.parameters if p.is_type('header')]
403         self.has_header_parameters = self.header_parameters and True
404         self.has_parameters = self.has_query_parameters or \
405             self.has_path_parameters or self.has_header_parameters
406         self.is_binary_response = self.response_class.is_binary
407
408         # Body param is different, since there's at most one
409         self.body_parameter = [
410             p for p in self.parameters if p.is_type('body')]
411         if len(self.body_parameter) > 1:
412             raise SwaggerError("Cannot have more than one body param", context)
413         self.body_parameter = self.body_parameter and self.body_parameter[0]
414         self.has_body_parameter = self.body_parameter and True
415
416         self.summary = op_json.get('summary')
417         self.notes = op_json.get('notes')
418         err_json = op_json.get('errorResponses') or []
419         self.error_responses = [
420             ErrorResponse().load(j, processor, context) for j in err_json]
421         self.has_error_responses = self.error_responses != []
422         processor.process_operation(self, context)
423         return self
424
425
426 class Api(Stringify):
427     """Model of a single API in an API declaration.
428
429     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
430     """
431
432     required_fields = ['path', 'operations']
433
434     def __init__(self,):
435         self.path = None
436         self.description = None
437         self.operations = []
438
439     def load(self, api_json, processor, context):
440         context = context.next_stack(api_json, 'path')
441         validate_required_fields(api_json, self.required_fields, context)
442         self.path = api_json.get('path')
443         self.description = api_json.get('description')
444         op_json = api_json.get('operations')
445         self.operations = [
446             Operation().load(j, processor, context) for j in op_json]
447         self.has_websocket = \
448             filter(lambda op: op.is_websocket, 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 >> sys.stderr, "Error: ", traceback.format_exc()
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 >> sys.stderr, \
628                 "%s != %s" % (self.resource_path, expected_resource_path)
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         api_json = api_decl_json.get('apis') or []
651         self.apis = [
652             Api().load(j, processor, context) for j in api_json]
653         paths = set()
654         for api in self.apis:
655             if api.path in paths:
656                 raise SwaggerError("API with duplicated path: %s" % api.path, context)
657             paths.add(api.path)
658         self.has_websocket = filter(lambda api: api.has_websocket,
659                                     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(subtype)
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']
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).replace('{format}', 'json')
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 >> sys.stderr, "Error: ", traceback.format_exc()
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" % 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)