From 2b781287174b9335b6a77f14f87f2c28e6cfad8e Mon Sep 17 00:00:00 2001 From: Joe Gregorio Date: Thu, 8 Dec 2011 12:00:25 -0500 Subject: [PATCH] Add documentation for request and response bodies from schema information. Reviewed in http://codereview.appspot.com/5451103/ --- apiclient/discovery.py | 50 ++++--- apiclient/schema.py | 303 +++++++++++++++++++++++++++++++++++++++++ tests/data/zoo.json | 11 ++ tests/test_schema.py | 136 ++++++++++++++++++ 4 files changed, 482 insertions(+), 18 deletions(-) create mode 100644 apiclient/schema.py create mode 100644 tests/test_schema.py diff --git a/apiclient/discovery.py b/apiclient/discovery.py index ca26025..4f76ca0 100644 --- a/apiclient/discovery.py +++ b/apiclient/discovery.py @@ -39,20 +39,22 @@ try: except ImportError: from cgi import parse_qsl -from anyjson import simplejson +from apiclient.anyjson import simplejson +from apiclient.errors import HttpError +from apiclient.errors import InvalidJsonError +from apiclient.errors import MediaUploadSizeError +from apiclient.errors import UnacceptableMimeTypeError +from apiclient.errors import UnknownApiNameOrVersion +from apiclient.errors import UnknownLinkType +from apiclient.http import HttpRequest +from apiclient.http import MediaFileUpload +from apiclient.http import MediaUpload +from apiclient.model import JsonModel +from apiclient.model import RawModel +from apiclient.schema import Schemas from email.mime.multipart import MIMEMultipart from email.mime.nonmultipart import MIMENonMultipart -from errors import HttpError -from errors import InvalidJsonError -from errors import MediaUploadSizeError -from errors import UnacceptableMimeTypeError -from errors import UnknownApiNameOrVersion -from errors import UnknownLinkType -from http import HttpRequest -from http import MediaUpload -from http import MediaFileUpload -from model import JsonModel -from model import RawModel + URITEMPLATE = re.compile('{[^}]*}') VARNAME = re.compile('[a-zA-Z0-9_-]+') @@ -237,7 +239,7 @@ def build_from_document( else: future = {} auth_discovery = {} - schema = service.get('schemas', {}) + schema = Schemas(service) if model is None: features = service.get('features', []) @@ -347,6 +349,10 @@ def createResource(http, baseUrl, model, requestBuilder, 'type': 'object', 'required': True, } + if 'request' in methodDesc: + methodDesc['parameters']['body'].update(methodDesc['request']) + else: + methodDesc['parameters']['body']['type'] = 'object' if 'mediaUpload' in methodDesc: methodDesc['parameters']['media_body'] = { 'description': 'The filename of the media request body.', @@ -569,15 +575,24 @@ def createResource(http, baseUrl, model, requestBuilder, required = ' (required)' paramdesc = methodDesc['parameters'][argmap[arg]] paramdoc = paramdesc.get('description', 'A parameter') - paramtype = paramdesc.get('type', 'string') - docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required, - repeated)) + if '$ref' in paramdesc: + docs.append( + (' %s: object, %s%s%s\n The object takes the' + ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated, + schema.prettyPrintByName(paramdesc['$ref']))) + else: + paramtype = paramdesc.get('type', 'string') + docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required, + repeated)) enum = paramdesc.get('enum', []) enumDesc = paramdesc.get('enumDescriptions', []) if enum and enumDesc: docs.append(' Allowed values\n') for (name, desc) in zip(enum, enumDesc): docs.append(' %s - %s\n' % (name, desc)) + if 'response' in methodDesc: + docs.append('\nReturns:\n An object of the form\n\n ') + docs.append(schema.prettyPrintSchema(methodDesc['response'])) setattr(method, '__doc__', ''.join(docs)) setattr(theclass, methodName, method) @@ -669,7 +684,6 @@ def createResource(http, baseUrl, model, requestBuilder, setattr(theclass, methodName, methodNext) - # Add basic methods to Resource if 'methods' in resourceDesc: for methodName, methodDesc in resourceDesc['methods'].iteritems(): @@ -716,7 +730,7 @@ def createResource(http, baseUrl, model, requestBuilder, if 'response' in methodDesc: responseSchema = methodDesc['response'] if '$ref' in responseSchema: - responseSchema = schema[responseSchema['$ref']] + responseSchema = schema.get(responseSchema['$ref']) hasNextPageToken = 'nextPageToken' in responseSchema.get('properties', {}) hasPageToken = 'pageToken' in methodDesc.get('parameters', {}) diff --git a/apiclient/schema.py b/apiclient/schema.py new file mode 100644 index 0000000..cd5a7cb --- /dev/null +++ b/apiclient/schema.py @@ -0,0 +1,303 @@ +# Copyright (C) 2010 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Schema processing for discovery based APIs + +Schemas holds an APIs discovery schemas. It can return those schema as +deserialized JSON objects, or pretty print them as prototype objects that +conform to the schema. + +For example, given the schema: + + schema = \"\"\"{ + "Foo": { + "type": "object", + "properties": { + "etag": { + "type": "string", + "description": "ETag of the collection." + }, + "kind": { + "type": "string", + "description": "Type of the collection ('calendar#acl').", + "default": "calendar#acl" + }, + "nextPageToken": { + "type": "string", + "description": "Token used to access the next + page of this result. Omitted if no further results are available." + } + } + } + }\"\"\" + + s = Schemas(schema) + print s.prettyPrintByName('Foo') + + Produces the following output: + + { + "nextPageToken": "A String", # Token used to access the + # next page of this result. Omitted if no further results are available. + "kind": "A String", # Type of the collection ('calendar#acl'). + "etag": "A String", # ETag of the collection. + }, + +The constructor takes a discovery document in which to look up named schema. +""" + +# TODO(jcgregorio) support format, enum, minimum, maximum + +__author__ = 'jcgregorio@google.com (Joe Gregorio)' + +import copy +from apiclient.anyjson import simplejson + + +class Schemas(object): + """Schemas for an API.""" + + def __init__(self, discovery): + """Constructor. + + Args: + discovery: object, Deserialized discovery document from which we pull + out the named schema. + """ + self.schemas = discovery.get('schemas', {}) + + # Cache of pretty printed schemas. + self.pretty = {} + + def _prettyPrintByName(self, name, seen=None, dent=0): + """Get pretty printed object prototype from the schema name. + + Args: + name: string, Name of schema in the discovery document. + seen: list of string, Names of schema already seen. Used to handle + recursive definitions. + + Returns: + string, A string that contains a prototype object with + comments that conforms to the given schema. + """ + if seen is None: + seen = [] + + if name in seen: + # Do not fall into an infinite loop over recursive definitions. + return '# Object with schema name: %s' % name + seen.append(name) + + if name not in self.pretty: + self.pretty[name] = _SchemaToStruct(self.schemas[name], + seen, dent).to_str(self._prettyPrintByName) + + seen.pop() + + return self.pretty[name] + + def prettyPrintByName(self, name): + """Get pretty printed object prototype from the schema name. + + Args: + name: string, Name of schema in the discovery document. + + Returns: + string, A string that contains a prototype object with + comments that conforms to the given schema. + """ + # Return with trailing comma and newline removed. + return self._prettyPrintByName(name, seen=[], dent=1)[:-2] + + def _prettyPrintSchema(self, schema, seen=None, dent=0): + """Get pretty printed object prototype of schema. + + Args: + schema: object, Parsed JSON schema. + seen: list of string, Names of schema already seen. Used to handle + recursive definitions. + + Returns: + string, A string that contains a prototype object with + comments that conforms to the given schema. + """ + if seen is None: + seen = [] + + return _SchemaToStruct(schema, seen, dent).to_str(self._prettyPrintByName) + + def prettyPrintSchema(self, schema): + """Get pretty printed object prototype of schema. + + Args: + schema: object, Parsed JSON schema. + + Returns: + string, A string that contains a prototype object with + comments that conforms to the given schema. + """ + # Return with trailing comma and newline removed. + return self._prettyPrintSchema(schema, dent=1)[:-2] + + def get(self, name): + """Get deserialized JSON schema from the schema name. + + Args: + name: string, Schema name. + """ + return self.schemas[name] + + +class _SchemaToStruct(object): + """Convert schema to a prototype object.""" + + def __init__(self, schema, seen, dent=0): + """Constructor. + + Args: + schema: object, Parsed JSON schema. + seen: list, List of names of schema already seen while parsing. Used to + handle recursive definitions. + dent: int, Initial indentation depth. + """ + # The result of this parsing kept as list of strings. + self.value = [] + + # The final value of the parsing. + self.string = None + + # The parsed JSON schema. + self.schema = schema + + # Indentation level. + self.dent = dent + + # Method that when called returns a prototype object for the schema with + # the given name. + self.from_cache = None + + # List of names of schema already seen while parsing. + self.seen = seen + + def emit(self, text): + """Add text as a line to the output. + + Args: + text: string, Text to output. + """ + self.value.extend([" " * self.dent, text, '\n']) + + def emitBegin(self, text): + """Add text to the output, but with no line terminator. + + Args: + text: string, Text to output. + """ + self.value.extend([" " * self.dent, text]) + + def emitEnd(self, text, comment): + """Add text and comment to the output with line terminator. + + Args: + text: string, Text to output. + comment: string, Python comment. + """ + if comment: + divider = '\n' + ' ' * (self.dent + 2) + '# ' + lines = comment.splitlines() + lines = [x.rstrip() for x in lines] + comment = divider.join(lines) + self.value.extend([text, ' # ', comment, '\n']) + else: + self.value.extend([text, '\n']) + + def indent(self): + """Increase indentation level.""" + self.dent += 1 + + def undent(self): + """Decrease indentation level.""" + self.dent -= 1 + + def _to_str_impl(self, schema): + """Prototype object based on the schema, in Python code with comments. + + Args: + schema: object, Parsed JSON schema file. + + Returns: + Prototype object based on the schema, in Python code with comments. + """ + stype = schema.get('type') + if stype == 'object': + self.emitEnd('{', schema.get('description', '')) + self.indent() + for pname, pschema in schema.get('properties', {}).iteritems(): + self.emitBegin('"%s": ' % pname) + self._to_str_impl(pschema) + self.undent() + self.emit('},') + elif '$ref' in schema: + schemaName = schema['$ref'] + description = schema.get('description', '') + s = self.from_cache(schemaName, self.seen) + parts = s.splitlines() + self.emitEnd(parts[0], description) + for line in parts[1:]: + self.emit(line.rstrip()) + elif stype == 'boolean': + value = schema.get('default', 'True or False') + self.emitEnd('%s,' % str(value), schema.get('description', '')) + elif stype == 'string': + value = schema.get('default', 'A String') + self.emitEnd('"%s",' % value, schema.get('description', '')) + elif stype == 'integer': + value = schema.get('default', 42) + self.emitEnd('%d,' % value, schema.get('description', '')) + elif stype == 'number': + value = schema.get('default', 3.14) + self.emitEnd('%f,' % value, schema.get('description', '')) + elif stype == 'null': + self.emitEnd('None,', schema.get('description', '')) + elif stype == 'any': + self.emitEnd('"",', schema.get('description', '')) + elif stype == 'array': + self.emitEnd('[', schema.get('description')) + self.indent() + self.emitBegin('') + self._to_str_impl(schema['items']) + self.undent() + self.emit('],') + else: + self.emit('Unknown type! %s' % stype) + self.emitEnd('', '') + + self.string = ''.join(self.value) + return self.string + + def to_str(self, from_cache): + """Prototype object based on the schema, in Python code with comments. + + Args: + from_cache: callable(name, seen), Callable that retrieves an object + prototype for a schema with the given name. Seen is a list of schema + names already seen as we recursively descend the schema definition. + + Returns: + Prototype object based on the schema, in Python code with comments. + The lines of the code will all be properly indented. + """ + self.from_cache = from_cache + return self._to_str_impl(self.schema) diff --git a/tests/data/zoo.json b/tests/data/zoo.json index 0bb0a79..9ead92a 100644 --- a/tests/data/zoo.json +++ b/tests/data/zoo.json @@ -89,6 +89,17 @@ "doubleVal": { "type": "number" }, + "nullVal": { + "type": "null" + }, + "booleanVal": { + "type": "boolean", + "description": "True or False." + }, + "anyVal": { + "type": "any", + "description": "Anything will do." + }, "enumVal": { "type": "string" }, diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..7430ac7 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,136 @@ +# Copyright 2011 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for apiclient.schema.""" + +__author__ = 'jcgregorio@google.com (Joe Gregorio)' + +import os +import unittest +import StringIO + +from apiclient.anyjson import simplejson +from apiclient.schema import Schemas + + +DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') + + +def datafile(filename): + return os.path.join(DATA_DIR, filename) + +LOAD_FEED = """{ + "items": [ + { + "longVal": 42, + "kind": "zoo#loadValue", + "enumVal": "A String", + "anyVal": "", # Anything will do. + "nullVal": None, + "stringVal": "A String", + "doubleVal": 3.140000, + "booleanVal": True or False, # True or False. + }, + ], + "kind": "zoo#loadFeed", + }""" + +class SchemasTest(unittest.TestCase): + def setUp(self): + f = file(datafile('zoo.json')) + discovery = f.read() + f.close() + discovery = simplejson.loads(discovery) + self.sc = Schemas(discovery) + + def test_basic_formatting(self): + self.assertEqual(LOAD_FEED, self.sc.prettyPrintByName('LoadFeed')) + + def test_empty_edge_case(self): + self.assertTrue('Unknown type' in self.sc.prettyPrintSchema({})) + + def test_simple_object(self): + self.assertEqual({}, eval(self.sc.prettyPrintSchema({'type': 'object'}))) + + def test_string(self): + self.assertEqual(type(""), type(eval(self.sc.prettyPrintSchema({'type': + 'string'})))) + + def test_integer(self): + self.assertEqual(type(20), type(eval(self.sc.prettyPrintSchema({'type': + 'integer'})))) + + def test_number(self): + self.assertEqual(type(1.2), type(eval(self.sc.prettyPrintSchema({'type': + 'number'})))) + + def test_boolean(self): + self.assertEqual(type(True), type(eval(self.sc.prettyPrintSchema({'type': + 'boolean'})))) + + def test_string_default(self): + self.assertEqual('foo', eval(self.sc.prettyPrintSchema({'type': + 'string', 'default': 'foo'}))) + + def test_integer_default(self): + self.assertEqual(20, eval(self.sc.prettyPrintSchema({'type': + 'integer', 'default': 20}))) + + def test_number_default(self): + self.assertEqual(1.2, eval(self.sc.prettyPrintSchema({'type': + 'number', 'default': 1.2}))) + + def test_boolean_default(self): + self.assertEqual(False, eval(self.sc.prettyPrintSchema({'type': + 'boolean', 'default': False}))) + + def test_null(self): + self.assertEqual(None, eval(self.sc.prettyPrintSchema({'type': 'null'}))) + + def test_any(self): + self.assertEqual('', eval(self.sc.prettyPrintSchema({'type': 'any'}))) + + def test_array(self): + self.assertEqual([{}], eval(self.sc.prettyPrintSchema({'type': 'array', + 'items': {'type': 'object'}}))) + + def test_nested_references(self): + feed = { + 'items': [ { + 'photo': { + 'hash': 'A String', + 'hashAlgorithm': 'A String', + 'filename': 'A String', + 'type': 'A String', + 'size': 42 + }, + 'kind': 'zoo#animal', + 'etag': 'A String', + 'name': 'A String' + } + ], + 'kind': 'zoo#animalFeed', + 'etag': 'A String' + } + + self.assertEqual(feed, eval(self.sc.prettyPrintByName('AnimalFeed'))) + + def test_unknown_name(self): + self.assertRaises(KeyError, + self.sc.prettyPrintByName, 'UknownSchemaThing') + + +if __name__ == '__main__': + unittest.main() +