1# Copyright 2014 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Schema processing for discovery based APIs
16
17Schemas holds an APIs discovery schemas. It can return those schema as
18deserialized JSON objects, or pretty print them as prototype objects that
19conform to the schema.
20
21For example, given the schema:
22
23 schema = \"\"\"{
24   "Foo": {
25    "type": "object",
26    "properties": {
27     "etag": {
28      "type": "string",
29      "description": "ETag of the collection."
30     },
31     "kind": {
32      "type": "string",
33      "description": "Type of the collection ('calendar#acl').",
34      "default": "calendar#acl"
35     },
36     "nextPageToken": {
37      "type": "string",
38      "description": "Token used to access the next
39         page of this result. Omitted if no further results are available."
40     }
41    }
42   }
43 }\"\"\"
44
45 s = Schemas(schema)
46 print s.prettyPrintByName('Foo')
47
48 Produces the following output:
49
50  {
51   "nextPageToken": "A String", # Token used to access the
52       # next page of this result. Omitted if no further results are available.
53   "kind": "A String", # Type of the collection ('calendar#acl').
54   "etag": "A String", # ETag of the collection.
55  },
56
57The constructor takes a discovery document in which to look up named schema.
58"""
59from __future__ import absolute_import
60import six
61
62# TODO(jcgregorio) support format, enum, minimum, maximum
63
64__author__ = 'jcgregorio@google.com (Joe Gregorio)'
65
66import copy
67
68from oauth2client import util
69
70
71class Schemas(object):
72  """Schemas for an API."""
73
74  def __init__(self, discovery):
75    """Constructor.
76
77    Args:
78      discovery: object, Deserialized discovery document from which we pull
79        out the named schema.
80    """
81    self.schemas = discovery.get('schemas', {})
82
83    # Cache of pretty printed schemas.
84    self.pretty = {}
85
86  @util.positional(2)
87  def _prettyPrintByName(self, name, seen=None, dent=0):
88    """Get pretty printed object prototype from the schema name.
89
90    Args:
91      name: string, Name of schema in the discovery document.
92      seen: list of string, Names of schema already seen. Used to handle
93        recursive definitions.
94
95    Returns:
96      string, A string that contains a prototype object with
97        comments that conforms to the given schema.
98    """
99    if seen is None:
100      seen = []
101
102    if name in seen:
103      # Do not fall into an infinite loop over recursive definitions.
104      return '# Object with schema name: %s' % name
105    seen.append(name)
106
107    if name not in self.pretty:
108      self.pretty[name] = _SchemaToStruct(self.schemas[name],
109          seen, dent=dent).to_str(self._prettyPrintByName)
110
111    seen.pop()
112
113    return self.pretty[name]
114
115  def prettyPrintByName(self, name):
116    """Get pretty printed object prototype from the schema name.
117
118    Args:
119      name: string, Name of schema in the discovery document.
120
121    Returns:
122      string, A string that contains a prototype object with
123        comments that conforms to the given schema.
124    """
125    # Return with trailing comma and newline removed.
126    return self._prettyPrintByName(name, seen=[], dent=1)[:-2]
127
128  @util.positional(2)
129  def _prettyPrintSchema(self, schema, seen=None, dent=0):
130    """Get pretty printed object prototype of schema.
131
132    Args:
133      schema: object, Parsed JSON schema.
134      seen: list of string, Names of schema already seen. Used to handle
135        recursive definitions.
136
137    Returns:
138      string, A string that contains a prototype object with
139        comments that conforms to the given schema.
140    """
141    if seen is None:
142      seen = []
143
144    return _SchemaToStruct(schema, seen, dent=dent).to_str(self._prettyPrintByName)
145
146  def prettyPrintSchema(self, schema):
147    """Get pretty printed object prototype of schema.
148
149    Args:
150      schema: object, Parsed JSON schema.
151
152    Returns:
153      string, A string that contains a prototype object with
154        comments that conforms to the given schema.
155    """
156    # Return with trailing comma and newline removed.
157    return self._prettyPrintSchema(schema, dent=1)[:-2]
158
159  def get(self, name):
160    """Get deserialized JSON schema from the schema name.
161
162    Args:
163      name: string, Schema name.
164    """
165    return self.schemas[name]
166
167
168class _SchemaToStruct(object):
169  """Convert schema to a prototype object."""
170
171  @util.positional(3)
172  def __init__(self, schema, seen, dent=0):
173    """Constructor.
174
175    Args:
176      schema: object, Parsed JSON schema.
177      seen: list, List of names of schema already seen while parsing. Used to
178        handle recursive definitions.
179      dent: int, Initial indentation depth.
180    """
181    # The result of this parsing kept as list of strings.
182    self.value = []
183
184    # The final value of the parsing.
185    self.string = None
186
187    # The parsed JSON schema.
188    self.schema = schema
189
190    # Indentation level.
191    self.dent = dent
192
193    # Method that when called returns a prototype object for the schema with
194    # the given name.
195    self.from_cache = None
196
197    # List of names of schema already seen while parsing.
198    self.seen = seen
199
200  def emit(self, text):
201    """Add text as a line to the output.
202
203    Args:
204      text: string, Text to output.
205    """
206    self.value.extend(["  " * self.dent, text, '\n'])
207
208  def emitBegin(self, text):
209    """Add text to the output, but with no line terminator.
210
211    Args:
212      text: string, Text to output.
213      """
214    self.value.extend(["  " * self.dent, text])
215
216  def emitEnd(self, text, comment):
217    """Add text and comment to the output with line terminator.
218
219    Args:
220      text: string, Text to output.
221      comment: string, Python comment.
222    """
223    if comment:
224      divider = '\n' + '  ' * (self.dent + 2) + '# '
225      lines = comment.splitlines()
226      lines = [x.rstrip() for x in lines]
227      comment = divider.join(lines)
228      self.value.extend([text, ' # ', comment, '\n'])
229    else:
230      self.value.extend([text, '\n'])
231
232  def indent(self):
233    """Increase indentation level."""
234    self.dent += 1
235
236  def undent(self):
237    """Decrease indentation level."""
238    self.dent -= 1
239
240  def _to_str_impl(self, schema):
241    """Prototype object based on the schema, in Python code with comments.
242
243    Args:
244      schema: object, Parsed JSON schema file.
245
246    Returns:
247      Prototype object based on the schema, in Python code with comments.
248    """
249    stype = schema.get('type')
250    if stype == 'object':
251      self.emitEnd('{', schema.get('description', ''))
252      self.indent()
253      if 'properties' in schema:
254        for pname, pschema in six.iteritems(schema.get('properties', {})):
255          self.emitBegin('"%s": ' % pname)
256          self._to_str_impl(pschema)
257      elif 'additionalProperties' in schema:
258        self.emitBegin('"a_key": ')
259        self._to_str_impl(schema['additionalProperties'])
260      self.undent()
261      self.emit('},')
262    elif '$ref' in schema:
263      schemaName = schema['$ref']
264      description = schema.get('description', '')
265      s = self.from_cache(schemaName, seen=self.seen)
266      parts = s.splitlines()
267      self.emitEnd(parts[0], description)
268      for line in parts[1:]:
269        self.emit(line.rstrip())
270    elif stype == 'boolean':
271      value = schema.get('default', 'True or False')
272      self.emitEnd('%s,' % str(value), schema.get('description', ''))
273    elif stype == 'string':
274      value = schema.get('default', 'A String')
275      self.emitEnd('"%s",' % str(value), schema.get('description', ''))
276    elif stype == 'integer':
277      value = schema.get('default', '42')
278      self.emitEnd('%s,' % str(value), schema.get('description', ''))
279    elif stype == 'number':
280      value = schema.get('default', '3.14')
281      self.emitEnd('%s,' % str(value), schema.get('description', ''))
282    elif stype == 'null':
283      self.emitEnd('None,', schema.get('description', ''))
284    elif stype == 'any':
285      self.emitEnd('"",', schema.get('description', ''))
286    elif stype == 'array':
287      self.emitEnd('[', schema.get('description'))
288      self.indent()
289      self.emitBegin('')
290      self._to_str_impl(schema['items'])
291      self.undent()
292      self.emit('],')
293    else:
294      self.emit('Unknown type! %s' % stype)
295      self.emitEnd('', '')
296
297    self.string = ''.join(self.value)
298    return self.string
299
300  def to_str(self, from_cache):
301    """Prototype object based on the schema, in Python code with comments.
302
303    Args:
304      from_cache: callable(name, seen), Callable that retrieves an object
305         prototype for a schema with the given name. Seen is a list of schema
306         names already seen as we recursively descend the schema definition.
307
308    Returns:
309      Prototype object based on the schema, in Python code with comments.
310      The lines of the code will all be properly indented.
311    """
312    self.from_cache = from_cache
313    return self._to_str_impl(self.schema)
314