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