Shuffle RESTful URL's around.
[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 sys
33 import traceback
34
35 try:
36     from collections import OrderedDict
37 except ImportError:
38     from odict import OrderedDict
39
40
41 SWAGGER_VERSION = "1.1"
42
43
44 class SwaggerError(Exception):
45     """Raised when an error is encountered mapping the JSON objects into the
46     model.
47     """
48
49     def __init__(self, msg, context, cause=None):
50         """Ctor.
51
52         @param msg: String message for the error.
53         @param context: Array of strings for current context in the API.
54         @param cause: Optional exception that caused this one.
55         """
56         super(Exception, self).__init__(msg, context, cause)
57
58
59 class SwaggerPostProcessor(object):
60     """Post processing interface for model objects. This processor can add
61     fields to model objects for additional information to use in the
62     templates.
63     """
64     def process_api(self, resource_api, context):
65         """Post process a ResourceApi object.
66
67         @param resource_api: ResourceApi object.
68         @param context: Current context in the API.
69         """
70         pass
71
72     def process_operation(self, operation, context):
73         """Post process a Operation object.
74
75         @param operation: Operation object.
76         @param context: Current context in the API.
77         """
78         pass
79
80     def process_parameter(self, parameter, context):
81         """Post process a Parameter object.
82
83         @param parameter: Parameter object.
84         @param context: Current context in the API.
85         """
86         pass
87
88
89 class Stringify(object):
90     """Simple mix-in to make the repr of the model classes more meaningful.
91     """
92     def __repr__(self):
93         return "%s(%s)" % (self.__class__, pprint.saferepr(self.__dict__))
94
95
96 class AllowableRange(Stringify):
97     """Model of a allowableValues of type RANGE
98
99     See https://github.com/wordnik/swagger-core/wiki/datatypes#complex-types
100     """
101     def __init__(self, min_value, max_value):
102         self.min_value = min_value
103         self.max_value = max_value
104
105
106 class AllowableList(Stringify):
107     """Model of a allowableValues of type LIST
108
109     See https://github.com/wordnik/swagger-core/wiki/datatypes#complex-types
110     """
111     def __init__(self, values):
112         self.values = values
113
114
115 def load_allowable_values(json, context):
116     """Parse a JSON allowableValues object.
117
118     This returns None, AllowableList or AllowableRange, depending on the
119     valueType in the JSON. If the valueType is not recognized, a SwaggerError
120     is raised.
121     """
122     if not json:
123         return None
124
125     if not 'valueType' in json:
126         raise SwaggerError("Missing valueType field", context)
127
128     value_type = json['valueType']
129
130     if value_type == 'RANGE':
131         if not 'min' in json:
132             raise SwaggerError("Missing field min", context)
133         if not 'max' in json:
134             raise SwaggerError("Missing field max", context)
135         return AllowableRange(json['min'], json['max'])
136     if value_type == 'LIST':
137         if not 'values' in json:
138             raise SwaggerError("Missing field values", context)
139         return AllowableList(json['values'])
140     raise SwaggerError("Unkown valueType %s" % value_type, context)
141
142
143 class Parameter(Stringify):
144     """Model of an operation's parameter.
145
146     See https://github.com/wordnik/swagger-core/wiki/parameters
147     """
148
149     required_fields = ['name', 'paramType', 'dataType']
150
151     def __init__(self):
152         self.param_type = None
153         self.name = None
154         self.description = None
155         self.data_type = None
156         self.required = None
157         self.allowable_values = None
158         self.allow_multiple = None
159
160     def load(self, parameter_json, processor, context):
161         context = add_context(context, parameter_json, 'name')
162         validate_required_fields(parameter_json, self.required_fields, context)
163         self.name = parameter_json.get('name')
164         self.param_type = parameter_json.get('paramType')
165         self.description = parameter_json.get('description') or ''
166         self.data_type = parameter_json.get('dataType')
167         self.required = parameter_json.get('required') or False
168         self.allowable_values = load_allowable_values(
169             parameter_json.get('allowableValues'), context)
170         self.allow_multiple = parameter_json.get('allowMultiple') or False
171         processor.process_parameter(self, context)
172         return self
173
174     def is_type(self, other_type):
175         return self.param_type == other_type
176
177
178 class ErrorResponse(Stringify):
179     """Model of an error response.
180
181     See https://github.com/wordnik/swagger-core/wiki/errors
182     """
183
184     required_fields = ['code', 'reason']
185
186     def __init__(self):
187         self.code = None
188         self.reason = None
189
190     def load(self, err_json, processor, context):
191         context = add_context(context, err_json, 'code')
192         validate_required_fields(err_json, self.required_fields, context)
193         self.code = err_json.get('code')
194         self.reason = err_json.get('reason')
195         return self
196
197
198 class Operation(Stringify):
199     """Model of an operation on an API
200
201     See https://github.com/wordnik/swagger-core/wiki/API-Declaration#apis
202     """
203
204     required_fields = ['httpMethod', 'nickname', 'responseClass', 'summary']
205
206     def __init__(self):
207         self.http_method = None
208         self.nickname = None
209         self.response_class = None
210         self.parameters = []
211         self.summary = None
212         self.notes = None
213         self.error_responses = []
214
215     def load(self, op_json, processor, context):
216         context = add_context(context, op_json, 'nickname')
217         validate_required_fields(op_json, self.required_fields, context)
218         self.http_method = op_json.get('httpMethod')
219         self.nickname = op_json.get('nickname')
220         self.response_class = op_json.get('responseClass')
221         # Specifying WebSocket URL's is our own extension
222         self.is_websocket = op_json.get('upgrade') == 'websocket'
223         self.is_req = not self.is_websocket
224
225         if self.is_websocket:
226             self.websocket_protocol = op_json.get('websocketProtocol')
227             if self.http_method != 'GET':
228                 raise ValueError(
229                     "upgrade: websocket is only valid on GET operations")
230
231         params_json = op_json.get('parameters') or []
232         self.parameters = [
233             Parameter().load(j, processor, context) for j in params_json]
234         self.query_parameters = [
235             p for p in self.parameters if p.is_type('query')]
236         self.has_query_parameters = self.query_parameters and True
237         self.path_parameters = [
238             p for p in self.parameters if p.is_type('path')]
239         self.has_path_parameters = self.path_parameters and True
240         self.header_parameters = [
241             p for p in self.parameters if p.is_type('header')]
242         self.has_header_parameters = self.header_parameters and True
243         self.has_parameters = self.has_query_parameters or \
244             self.has_path_parameters or self.has_header_parameters
245         self.summary = op_json.get('summary')
246         self.notes = op_json.get('notes')
247         err_json = op_json.get('errorResponses') or []
248         self.error_responses = [
249             ErrorResponse().load(j, processor, context) for j in err_json]
250         processor.process_operation(self, context)
251         return self
252
253
254 class Api(Stringify):
255     """Model of a single API in an API declaration.
256
257     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
258     """
259
260     required_fields = ['path', 'operations']
261
262     def __init__(self,):
263         self.path = None
264         self.description = None
265         self.operations = []
266
267     def load(self, api_json, processor, context):
268         context = add_context(context, api_json, 'path')
269         validate_required_fields(api_json, self.required_fields, context)
270         self.path = api_json.get('path')
271         self.description = api_json.get('description')
272         op_json = api_json.get('operations')
273         self.operations = [
274             Operation().load(j, processor, context) for j in op_json]
275         self.has_websocket = \
276             filter(lambda op: op.is_websocket, self.operations) != []
277         return self
278
279
280 class Property(Stringify):
281     """Model of a Swagger property.
282
283     See https://github.com/wordnik/swagger-core/wiki/datatypes
284     """
285
286     required_fields = ['type']
287
288     def __init__(self, name):
289         self.name = name
290         self.type = None
291         self.description = None
292         self.required = None
293
294     def load(self, property_json, processor, context):
295         validate_required_fields(property_json, self.required_fields, context)
296         self.type = property_json.get('type')
297         self.description = property_json.get('description') or ''
298         self.required = property_json.get('required') or False
299         return self
300
301
302 class Model(Stringify):
303     """Model of a Swagger model.
304
305     See https://github.com/wordnik/swagger-core/wiki/datatypes
306     """
307
308     def __init__(self):
309         self.id = None
310         self.notes = None
311         self.description = None
312         self.properties = None
313
314     def load(self, id, model_json, processor, context):
315         context = add_context(context, model_json, 'id')
316         # This arrangement is required by the Swagger API spec
317         self.id = model_json.get('id')
318         if id != self.id:
319             raise SwaggerError("Model id doesn't match name", c)
320         self.description = model_json.get('description')
321         props = model_json.get('properties').items() or []
322         self.properties = [
323             Property(k).load(j, processor, context) for (k, j) in props]
324         return self
325
326
327 class ApiDeclaration(Stringify):
328     """Model class for an API Declaration.
329
330     See https://github.com/wordnik/swagger-core/wiki/API-Declaration
331     """
332
333     required_fields = [
334         'swaggerVersion', '_author', '_copyright', 'apiVersion', 'basePath',
335         'resourcePath', 'apis', 'models'
336     ]
337
338     def __init__(self):
339         self.swagger_version = None
340         self.author = None
341         self.copyright = None
342         self.api_version = None
343         self.base_path = None
344         self.resource_path = None
345         self.apis = []
346         self.models = []
347
348     def load_file(self, api_declaration_file, processor, context=[]):
349         context = context + [api_declaration_file]
350         try:
351             return self.__load_file(api_declaration_file, processor, context)
352         except SwaggerError:
353             raise
354         except Exception as e:
355             print >> sys.stderr, "Error: ", traceback.format_exc()
356             raise SwaggerError(
357                 "Error loading %s" % api_declaration_file, context, e)
358
359     def __load_file(self, api_declaration_file, processor, context):
360         with open(api_declaration_file) as fp:
361             self.load(json.load(fp), processor, context)
362
363         expected_resource_path = '/api-docs/' + \
364             os.path.basename(api_declaration_file) \
365             .replace(".json", ".{format}")
366
367         if self.resource_path != expected_resource_path:
368             print >> sys.stderr, \
369                 "%s != %s" % (self.resource_path, expected_resource_path)
370             raise SwaggerError("resourcePath has incorrect value", context)
371
372         return self
373
374     def load(self, api_decl_json, processor, context):
375         """Loads a resource from a single Swagger resource.json file.
376         """
377         # If the version doesn't match, all bets are off.
378         self.swagger_version = api_decl_json.get('swaggerVersion')
379         if self.swagger_version != SWAGGER_VERSION:
380             raise SwaggerError(
381                 "Unsupported Swagger version %s" % swagger_version, context)
382
383         validate_required_fields(api_decl_json, self.required_fields, context)
384
385         self.author = api_decl_json.get('_author')
386         self.copyright = api_decl_json.get('_copyright')
387         self.api_version = api_decl_json.get('apiVersion')
388         self.base_path = api_decl_json.get('basePath')
389         self.resource_path = api_decl_json.get('resourcePath')
390         api_json = api_decl_json.get('apis') or []
391         self.apis = [
392             Api().load(j, processor, context) for j in api_json]
393         models = api_decl_json.get('models').items() or []
394         self.models = [
395             Model().load(k, j, processor, context) for (k, j) in models]
396
397         return self
398
399
400 class ResourceApi(Stringify):
401     """Model of an API listing in the resources.json file.
402     """
403
404     required_fields = ['path', 'description']
405
406     def __init__(self):
407         self.path = None
408         self.description = None
409         self.api_declaration = None
410
411     def load(self, api_json, processor, context):
412         context = add_context(context, api_json, 'path')
413         validate_required_fields(api_json, self.required_fields, context)
414         self.path = api_json['path']
415         self.description = api_json['description']
416
417         if not self.path or self.path[0] != '/':
418             raise SwaggerError("Path must start with /", context)
419         processor.process_api(self, context)
420         return self
421
422     def load_api_declaration(self, base_dir, processor):
423         self.file = (base_dir + self.path).replace('{format}', 'json')
424         self.api_declaration = ApiDeclaration().load_file(self.file, processor)
425         processor.process_api(self, [self.file])
426
427
428 class ResourceListing(Stringify):
429     """Model of Swagger's resources.json file.
430     """
431
432     required_fields = ['apiVersion', 'basePath', 'apis']
433
434     def __init__(self):
435         self.swagger_version = None
436         self.api_version = None
437         self.base_path = None
438         self.apis = None
439
440     def load_file(self, resource_file, processor):
441         context = [resource_file]
442         try:
443             return self.__load_file(resource_file, processor, context)
444         except SwaggerError:
445             raise
446         except Exception as e:
447             print >> sys.stderr, "Error: ", traceback.format_exc()
448             raise SwaggerError(
449                 "Error loading %s" % resource_file, context, e)
450
451     def __load_file(self, resource_file, processor, context):
452         with open(resource_file) as fp:
453             return self.load(json.load(fp), processor, context)
454
455     def load(self, resources_json, processor, context):
456         # If the version doesn't match, all bets are off.
457         self.swagger_version = resources_json.get('swaggerVersion')
458         if self.swagger_version != SWAGGER_VERSION:
459             raise SwaggerError(
460                 "Unsupported Swagger version %s" % swagger_version, context)
461
462         validate_required_fields(resources_json, self.required_fields, context)
463         self.api_version = resources_json['apiVersion']
464         self.base_path = resources_json['basePath']
465         apis_json = resources_json['apis']
466         self.apis = [
467             ResourceApi().load(j, processor, context) for j in apis_json]
468         return self
469
470
471 def validate_required_fields(json, required_fields, context):
472     """Checks a JSON object for a set of required fields.
473
474     If any required field is missing, a SwaggerError is raised.
475
476     @param json: JSON object to check.
477     @param required_fields: List of required fields.
478     @param context: Current context in the API.
479     """
480     missing_fields = [f for f in required_fields if not f in json]
481
482     if missing_fields:
483         raise SwaggerError(
484             "Missing fields: %s" % ', '.join(missing_fields), context)
485
486
487 def add_context(context, json, id_field):
488     """Returns a new context with a new item added to it.
489
490     @param context: Old context.
491     @param json: Current JSON object.
492     @param id_field: Field identifying this object.
493     @return New context with additional item.
494     """
495     if not id_field in json:
496         raise SwaggerError("Missing id_field: %s" % id_field, context)
497     return context + ['%s=%s' % (id_field, str(json[id_field]))]