Hide keyboard shortcuts

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 

13 

14custom_responses = { 

15 CLIENT_TIMEOUT: 'Client Timeout' 

16} 

17store_cache = {} 

18 

19 

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 

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 

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

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