Coverage for gramex\handlers\socialhandler.py : 54%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import os
2import six
3import json
4import gramex
5import tornado.gen
6from oauthlib import oauth1
7from orderedattrdict import AttrDict
8from tornado.web import HTTPError
9from tornado.auth import TwitterMixin, FacebookGraphMixin
10from tornado.httputil import url_concat, responses
11from .basehandler import BaseHandler
12from gramex.http import OK, BAD_REQUEST, CLIENT_TIMEOUT
14custom_responses = {
15 CLIENT_TIMEOUT: 'Client Timeout'
16}
17store_cache = {}
20class SocialMixin(object):
21 @classmethod
22 def setup_social(cls, user_info, transform={}, methods=['get', 'post'], **kwargs):
23 # Session key that stores the user info
24 cls.user_info = user_info
26 # Set up methods
27 if not isinstance(methods, list):
28 methods = [methods]
29 methods = set(method.lower().strip() for method in methods)
30 for method in ('get', 'post', 'put', 'patch'):
31 if method in methods:
32 setattr(cls, method, cls.run)
34 @tornado.gen.coroutine
35 def social_response(self, response):
36 # Set response headers
37 if response.code in responses: 37 ↛ 40line 37 didn't jump to line 40, because the condition on line 37 was never false
38 self.set_status(response.code)
39 else:
40 self.set_status(response.code, custom_responses.get(response.code))
41 for header, header_value in response.headers.items():
42 # We're OK with anything that starts with X-
43 # Also set MIME type and last modified date
44 if header.startswith('X-') or header in {'Content-Type', 'Last-Modified'}:
45 self.set_header(header, header_value)
47 # Set user's headers
48 for header, header_value in self.kwargs.get('headers', {}).items():
49 self.set_header(header, header_value)
51 # Transform content
52 content = response.body
53 if content and response.code == OK: 53 ↛ 54line 53 didn't jump to line 54, because the condition on line 53 was never true
54 content = yield gramex.service.threadpool.submit(self.run_transforms, content=content)
55 # Convert to JSON if required
56 if not isinstance(content, (six.binary_type, six.text_type)): 56 ↛ 57line 56 didn't jump to line 57, because the condition on line 56 was never true
57 content = json.dumps(content, ensure_ascii=True, separators=(',', ':'))
58 raise tornado.gen.Return(content)
60 def run_transforms(self, content):
61 result = json.loads(content.decode('utf-8'))
62 for name, transform in self.transform.items():
63 for value in transform['function'](result):
64 result = value
65 for header, header_value in transform.get('headers', {}).items():
66 self.set_header(header, header_value)
67 return result
69 def write_error(self, status_code, **kwargs):
70 '''Write error responses in JSON'''
71 self.set_header('Content-Type', 'application/json; charset=UTF-8')
72 self.finish(json.dumps({'errors': [{
73 'code': status_code,
74 'message': self._reason,
75 }]}))
77 def _get_store_key(self):
78 '''
79 Allows social mixins to store information in a single global JSONStore.
80 Keys are "$YAMLPATH: url-key". Any value may be stored against it.
81 '''
82 if 'store' not in store_cache:
83 store_path = os.path.join(gramex.config.variables['GRAMEXDATA'], 'socialstore.json')
84 store_cache['store'] = gramex.handlers.basehandler.JSONStore(store_path, flush=60)
85 base_key = '{}: {}'.format(os.getcwd(), self.name)
86 return store_cache['store'], base_key
88 def read_store(self):
89 '''
90 Read from this URL handler's social store. Typically returns a dict
91 '''
92 cache, key = self._get_store_key()
93 return cache.load(key, {})
95 def write_store(self, value):
96 '''
97 Write to this URL handler's social store. Typically stores a dict
98 '''
99 cache, key = self._get_store_key()
100 cache.dump(key, value)
102 def get_token(self, key, fetch=lambda info, key, val: info.get(key, val)): 102 ↛ exitline 102 didn't run the lambda on line 102
103 '''
104 Returns an access token / key / secret with the following priority:
106 1. If YAML config specifies "persist" for the token, get it from the last
107 stored value. If none is stored, save and use the current session's
108 token
109 2. If YAML config specifies a token, use it
110 3. If YAML config does NOT specify a token, use current sessions' token
112 If after all of this, we don't have a token, raise an exception.
113 '''
114 info = self.session.get(self.user_info, {})
115 token = self.kwargs.get(key, None) # Get from config
116 session_token = fetch(info, key, None)
117 if token == 'persist': # nosec 117 ↛ 118line 117 didn't jump to line 118, because the condition on line 117 was never true
118 token = self.read_store().get(key, None) # If persist, use store
119 if token is None and session_token: # Or persist from session
120 self.write_store(info)
121 if token is None: 121 ↛ 122line 121 didn't jump to line 122, because the condition on line 121 was never true
122 token = session_token # Use session token
123 if token is None: # Ensure token is present 123 ↛ 124line 123 didn't jump to line 124, because the condition on line 123 was never true
124 raise HTTPError(BAD_REQUEST, reason='token %s missing' % key)
125 return token
128class TwitterRESTHandler(SocialMixin, BaseHandler, TwitterMixin):
129 '''
130 Proxy for the Twitter 1.1 REST API via these ``kwargs``::
132 pattern: /twitter/(.*)
133 handler: TwitterRESTHandler
134 kwargs:
135 key: your-consumer-key
136 secret: your-consumer-secret
137 access_key: your-access-key # Optional -- picked up from session
138 access_secret: your-access-token # Optional -- picked up from session
139 methods: [get, post] # HTTP methods to use for the API
140 path: /search/tweets.json # Freeze Twitter API request
142 Now ``POST /twitter/search/tweets.json?q=gramener`` returns the same response
143 as the Twitter REST API ``/search/tweets.json``.
145 If you only want to expose a specific API, specify a ``path:``. It overrides
146 the URL path. The query parameters will still work.
148 By default, ``methods`` is POST, and GET logs the user in, storing the access
149 token in the session for future use. But you can specify the ``access_...``
150 values and set ``methods`` to ``[get, post]`` to use both GET and POST
151 requests to proxy the API.
152 '''
153 @staticmethod
154 def get_from_token(info, key, val):
155 return info.get('access_token', {}).get(key.replace('access_', ''), val)
157 @classmethod
158 def setup(cls, **kwargs):
159 super(TwitterRESTHandler, cls).setup(**kwargs)
160 cls.setup_social('user.twitter', **kwargs)
162 @tornado.gen.coroutine
163 def run(self, path=None):
164 path = self.kwargs.get('path', path)
165 if not path and self.request.method == 'GET': 165 ↛ 166line 165 didn't jump to line 166, because the condition on line 165 was never true
166 yield self.login()
167 raise tornado.gen.Return()
169 args = {key: val[0] for key, val in self.args.items()}
170 params = AttrDict(self.kwargs)
171 params['access_key'] = self.get_token('access_key', self.get_from_token)
172 params['access_secret'] = self.get_token('access_secret', self.get_from_token)
174 client = oauth1.Client(
175 client_key=params['key'],
176 client_secret=params['secret'],
177 resource_owner_key=params['access_key'],
178 resource_owner_secret=params['access_secret'])
179 endpoint = params.get('endpoint', 'https://api.twitter.com/1.1/')
180 path = params.get('path', path)
181 uri, headers, body = client.sign(url_concat(endpoint + path, args))
182 http = self.get_auth_http_client()
183 response = yield http.fetch(uri, headers=headers, raise_error=False)
184 result = yield self.social_response(response)
185 self.set_header('Content-Type', 'application/json; charset=UTF-8')
186 self.write(result)
188 @tornado.gen.coroutine
189 def login(self):
190 if self.get_argument('oauth_token', None):
191 info = self.session[self.user_info] = yield self.get_authenticated_user()
192 if (any(self.kwargs.get(key, None) == 'persist'
193 for key in ('access_key', 'access_secret'))):
194 self.write_store(info)
195 self.redirect_next()
196 else:
197 self.save_redirect_page()
198 yield self.authorize_redirect(callback_uri=self.request.protocol + "://" +
199 self.request.host + self.request.uri)
201 def _oauth_consumer_token(self):
202 return dict(key=self.kwargs['key'],
203 secret=self.kwargs['secret'])
206class FacebookGraphHandler(SocialMixin, BaseHandler, FacebookGraphMixin):
207 '''
208 Proxy for the Facebook Graph API via these ``kwargs``::
210 pattern: /facebook/(.*)
211 handler: FacebookGraphHandler
212 kwargs:
213 key: your-consumer-key
214 secret: your-consumer-secret
215 access_token: your-access-token # Optional -- picked up from session
216 methods: [get, post] # HTTP methods to use for the API
217 scope: user_posts,user_photos # Permissions requested for the user
218 path: /me/feed # Freeze Facebook Graph API request
220 Now ``POST /facebook/me`` returns the same response as the Facebook Graph API
221 ``/me``. To request specific access rights, specify the ``scope`` based on
222 `permissions`_ required by the `Graph API`_.
224 If you only want to expose a specific API, specify a ``path:``. It overrides
225 the URL path. The query parameters will still work.
227 By default, ``methods`` is POST, and GET logs the user in, storing the access
228 token in the session for future use. But you can specify the ``access_token``
229 values and set ``methods`` to ``[get, post]`` to use both GET and POST
230 requests to proxy the API.
232 .. _permissions: https://developers.facebook.com/docs/facebook-login/permissions
233 .. _Graph API: https://developers.facebook.com/docs/graph-api/reference
234 '''
235 @classmethod
236 def setup(cls, **kwargs):
237 super(FacebookGraphHandler, cls).setup(**kwargs)
238 cls.setup_social('user.facebook', **kwargs)
240 @tornado.gen.coroutine
241 def run(self, path=None):
242 path = self.kwargs.get('path', path)
243 if not path and self.request.method == 'GET':
244 yield self.login()
245 raise tornado.gen.Return()
247 args = {key: val[0] for key, val in self.args.items()}
248 args['access_token'] = self.get_token('access_token')
249 uri = url_concat(self._FACEBOOK_BASE_URL + '/' + self.kwargs.get('path', path), args)
250 http = self.get_auth_http_client()
251 response = yield http.fetch(uri, raise_error=False)
252 result = yield self.social_response(response)
253 self.set_header('Content-Type', 'application/json; charset=UTF-8')
254 self.write(result)
256 @tornado.gen.coroutine
257 def login(self):
258 redirect_uri = self.request.protocol + "://" + self.request.host + self.request.uri
259 if self.get_argument('code', False):
260 info = self.session[self.user_info] = yield self.get_authenticated_user(
261 redirect_uri=redirect_uri,
262 client_id=self.kwargs['key'],
263 client_secret=self.kwargs['secret'],
264 code=self.get_argument('code'))
265 if self.kwargs.get('access_token', None) == 'persist':
266 self.write_store(info)
267 self.redirect_next()
268 else:
269 self.save_redirect_page()
270 scope = self.kwargs.get('scope', 'user_posts,read_insights')
271 yield self.authorize_redirect(
272 redirect_uri=redirect_uri,
273 client_id=self.kwargs['key'],
274 extra_params={'scope': scope})