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

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

## 138 statements 81 run 57 missing 0 excluded 8 partial

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

1 import os

2 import six

3 import json

4 import gramex

5 import tornado.gen

6 from oauthlib import oauth1

7 from orderedattrdict import AttrDict

8 from tornado.web import HTTPError

9 from tornado.auth import TwitterMixin, FacebookGraphMixin

10 from tornado.httputil import url_concat, responses

11 from .basehandler import BaseHandler

12 from gramex.http import OK, BAD_REQUEST, CLIENT_TIMEOUT

13

14 custom_responses = {

15 CLIENT_TIMEOUT: 'Client Timeout'

16}

17 store_cache = {}

18

19

20 class 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

25

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)

33

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)

46

47 # Set user's headers

48 for header, header_value in self.kwargs.get('headers', {}).items():

49 self.set_header(header, header_value)

50

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)

59

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

68

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 }]}))

76

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

87

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, {})

94

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)

101

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:

105

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

111

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

126

127

128 class TwitterRESTHandler(SocialMixin, BaseHandler, TwitterMixin):

129 '''

130 Proxy for the Twitter 1.1 REST API via these ``kwargs``::

131

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

141

142 Now ``POST /twitter/search/tweets.json?q=gramener`` returns the same response

143 as the Twitter REST API ``/search/tweets.json``.

144

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.

147

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)

156

157 @classmethod

158 def setup(cls, **kwargs):

159 super(TwitterRESTHandler, cls).setup(**kwargs)

160 cls.setup_social('user.twitter', **kwargs)

161

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()

168

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)

173

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)

187

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)

200

201 def _oauth_consumer_token(self):

202 return dict(key=self.kwargs['key'],

203 secret=self.kwargs['secret'])

204

205

206 class FacebookGraphHandler(SocialMixin, BaseHandler, FacebookGraphMixin):

207 '''

208 Proxy for the Facebook Graph API via these ``kwargs``::

209

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

219

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`_.

223

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.

226

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.

231

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)

239

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()

246

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)

255

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})