ari:Add application/json parameter support
[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 SwaggerError(
381                     "upgrade: websocket is only valid on GET operations",
382                     context)
383
384         params_json = op_json.get('parameters') or []
385         self.parameters = [
386             Parameter().load(j, processor, context) for j in params_json]
387         self.query_parameters = [
388             p for p in self.parameters if p.is_type('query')]
389         self.has_query_parameters = self.query_parameters and True
390         self.path_parameters = [
391             p for p in self.parameters if p.is_type('path')]
392         self.has_path_parameters = self.path_parameters and True
393         self.header_parameters = [
394             p for p in self.parameters if p.is_type('header')]
395         self.has_header_parameters = self.header_parameters and True
396         self.has_parameters = self.has_query_parameters or \
397             self.has_path_parameters or self.has_header_parameters
398
399         # Body param is different, since there's at most one
400         self.body_parameter = [
401             p for p in self.parameters if p.is_type('body')]
402         if len(self.body_parameter) > 1:
403             raise SwaggerError("Cannot have more than one body param", context)
404         self.body_parameter = self.body_parameter and self.body_parameter[0]
405
406         self.summary = op_json.get('summary')
407         self.notes = op_json.get('notes')
408         err_json = op_json.get('errorResponses') or []
409         self.error_responses = [
410             ErrorResponse().load(j, processor, context) for j in err_json]
411         self.has_error_responses = self.error_responses != []
412         processor.process_operation(self, context)
413         return self
414
415
416 class Api(Stringify):
417     """Model of a single API in an API declaration.
418
419     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
420     """
421
422     required_fields = ['path', 'operations']
423
424     def __init__(self,):
425         self.path = None
426         self.description = None
427         self.operations = []
428
429     def load(self, api_json, processor, context):
430         context = context.next_stack(api_json, 'path')
431         validate_required_fields(api_json, self.required_fields, context)
432         self.path = api_json.get('path')
433         self.description = api_json.get('description')
434         op_json = api_json.get('operations')
435         self.operations = [
436             Operation().load(j, processor, context) for j in op_json]
437         self.has_websocket = \
438             filter(lambda op: op.is_websocket, self.operations) != []
439         processor.process_api(self, context)
440         return self
441
442
443 def get_list_parameter_type(type_string):
444     """Returns the type parameter if the given type_string is List[].
445
446     @param type_string: Type string to parse
447     @returns Type parameter of the list, or None if not a List.
448     """
449     list_match = re.match('^List\[(.*)\]$', type_string)
450     return list_match and list_match.group(1)
451
452
453 class Property(Stringify):
454     """Model of a Swagger property.
455
456     See https://github.com/wordnik/swagger-core/wiki/datatypes
457     """
458
459     required_fields = ['type']
460
461     def __init__(self, name):
462         self.name = name
463         self.type = None
464         self.description = None
465         self.required = None
466
467     def load(self, property_json, processor, context):
468         validate_required_fields(property_json, self.required_fields, context)
469         # Bit of a hack, but properties do not self-identify
470         context = context.next_stack({'name': self.name}, 'name')
471         self.description = property_json.get('description') or ''
472         self.required = property_json.get('required') or False
473
474         type = property_json.get('type')
475         self.type = type and SwaggerType().load(type, processor, context)
476
477         processor.process_property(self, context)
478         return self
479
480
481 class Model(Stringify):
482     """Model of a Swagger model.
483
484     See https://github.com/wordnik/swagger-core/wiki/datatypes
485     """
486
487     required_fields = ['description', 'properties']
488
489     def __init__(self):
490         self.id = None
491         self.subtypes = []
492         self.__subtype_types = []
493         self.notes = None
494         self.description = None
495         self.__properties = None
496         self.__discriminator = None
497         self.__extends_type = None
498
499     def load(self, id, model_json, processor, context):
500         context = context.next_stack(model_json, 'id')
501         validate_required_fields(model_json, self.required_fields, context)
502         # The duplication of the model's id is required by the Swagger spec.
503         self.id = model_json.get('id')
504         if id != self.id:
505             raise SwaggerError("Model id doesn't match name", context)
506         self.subtypes = model_json.get('subTypes') or []
507         if self.subtypes and context.version_less_than("1.2"):
508             raise SwaggerError("Type extension support added in Swagger 1.2",
509                                context)
510         self.description = model_json.get('description')
511         props = model_json.get('properties').items() or []
512         self.__properties = [
513             Property(k).load(j, processor, context) for (k, j) in props]
514         self.__properties = sorted(self.__properties, key=lambda p: p.name)
515
516         discriminator = model_json.get('discriminator')
517
518         if discriminator:
519             if context.version_less_than("1.2"):
520                 raise SwaggerError("Discriminator support added in Swagger 1.2",
521                                    context)
522
523             discr_props = [p for p in self.__properties if p.name == discriminator]
524             if not discr_props:
525                 raise SwaggerError(
526                     "Discriminator '%s' does not name a property of '%s'" % (
527                         discriminator, self.id),
528                     context)
529
530             self.__discriminator = discr_props[0]
531
532         self.model_json = json.dumps(model_json,
533                                      indent=2, separators=(',', ': '))
534
535         processor.process_model(self, context)
536         return self
537
538     def extends(self):
539         return self.__extends_type and self.__extends_type.id
540
541     def set_extends_type(self, extends_type):
542         self.__extends_type = extends_type
543
544     def set_subtype_types(self, subtype_types):
545         self.__subtype_types = subtype_types
546
547     def discriminator(self):
548         """Returns the discriminator, digging through base types if needed.
549         """
550         return self.__discriminator or \
551             self.__extends_type and self.__extends_type.discriminator()
552
553     def properties(self):
554         base_props = []
555         if self.__extends_type:
556             base_props = self.__extends_type.properties()
557         return base_props + self.__properties
558
559     def has_properties(self):
560         return len(self.properties()) > 0
561
562     def all_subtypes(self):
563         """Returns the full list of all subtypes, including sub-subtypes.
564         """
565         res = self.__subtype_types + \
566               [subsubtypes for subtype in self.__subtype_types
567                for subsubtypes in subtype.all_subtypes()]
568         return sorted(res, key=lambda m: m.id)
569
570     def has_subtypes(self):
571         """Returns True if type has any subtypes.
572         """
573         return len(self.subtypes) > 0
574
575
576 class ApiDeclaration(Stringify):
577     """Model class for an API Declaration.
578
579     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
580     """
581
582     required_fields = [
583         'swaggerVersion', '_author', '_copyright', 'apiVersion', 'basePath',
584         'resourcePath', 'apis', 'models'
585     ]
586
587     def __init__(self):
588         self.swagger_version = None
589         self.author = None
590         self.copyright = None
591         self.api_version = None
592         self.base_path = None
593         self.resource_path = None
594         self.apis = []
595         self.models = []
596
597     def load_file(self, api_declaration_file, processor):
598         context = ParsingContext(None, [api_declaration_file])
599         try:
600             return self.__load_file(api_declaration_file, processor, context)
601         except SwaggerError:
602             raise
603         except Exception as e:
604             print >> sys.stderr, "Error: ", traceback.format_exc()
605             raise SwaggerError(
606                 "Error loading %s" % api_declaration_file, context, e)
607
608     def __load_file(self, api_declaration_file, processor, context):
609         with open(api_declaration_file) as fp:
610             self.load(json.load(fp), processor, context)
611
612         expected_resource_path = '/api-docs/' + \
613             os.path.basename(api_declaration_file) \
614             .replace(".json", ".{format}")
615
616         if self.resource_path != expected_resource_path:
617             print >> sys.stderr, \
618                 "%s != %s" % (self.resource_path, expected_resource_path)
619             raise SwaggerError("resourcePath has incorrect value", context)
620
621         return self
622
623     def load(self, api_decl_json, processor, context):
624         """Loads a resource from a single Swagger resource.json file.
625         """
626         # If the version doesn't match, all bets are off.
627         self.swagger_version = api_decl_json.get('swaggerVersion')
628         context = context.next(version=self.swagger_version)
629         if not self.swagger_version in SWAGGER_VERSIONS:
630             raise SwaggerError(
631                 "Unsupported Swagger version %s" % self.swagger_version, context)
632
633         validate_required_fields(api_decl_json, self.required_fields, context)
634
635         self.author = api_decl_json.get('_author')
636         self.copyright = api_decl_json.get('_copyright')
637         self.api_version = api_decl_json.get('apiVersion')
638         self.base_path = api_decl_json.get('basePath')
639         self.resource_path = api_decl_json.get('resourcePath')
640         api_json = api_decl_json.get('apis') or []
641         self.apis = [
642             Api().load(j, processor, context) for j in api_json]
643         paths = set()
644         for api in self.apis:
645             if api.path in paths:
646                 raise SwaggerError("API with duplicated path: %s" % api.path, context)
647             paths.add(api.path)
648         self.has_websocket = filter(lambda api: api.has_websocket,
649                                     self.apis) == []
650         models = api_decl_json.get('models').items() or []
651         self.models = [Model().load(id, json, processor, context)
652                        for (id, json) in models]
653         self.models = sorted(self.models, key=lambda m: m.id)
654         # Now link all base/extended types
655         model_dict = dict((m.id, m) for m in self.models)
656         for m in self.models:
657             def link_subtype(name):
658                 res = model_dict.get(subtype)
659                 if not res:
660                     raise SwaggerError("%s has non-existing subtype %s",
661                                        m.id, name)
662                 res.set_extends_type(m)
663                 return res;
664             if m.subtypes:
665                 m.set_subtype_types([
666                     link_subtype(subtype) for subtype in m.subtypes])
667         return self
668
669
670 class ResourceApi(Stringify):
671     """Model of an API listing in the resources.json file.
672     """
673
674     required_fields = ['path', 'description']
675
676     def __init__(self):
677         self.path = None
678         self.description = None
679         self.api_declaration = None
680
681     def load(self, api_json, processor, context):
682         context = context.next_stack(api_json, 'path')
683         validate_required_fields(api_json, self.required_fields, context)
684         self.path = api_json['path']
685         self.description = api_json['description']
686
687         if not self.path or self.path[0] != '/':
688             raise SwaggerError("Path must start with /", context)
689         processor.process_resource_api(self, context)
690         return self
691
692     def load_api_declaration(self, base_dir, processor):
693         self.file = (base_dir + self.path).replace('{format}', 'json')
694         self.api_declaration = ApiDeclaration().load_file(self.file, processor)
695         processor.process_resource_api(self, [self.file])
696
697
698 class ResourceListing(Stringify):
699     """Model of Swagger's resources.json file.
700     """
701
702     required_fields = ['apiVersion', 'basePath', 'apis']
703
704     def __init__(self):
705         self.swagger_version = None
706         self.api_version = None
707         self.base_path = None
708         self.apis = None
709
710     def load_file(self, resource_file, processor):
711         context = ParsingContext(None, [resource_file])
712         try:
713             return self.__load_file(resource_file, processor, context)
714         except SwaggerError:
715             raise
716         except Exception as e:
717             print >> sys.stderr, "Error: ", traceback.format_exc()
718             raise SwaggerError(
719                 "Error loading %s" % resource_file, context, e)
720
721     def __load_file(self, resource_file, processor, context):
722         with open(resource_file) as fp:
723             return self.load(json.load(fp), processor, context)
724
725     def load(self, resources_json, processor, context):
726         # If the version doesn't match, all bets are off.
727         self.swagger_version = resources_json.get('swaggerVersion')
728         if not self.swagger_version in SWAGGER_VERSIONS:
729             raise SwaggerError(
730                 "Unsupported Swagger version %s" % swagger_version, context)
731
732         validate_required_fields(resources_json, self.required_fields, context)
733         self.api_version = resources_json['apiVersion']
734         self.base_path = resources_json['basePath']
735         apis_json = resources_json['apis']
736         self.apis = [
737             ResourceApi().load(j, processor, context) for j in apis_json]
738         processor.process_resource_listing(self, context)
739         return self
740
741
742 def validate_required_fields(json, required_fields, context):
743     """Checks a JSON object for a set of required fields.
744
745     If any required field is missing, a SwaggerError is raised.
746
747     @param json: JSON object to check.
748     @param required_fields: List of required fields.
749     @param context: Current context in the API.
750     """
751     missing_fields = [f for f in required_fields if not f in json]
752
753     if missing_fields:
754         raise SwaggerError(
755             "Missing fields: %s" % ', '.join(missing_fields), context)