144f22bdb04fcd127d5268d3f1afc8d2e7ba5bc2
[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
224 class AllowableList(Stringify):
225     """Model of a allowableValues of type LIST
226
227     See https://github.com/wordnik/swagger-core/wiki/datatypes#complex-types
228     """
229     def __init__(self, values):
230         self.values = values
231
232
233 def load_allowable_values(json, context):
234     """Parse a JSON allowableValues object.
235
236     This returns None, AllowableList or AllowableRange, depending on the
237     valueType in the JSON. If the valueType is not recognized, a SwaggerError
238     is raised.
239     """
240     if not json:
241         return None
242
243     if not 'valueType' in json:
244         raise SwaggerError("Missing valueType field", context)
245
246     value_type = json['valueType']
247
248     if value_type == 'RANGE':
249         if not 'min' in json and not 'max' in json:
250             raise SwaggerError("Missing fields min/max", context)
251         return AllowableRange(json.get('min'), json.get('max'))
252     if value_type == 'LIST':
253         if not 'values' in json:
254             raise SwaggerError("Missing field values", context)
255         return AllowableList(json['values'])
256     raise SwaggerError("Unkown valueType %s" % value_type, context)
257
258
259 class Parameter(Stringify):
260     """Model of an operation's parameter.
261
262     See https://github.com/wordnik/swagger-core/wiki/parameters
263     """
264
265     required_fields = ['name', 'paramType', 'dataType']
266
267     def __init__(self):
268         self.param_type = None
269         self.name = None
270         self.description = None
271         self.data_type = None
272         self.required = None
273         self.allowable_values = None
274         self.allow_multiple = None
275
276     def load(self, parameter_json, processor, context):
277         context = context.next_stack(parameter_json, 'name')
278         validate_required_fields(parameter_json, self.required_fields, context)
279         self.name = parameter_json.get('name')
280         self.param_type = parameter_json.get('paramType')
281         self.description = parameter_json.get('description') or ''
282         self.data_type = parameter_json.get('dataType')
283         self.required = parameter_json.get('required') or False
284         self.default_value = parameter_json.get('defaultValue')
285         self.allowable_values = load_allowable_values(
286             parameter_json.get('allowableValues'), context)
287         self.allow_multiple = parameter_json.get('allowMultiple') or False
288         processor.process_parameter(self, context)
289         if parameter_json.get('allowedValues'):
290             raise SwaggerError(
291                 "Field 'allowedValues' invalid; use 'allowableValues'",
292                 context)
293         return self
294
295     def is_type(self, other_type):
296         return self.param_type == other_type
297
298
299 class ErrorResponse(Stringify):
300     """Model of an error response.
301
302     See https://github.com/wordnik/swagger-core/wiki/errors
303     """
304
305     required_fields = ['code', 'reason']
306
307     def __init__(self):
308         self.code = None
309         self.reason = None
310
311     def load(self, err_json, processor, context):
312         context = context.next_stack(err_json, 'code')
313         validate_required_fields(err_json, self.required_fields, context)
314         self.code = err_json.get('code')
315         self.reason = err_json.get('reason')
316         return self
317
318
319 class SwaggerType(Stringify):
320     """Model of a data type.
321     """
322
323     def __init__(self):
324         self.name = None
325         self.is_discriminator = None
326         self.is_list = None
327         self.singular_name = None
328         self.is_primitive = None
329
330     def load(self, type_name, processor, context):
331         # Some common errors
332         if type_name == 'integer':
333             raise SwaggerError("The type for integer should be 'int'", context)
334
335         self.name = type_name
336         type_param = get_list_parameter_type(self.name)
337         self.is_list = type_param is not None
338         if self.is_list:
339             self.singular_name = type_param
340         else:
341             self.singular_name = self.name
342         self.is_primitive = self.singular_name in SWAGGER_PRIMITIVES
343         processor.process_type(self, context)
344         return self
345
346
347 class Operation(Stringify):
348     """Model of an operation on an API
349
350     See https://github.com/wordnik/swagger-core/wiki/API-Declaration#apis
351     """
352
353     required_fields = ['httpMethod', 'nickname', 'responseClass', 'summary']
354
355     def __init__(self):
356         self.http_method = None
357         self.nickname = None
358         self.response_class = None
359         self.parameters = []
360         self.summary = None
361         self.notes = None
362         self.error_responses = []
363
364     def load(self, op_json, processor, context):
365         context = context.next_stack(op_json, 'nickname')
366         validate_required_fields(op_json, self.required_fields, context)
367         self.http_method = op_json.get('httpMethod')
368         self.nickname = op_json.get('nickname')
369         response_class = op_json.get('responseClass')
370         self.response_class = response_class and SwaggerType().load(
371             response_class, processor, context)
372
373         # Specifying WebSocket URL's is our own extension
374         self.is_websocket = op_json.get('upgrade') == 'websocket'
375         self.is_req = not self.is_websocket
376
377         if self.is_websocket:
378             self.websocket_protocol = op_json.get('websocketProtocol')
379             if self.http_method != 'GET':
380                 raise ValueError(
381                     "upgrade: websocket is only valid on GET operations")
382
383         params_json = op_json.get('parameters') or []
384         self.parameters = [
385             Parameter().load(j, processor, context) for j in params_json]
386         self.query_parameters = [
387             p for p in self.parameters if p.is_type('query')]
388         self.has_query_parameters = self.query_parameters and True
389         self.path_parameters = [
390             p for p in self.parameters if p.is_type('path')]
391         self.has_path_parameters = self.path_parameters and True
392         self.header_parameters = [
393             p for p in self.parameters if p.is_type('header')]
394         self.has_header_parameters = self.header_parameters and True
395         self.has_parameters = self.has_query_parameters or \
396             self.has_path_parameters or self.has_header_parameters
397         self.summary = op_json.get('summary')
398         self.notes = op_json.get('notes')
399         err_json = op_json.get('errorResponses') or []
400         self.error_responses = [
401             ErrorResponse().load(j, processor, context) for j in err_json]
402         self.has_error_responses = self.error_responses != []
403         processor.process_operation(self, context)
404         return self
405
406
407 class Api(Stringify):
408     """Model of a single API in an API declaration.
409
410     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
411     """
412
413     required_fields = ['path', 'operations']
414
415     def __init__(self,):
416         self.path = None
417         self.description = None
418         self.operations = []
419
420     def load(self, api_json, processor, context):
421         context = context.next_stack(api_json, 'path')
422         validate_required_fields(api_json, self.required_fields, context)
423         self.path = api_json.get('path')
424         self.description = api_json.get('description')
425         op_json = api_json.get('operations')
426         self.operations = [
427             Operation().load(j, processor, context) for j in op_json]
428         self.has_websocket = \
429             filter(lambda op: op.is_websocket, self.operations) != []
430         processor.process_api(self, context)
431         return self
432
433
434 def get_list_parameter_type(type_string):
435     """Returns the type parameter if the given type_string is List[].
436
437     @param type_string: Type string to parse
438     @returns Type parameter of the list, or None if not a List.
439     """
440     list_match = re.match('^List\[(.*)\]$', type_string)
441     return list_match and list_match.group(1)
442
443
444 class Property(Stringify):
445     """Model of a Swagger property.
446
447     See https://github.com/wordnik/swagger-core/wiki/datatypes
448     """
449
450     required_fields = ['type']
451
452     def __init__(self, name):
453         self.name = name
454         self.type = None
455         self.description = None
456         self.required = None
457
458     def load(self, property_json, processor, context):
459         validate_required_fields(property_json, self.required_fields, context)
460         # Bit of a hack, but properties do not self-identify
461         context = context.next_stack({'name': self.name}, 'name')
462         self.description = property_json.get('description') or ''
463         self.required = property_json.get('required') or False
464
465         type = property_json.get('type')
466         self.type = type and SwaggerType().load(type, processor, context)
467
468         processor.process_property(self, context)
469         return self
470
471
472 class Model(Stringify):
473     """Model of a Swagger model.
474
475     See https://github.com/wordnik/swagger-core/wiki/datatypes
476     """
477
478     required_fields = ['description', 'properties']
479
480     def __init__(self):
481         self.id = None
482         self.subtypes = []
483         self.__subtype_types = []
484         self.notes = None
485         self.description = None
486         self.__properties = None
487         self.__discriminator = None
488         self.__extends_type = None
489
490     def load(self, id, model_json, processor, context):
491         context = context.next_stack(model_json, 'id')
492         validate_required_fields(model_json, self.required_fields, context)
493         # The duplication of the model's id is required by the Swagger spec.
494         self.id = model_json.get('id')
495         if id != self.id:
496             raise SwaggerError("Model id doesn't match name", context)
497         self.subtypes = model_json.get('subTypes') or []
498         if self.subtypes and context.version_less_than("1.2"):
499             raise SwaggerError("Type extension support added in Swagger 1.2",
500                                context)
501         self.description = model_json.get('description')
502         props = model_json.get('properties').items() or []
503         self.__properties = [
504             Property(k).load(j, processor, context) for (k, j) in props]
505         self.__properties = sorted(self.__properties, key=lambda p: p.name)
506
507         discriminator = model_json.get('discriminator')
508
509         if discriminator:
510             if context.version_less_than("1.2"):
511                 raise SwaggerError("Discriminator support added in Swagger 1.2",
512                                    context)
513
514             discr_props = [p for p in self.__properties if p.name == discriminator]
515             if not discr_props:
516                 raise SwaggerError(
517                     "Discriminator '%s' does not name a property of '%s'" % (
518                         discriminator, self.id),
519                     context)
520
521             self.__discriminator = discr_props[0]
522
523         self.model_json = json.dumps(model_json,
524                                      indent=2, separators=(',', ': '))
525
526         processor.process_model(self, context)
527         return self
528
529     def extends(self):
530         return self.__extends_type and self.__extends_type.id
531
532     def set_extends_type(self, extends_type):
533         self.__extends_type = extends_type
534
535     def set_subtype_types(self, subtype_types):
536         self.__subtype_types = subtype_types
537
538     def discriminator(self):
539         """Returns the discriminator, digging through base types if needed.
540         """
541         return self.__discriminator or \
542             self.__extends_type and self.__extends_type.discriminator()
543
544     def properties(self):
545         base_props = []
546         if self.__extends_type:
547             base_props = self.__extends_type.properties()
548         return base_props + self.__properties
549
550     def has_properties(self):
551         return len(self.properties()) > 0
552
553     def all_subtypes(self):
554         """Returns the full list of all subtypes, including sub-subtypes.
555         """
556         res = self.__subtype_types + \
557               [subsubtypes for subtype in self.__subtype_types
558                for subsubtypes in subtype.all_subtypes()]
559         return sorted(res, key=lambda m: m.id)
560
561     def has_subtypes(self):
562         """Returns True if type has any subtypes.
563         """
564         return len(self.subtypes) > 0
565
566
567 class ApiDeclaration(Stringify):
568     """Model class for an API Declaration.
569
570     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
571     """
572
573     required_fields = [
574         'swaggerVersion', '_author', '_copyright', 'apiVersion', 'basePath',
575         'resourcePath', 'apis', 'models'
576     ]
577
578     def __init__(self):
579         self.swagger_version = None
580         self.author = None
581         self.copyright = None
582         self.api_version = None
583         self.base_path = None
584         self.resource_path = None
585         self.apis = []
586         self.models = []
587
588     def load_file(self, api_declaration_file, processor):
589         context = ParsingContext(None, [api_declaration_file])
590         try:
591             return self.__load_file(api_declaration_file, processor, context)
592         except SwaggerError:
593             raise
594         except Exception as e:
595             print >> sys.stderr, "Error: ", traceback.format_exc()
596             raise SwaggerError(
597                 "Error loading %s" % api_declaration_file, context, e)
598
599     def __load_file(self, api_declaration_file, processor, context):
600         with open(api_declaration_file) as fp:
601             self.load(json.load(fp), processor, context)
602
603         expected_resource_path = '/api-docs/' + \
604             os.path.basename(api_declaration_file) \
605             .replace(".json", ".{format}")
606
607         if self.resource_path != expected_resource_path:
608             print >> sys.stderr, \
609                 "%s != %s" % (self.resource_path, expected_resource_path)
610             raise SwaggerError("resourcePath has incorrect value", context)
611
612         return self
613
614     def load(self, api_decl_json, processor, context):
615         """Loads a resource from a single Swagger resource.json file.
616         """
617         # If the version doesn't match, all bets are off.
618         self.swagger_version = api_decl_json.get('swaggerVersion')
619         context = context.next(version=self.swagger_version)
620         if not self.swagger_version in SWAGGER_VERSIONS:
621             raise SwaggerError(
622                 "Unsupported Swagger version %s" % self.swagger_version, context)
623
624         validate_required_fields(api_decl_json, self.required_fields, context)
625
626         self.author = api_decl_json.get('_author')
627         self.copyright = api_decl_json.get('_copyright')
628         self.api_version = api_decl_json.get('apiVersion')
629         self.base_path = api_decl_json.get('basePath')
630         self.resource_path = api_decl_json.get('resourcePath')
631         api_json = api_decl_json.get('apis') or []
632         self.apis = [
633             Api().load(j, processor, context) for j in api_json]
634         paths = set()
635         for api in self.apis:
636             if api.path in paths:
637                 raise SwaggerError("API with duplicated path: %s" % api.path, context)
638             paths.add(api.path)
639         self.has_websocket = filter(lambda api: api.has_websocket,
640                                     self.apis) == []
641         models = api_decl_json.get('models').items() or []
642         self.models = [Model().load(id, json, processor, context)
643                        for (id, json) in models]
644         self.models = sorted(self.models, key=lambda m: m.id)
645         # Now link all base/extended types
646         model_dict = dict((m.id, m) for m in self.models)
647         for m in self.models:
648             def link_subtype(name):
649                 res = model_dict.get(subtype)
650                 if not res:
651                     raise SwaggerError("%s has non-existing subtype %s",
652                                        m.id, name)
653                 res.set_extends_type(m)
654                 return res;
655             if m.subtypes:
656                 m.set_subtype_types([
657                     link_subtype(subtype) for subtype in m.subtypes])
658         return self
659
660
661 class ResourceApi(Stringify):
662     """Model of an API listing in the resources.json file.
663     """
664
665     required_fields = ['path', 'description']
666
667     def __init__(self):
668         self.path = None
669         self.description = None
670         self.api_declaration = None
671
672     def load(self, api_json, processor, context):
673         context = context.next_stack(api_json, 'path')
674         validate_required_fields(api_json, self.required_fields, context)
675         self.path = api_json['path']
676         self.description = api_json['description']
677
678         if not self.path or self.path[0] != '/':
679             raise SwaggerError("Path must start with /", context)
680         processor.process_resource_api(self, context)
681         return self
682
683     def load_api_declaration(self, base_dir, processor):
684         self.file = (base_dir + self.path).replace('{format}', 'json')
685         self.api_declaration = ApiDeclaration().load_file(self.file, processor)
686         processor.process_resource_api(self, [self.file])
687
688
689 class ResourceListing(Stringify):
690     """Model of Swagger's resources.json file.
691     """
692
693     required_fields = ['apiVersion', 'basePath', 'apis']
694
695     def __init__(self):
696         self.swagger_version = None
697         self.api_version = None
698         self.base_path = None
699         self.apis = None
700
701     def load_file(self, resource_file, processor):
702         context = ParsingContext(None, [resource_file])
703         try:
704             return self.__load_file(resource_file, processor, context)
705         except SwaggerError:
706             raise
707         except Exception as e:
708             print >> sys.stderr, "Error: ", traceback.format_exc()
709             raise SwaggerError(
710                 "Error loading %s" % resource_file, context, e)
711
712     def __load_file(self, resource_file, processor, context):
713         with open(resource_file) as fp:
714             return self.load(json.load(fp), processor, context)
715
716     def load(self, resources_json, processor, context):
717         # If the version doesn't match, all bets are off.
718         self.swagger_version = resources_json.get('swaggerVersion')
719         if not self.swagger_version in SWAGGER_VERSIONS:
720             raise SwaggerError(
721                 "Unsupported Swagger version %s" % swagger_version, context)
722
723         validate_required_fields(resources_json, self.required_fields, context)
724         self.api_version = resources_json['apiVersion']
725         self.base_path = resources_json['basePath']
726         apis_json = resources_json['apis']
727         self.apis = [
728             ResourceApi().load(j, processor, context) for j in apis_json]
729         processor.process_resource_listing(self, context)
730         return self
731
732
733 def validate_required_fields(json, required_fields, context):
734     """Checks a JSON object for a set of required fields.
735
736     If any required field is missing, a SwaggerError is raised.
737
738     @param json: JSON object to check.
739     @param required_fields: List of required fields.
740     @param context: Current context in the API.
741     """
742     missing_fields = [f for f in required_fields if not f in json]
743
744     if missing_fields:
745         raise SwaggerError(
746             "Missing fields: %s" % ', '.join(missing_fields), context)