blob: 166028828b23bc6cdd3f58da1fe8a5202bd4b17d [file] [log] [blame]
Joe Gregorio48d361f2010-08-18 13:19:21 -04001# Copyright (C) 2010 Google Inc.
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"""Client for discovery based APIs
16
17A client library for Google's discovery
18based APIs.
19"""
20
21__author__ = 'jcgregorio@google.com (Joe Gregorio)'
22
23
24import httplib2
ade@google.com850cf552010-08-20 23:24:56 +010025import logging
Joe Gregorio6d5e94f2010-08-25 23:49:30 -040026import os
Joe Gregorio48d361f2010-08-18 13:19:21 -040027import re
Joe Gregorio48d361f2010-08-18 13:19:21 -040028import uritemplate
Joe Gregoriofe695fb2010-08-30 12:04:04 -040029import urllib
Joe Gregorio6d5e94f2010-08-25 23:49:30 -040030import urlparse
ade@google.comc5eb46f2010-09-27 23:35:39 +010031try:
32 from urlparse import parse_qsl
33except ImportError:
34 from cgi import parse_qsl
Joe Gregorio5f087cf2010-09-20 16:08:07 -040035from apiclient.http import HttpRequest
Joe Gregoriof658e322010-10-11 15:40:31 -040036from apiclient.json import simplejson
Joe Gregorio48d361f2010-08-18 13:19:21 -040037
Joe Gregoriobc2ff9b2010-11-08 09:20:48 -050038URITEMPLATE = re.compile('{[^}]*}')
39VARNAME = re.compile('[a-zA-Z0-9_-]+')
40
Tom Miller05cd4f52010-10-06 11:09:12 -070041class Error(Exception):
42 """Base error for this module."""
Joe Gregorio41cf7972010-08-18 15:21:06 -040043 pass
44
Joe Gregorio3bbbf662010-08-30 16:41:53 -040045
Tom Miller05cd4f52010-10-06 11:09:12 -070046class HttpError(Error):
47 """HTTP data was invalid or unexpected."""
Joe Gregorio0c73a672010-10-14 08:27:59 -040048 def __init__(self, resp, detail):
49 self.resp = resp
50 self.detail = detail
51 def __str__(self):
52 return self.detail
Joe Gregorio6d5e94f2010-08-25 23:49:30 -040053
Tom Miller05cd4f52010-10-06 11:09:12 -070054
55class UnknownLinkType(Error):
56 """Link type unknown or unexpected."""
57 pass
58
59
Joe Gregorio2379ecc2010-10-26 10:51:28 -040060DISCOVERY_URI = ('https://www.googleapis.com/discovery/v0.2beta1/describe/'
61 '{api}/{apiVersion}')
Joe Gregorio48d361f2010-08-18 13:19:21 -040062
63
Joe Gregorio48d361f2010-08-18 13:19:21 -040064def key2param(key):
65 """
66 max-results -> max_results
67 """
68 result = []
69 key = list(key)
70 if not key[0].isalpha():
71 result.append('x')
72 for c in key:
73 if c.isalnum():
74 result.append(c)
75 else:
76 result.append('_')
77
78 return ''.join(result)
79
80
81class JsonModel(object):
Joe Gregorio41cf7972010-08-18 15:21:06 -040082
ade@google.com850cf552010-08-20 23:24:56 +010083 def request(self, headers, path_params, query_params, body_value):
84 query = self.build_query(query_params)
Joe Gregorioba9ea7f2010-08-19 15:49:04 -040085 headers['accept'] = 'application/json'
Joe Gregorio6b2e5dc2010-09-14 15:11:03 -040086 if 'user-agent' in headers:
87 headers['user-agent'] += ' '
88 else:
89 headers['user-agent'] = ''
Joe Gregorioe9369582010-09-14 15:17:28 -040090 headers['user-agent'] += 'google-api-python-client/1.0'
ade@google.com850cf552010-08-20 23:24:56 +010091 if body_value is None:
92 return (headers, path_params, query, None)
Joe Gregorio48d361f2010-08-18 13:19:21 -040093 else:
Joe Gregorioba9ea7f2010-08-19 15:49:04 -040094 headers['content-type'] = 'application/json'
Joe Gregorio913e70d2010-11-05 15:38:23 -040095 return (headers, path_params, query, simplejson.dumps(body_value))
ade@google.com850cf552010-08-20 23:24:56 +010096
97 def build_query(self, params):
jcgregorio@google.come3c8b6d2010-10-07 19:34:54 -040098 params.update({'alt': 'json'})
Joe Gregoriofe695fb2010-08-30 12:04:04 -040099 astuples = []
100 for key, value in params.iteritems():
101 if getattr(value, 'encode', False) and callable(value.encode):
102 value = value.encode('utf-8')
103 astuples.append((key, value))
104 return '?' + urllib.urlencode(astuples)
Joe Gregorio48d361f2010-08-18 13:19:21 -0400105
106 def response(self, resp, content):
Joe Gregorio41cf7972010-08-18 15:21:06 -0400107 # Error handling is TBD, for example, do we retry
108 # for some operation/error combinations?
Joe Gregorio48d361f2010-08-18 13:19:21 -0400109 if resp.status < 300:
ade@google.com9d821b02010-10-11 03:33:51 -0700110 if resp.status == 204:
111 # A 204: No Content response should be treated differently to all the other success states
112 return simplejson.loads('{}')
Joe Gregorio78a508d2010-10-26 16:36:36 -0400113 body = simplejson.loads(content)
114 if isinstance(body, dict) and 'data' in body:
115 body = body['data']
116 return body
Joe Gregorio48d361f2010-08-18 13:19:21 -0400117 else:
ade@google.com850cf552010-08-20 23:24:56 +0100118 logging.debug('Content from bad request was: %s' % content)
Joe Gregorio0c73a672010-10-14 08:27:59 -0400119 if resp.get('content-type', '').startswith('application/json'):
120 raise HttpError(resp, simplejson.loads(content)['error'])
Joe Gregorio48d361f2010-08-18 13:19:21 -0400121 else:
Joe Gregorio0c73a672010-10-14 08:27:59 -0400122 raise HttpError(resp, '%d %s' % (resp.status, resp.reason))
Joe Gregorio48d361f2010-08-18 13:19:21 -0400123
124
Joe Gregorioc204b642010-09-21 12:01:23 -0400125def build(serviceName, version, http=None,
Joe Gregorio00cf1d92010-09-27 09:22:03 -0400126 discoveryServiceUrl=DISCOVERY_URI, developerKey=None, model=JsonModel()):
Joe Gregorio48d361f2010-08-18 13:19:21 -0400127 params = {
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400128 'api': serviceName,
Joe Gregorio48d361f2010-08-18 13:19:21 -0400129 'apiVersion': version
130 }
ade@google.com850cf552010-08-20 23:24:56 +0100131
Joe Gregorioc204b642010-09-21 12:01:23 -0400132 if http is None:
133 http = httplib2.Http()
ade@google.com850cf552010-08-20 23:24:56 +0100134 requested_url = uritemplate.expand(discoveryServiceUrl, params)
135 logging.info('URL being requested: %s' % requested_url)
136 resp, content = http.request(requested_url)
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400137 service = simplejson.loads(content)
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400138
Joe Gregorio3bbbf662010-08-30 16:41:53 -0400139 fn = os.path.join(os.path.dirname(__file__), "contrib",
140 serviceName, "future.json")
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400141 try:
142 f = file(fn, "r")
143 d = simplejson.load(f)
144 f.close()
145 future = d['resources']
146 auth_discovery = d['auth']
147 except IOError:
148 future = {}
149 auth_discovery = {}
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400150
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400151 base = urlparse.urljoin(discoveryServiceUrl, service['restBasePath'])
Joe Gregorio48d361f2010-08-18 13:19:21 -0400152 resources = service['resources']
153
154 class Service(object):
155 """Top level interface for a service"""
156
157 def __init__(self, http=http):
158 self._http = http
159 self._baseUrl = base
160 self._model = model
Joe Gregorio00cf1d92010-09-27 09:22:03 -0400161 self._developerKey = developerKey
Joe Gregorio48d361f2010-08-18 13:19:21 -0400162
Joe Gregorioa2f56e72010-09-09 15:15:56 -0400163 def auth_discovery(self):
164 return auth_discovery
165
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400166 def createMethod(theclass, methodName, methodDesc, futureDesc):
Joe Gregorio41cf7972010-08-18 15:21:06 -0400167
Joe Gregorio59cd9512010-10-04 12:46:46 -0400168 def method(self):
Joe Gregorio48d361f2010-08-18 13:19:21 -0400169 return createResource(self._http, self._baseUrl, self._model,
Joe Gregorio00cf1d92010-09-27 09:22:03 -0400170 methodName, self._developerKey, methodDesc, futureDesc)
Joe Gregorio48d361f2010-08-18 13:19:21 -0400171
172 setattr(method, '__doc__', 'A description of how to use this function')
Joe Gregorio20cfcda2010-10-26 11:58:08 -0400173 setattr(method, '__is_resource__', True)
Joe Gregorio48d361f2010-08-18 13:19:21 -0400174 setattr(theclass, methodName, method)
175
176 for methodName, methodDesc in resources.iteritems():
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400177 createMethod(Service, methodName, methodDesc, future.get(methodName, {}))
Joe Gregorio48d361f2010-08-18 13:19:21 -0400178 return Service()
179
180
Joe Gregorio00cf1d92010-09-27 09:22:03 -0400181def createResource(http, baseUrl, model, resourceName, developerKey,
182 resourceDesc, futureDesc):
Joe Gregorio48d361f2010-08-18 13:19:21 -0400183
184 class Resource(object):
185 """A class for interacting with a resource."""
186
187 def __init__(self):
188 self._http = http
189 self._baseUrl = baseUrl
190 self._model = model
Joe Gregorio00cf1d92010-09-27 09:22:03 -0400191 self._developerKey = developerKey
Joe Gregorio48d361f2010-08-18 13:19:21 -0400192
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400193 def createMethod(theclass, methodName, methodDesc, futureDesc):
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400194 pathUrl = methodDesc['restPath']
Joe Gregorio48d361f2010-08-18 13:19:21 -0400195 pathUrl = re.sub(r'\{', r'{+', pathUrl)
196 httpMethod = methodDesc['httpMethod']
Joe Gregorio21f11672010-08-18 17:23:17 -0400197
ade@google.com850cf552010-08-20 23:24:56 +0100198 argmap = {}
199 if httpMethod in ['PUT', 'POST']:
200 argmap['body'] = 'body'
201
202
203 required_params = [] # Required parameters
204 pattern_params = {} # Parameters that must match a regex
205 query_params = [] # Parameters that will be used in the query string
206 path_params = {} # Parameters that will be used in the base URL
Joe Gregorio4292c6e2010-09-09 14:32:43 -0400207 if 'parameters' in methodDesc:
208 for arg, desc in methodDesc['parameters'].iteritems():
209 param = key2param(arg)
210 argmap[param] = arg
Joe Gregorio21f11672010-08-18 17:23:17 -0400211
Joe Gregorio4292c6e2010-09-09 14:32:43 -0400212 if desc.get('pattern', ''):
213 pattern_params[param] = desc['pattern']
214 if desc.get('required', False):
215 required_params.append(param)
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400216 if desc.get('restParameterType') == 'query':
Joe Gregorio4292c6e2010-09-09 14:32:43 -0400217 query_params.append(param)
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400218 if desc.get('restParameterType') == 'path':
Joe Gregorio4292c6e2010-09-09 14:32:43 -0400219 path_params[param] = param
Joe Gregorio48d361f2010-08-18 13:19:21 -0400220
Joe Gregoriobc2ff9b2010-11-08 09:20:48 -0500221 for match in URITEMPLATE.finditer(pathUrl):
222 for namematch in VARNAME.finditer(match.group(0)):
223 name = key2param(namematch.group(0))
224 path_params[name] = name
225 if name in query_params:
226 query_params.remove(name)
227
Joe Gregorio48d361f2010-08-18 13:19:21 -0400228 def method(self, **kwargs):
229 for name in kwargs.iterkeys():
230 if name not in argmap:
231 raise TypeError('Got an unexpected keyword argument "%s"' % name)
Joe Gregorio21f11672010-08-18 17:23:17 -0400232
ade@google.com850cf552010-08-20 23:24:56 +0100233 for name in required_params:
Joe Gregoriofbf9d0d2010-08-18 16:50:47 -0400234 if name not in kwargs:
235 raise TypeError('Missing required parameter "%s"' % name)
Joe Gregorio21f11672010-08-18 17:23:17 -0400236
ade@google.com850cf552010-08-20 23:24:56 +0100237 for name, regex in pattern_params.iteritems():
Joe Gregorio21f11672010-08-18 17:23:17 -0400238 if name in kwargs:
239 if re.match(regex, kwargs[name]) is None:
Joe Gregorio3bbbf662010-08-30 16:41:53 -0400240 raise TypeError(
241 'Parameter "%s" value "%s" does not match the pattern "%s"' %
242 (name, kwargs[name], regex))
Joe Gregorio21f11672010-08-18 17:23:17 -0400243
ade@google.com850cf552010-08-20 23:24:56 +0100244 actual_query_params = {}
245 actual_path_params = {}
Joe Gregorio21f11672010-08-18 17:23:17 -0400246 for key, value in kwargs.iteritems():
ade@google.com850cf552010-08-20 23:24:56 +0100247 if key in query_params:
248 actual_query_params[argmap[key]] = value
249 if key in path_params:
250 actual_path_params[argmap[key]] = value
251 body_value = kwargs.get('body', None)
Joe Gregorio21f11672010-08-18 17:23:17 -0400252
Joe Gregorio00cf1d92010-09-27 09:22:03 -0400253 if self._developerKey:
254 actual_query_params['key'] = self._developerKey
255
Joe Gregorio48d361f2010-08-18 13:19:21 -0400256 headers = {}
Joe Gregorio3bbbf662010-08-30 16:41:53 -0400257 headers, params, query, body = self._model.request(headers,
258 actual_path_params, actual_query_params, body_value)
Joe Gregorio48d361f2010-08-18 13:19:21 -0400259
ade@google.com7ebb2ca2010-09-29 16:42:15 +0100260 # TODO(ade) This exists to fix a bug in V1 of the Buzz discovery document.
261 # Base URLs should not contain any path elements. If they do then urlparse.urljoin will strip them out
262 # This results in an incorrect URL which returns a 404
263 url_result = urlparse.urlsplit(self._baseUrl)
264 new_base_url = url_result.scheme + '://' + url_result.netloc
265
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400266 expanded_url = uritemplate.expand(pathUrl, params)
ade@google.com7ebb2ca2010-09-29 16:42:15 +0100267 url = urlparse.urljoin(new_base_url, url_result.path + expanded_url + query)
Joe Gregoriofbf9d0d2010-08-18 16:50:47 -0400268
ade@google.com850cf552010-08-20 23:24:56 +0100269 logging.info('URL being requested: %s' % url)
Joe Gregorio5f087cf2010-09-20 16:08:07 -0400270 return HttpRequest(self._http, url, method=httpMethod, body=body,
271 headers=headers, postproc=self._model.response)
Joe Gregorio48d361f2010-08-18 13:19:21 -0400272
273 docs = ['A description of how to use this function\n\n']
274 for arg in argmap.iterkeys():
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400275 required = ""
276 if arg in required_params:
277 required = " (required)"
278 docs.append('%s - A parameter%s\n' % (arg, required))
Joe Gregorio48d361f2010-08-18 13:19:21 -0400279
280 setattr(method, '__doc__', ''.join(docs))
281 setattr(theclass, methodName, method)
282
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400283 def createNextMethod(theclass, methodName, methodDesc):
284
285 def method(self, previous):
286 """
287 Takes a single argument, 'body', which is the results
288 from the last call, and returns the next set of items
289 in the collection.
290
291 Returns None if there are no more items in
292 the collection.
293 """
294 if methodDesc['type'] != 'uri':
295 raise UnknownLinkType(methodDesc['type'])
296
297 try:
298 p = previous
299 for key in methodDesc['location']:
300 p = p[key]
301 url = p
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400302 except (KeyError, TypeError):
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400303 return None
304
Joe Gregorio00cf1d92010-09-27 09:22:03 -0400305 if self._developerKey:
306 parsed = list(urlparse.urlparse(url))
ade@google.comc5eb46f2010-09-27 23:35:39 +0100307 q = parse_qsl(parsed[4])
Joe Gregorio00cf1d92010-09-27 09:22:03 -0400308 q.append(('key', self._developerKey))
309 parsed[4] = urllib.urlencode(q)
310 url = urlparse.urlunparse(parsed)
311
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400312 headers = {}
313 headers, params, query, body = self._model.request(headers, {}, {}, None)
314
315 logging.info('URL being requested: %s' % url)
316 resp, content = self._http.request(url, method='GET', headers=headers)
317
Joe Gregorio5f087cf2010-09-20 16:08:07 -0400318 return HttpRequest(self._http, url, method='GET',
319 headers=headers, postproc=self._model.response)
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400320
321 setattr(theclass, methodName, method)
322
323 # Add basic methods to Resource
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400324 if 'methods' in resourceDesc:
325 for methodName, methodDesc in resourceDesc['methods'].iteritems():
326 if futureDesc:
327 future = futureDesc['methods'].get(methodName, {})
328 else:
329 future = None
330 createMethod(Resource, methodName, methodDesc, future)
331
332 # Add in nested resources
333 if 'resources' in resourceDesc:
334 def createMethod(theclass, methodName, methodDesc, futureDesc):
335
336 def method(self):
337 return createResource(self._http, self._baseUrl, self._model,
338 methodName, self._developerKey, methodDesc, futureDesc)
339
340 setattr(method, '__doc__', 'A description of how to use this function')
Joe Gregorio20cfcda2010-10-26 11:58:08 -0400341 setattr(method, '__is_resource__', True)
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400342 setattr(theclass, methodName, method)
343
344 for methodName, methodDesc in resourceDesc['resources'].iteritems():
345 if futureDesc and 'resources' in futureDesc:
346 future = futureDesc['resources'].get(methodName, {})
347 else:
348 future = {}
349 createMethod(Resource, methodName, methodDesc, future.get(methodName, {}))
Joe Gregorio6d5e94f2010-08-25 23:49:30 -0400350
351 # Add <m>_next() methods to Resource
Joe Gregorio2379ecc2010-10-26 10:51:28 -0400352 if futureDesc:
353 for methodName, methodDesc in futureDesc['methods'].iteritems():
354 if 'next' in methodDesc and methodName in resourceDesc['methods']:
355 createNextMethod(Resource, methodName + "_next", methodDesc['next'])
Joe Gregorio48d361f2010-08-18 13:19:21 -0400356
357 return Resource()