-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathflask_snooze.py
More file actions
334 lines (261 loc) · 9.35 KB
/
flask_snooze.py
File metadata and controls
334 lines (261 loc) · 9.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# coding: utf-8
"""
Snooze: a backend-agnostic REST API provider for Flask.
e.g.
from flask import app, Blueprint
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.snooze import Snooze, SqlAlchemyEndpoint
from my_model import sqlalchemy_db, Book
api = Blueprint('api_v1', __name__)
apimgr = Snooze(api)
apimgr.add(SqlAlchemyEndpoint(sqlalchemy_db, Book, ['author', 'title']))
app.register_blueprint(api, url_prefix='/api_v1')
"""
from flask import request, make_response
import re
try:
import simplejson as json
except ImportError:
import json
class NotFoundError(Exception):
"""
Resource not found.
"""
def __init__(self, cls, path):
super(NotFoundError, self).__init__()
self.cls = cls
self.path = path
self.message = 'No %(cls)s exists with an ID of %(path)s' % dict(
cls=cls.__name__,
path=path
)
def error_dict(etype, message, **kwargs):
d = dict(type=etype, message=message)
if kwargs:
d['detail'] = kwargs
return d
class CoerceToDictEncoder(json.JSONEncoder):
"""
A fairly naive encoder that will try to convert unrecognised types to dict.
The idea being that objects can be made iterable quite easily as a bridge
to being converted to JSON.
"""
def default(self, obj):
if obj is None or type(obj) in (
dict,
list, tuple,
str, unicode,
int, long, float,
bool):
return json.JSONEncoder.default(self, obj)
return dict(obj)
def wrap_verb_call(call, endpoint, data_in, data_out):
"""
Construct a callback that will wrap a given HTTP Verb call, passing a path.
"""
def f(path=None):
data = data_in(request.data) if request.data != '' else dict()
assert isinstance(data, dict), "Data must be a dict"
try:
res = call(endpoint, path, data)
try:
# NB. error_data used because Flask stringifies stuff we put
# into res.data, which isn't good for us
res.data = data_out(res.error_data)
except AttributeError:
try:
res.data = data_out(res.data)
except AttributeError:
res = data_out(res)
except NotFoundError, e:
res = make_response()
res.status = '404'
res.data = data_out(error_dict(**{
'etype': type(e).__name__,
'message': e.message,
'class': e.cls.__name__,
'path': e.path
}))
except:
import sys
from traceback import extract_tb
exc_type, exc_value, exc_traceback = sys.exc_info()
res = data_out(error_dict(exc_type.__name__,
exc_value.message,
traceback=extract_tb(exc_traceback))), '500'
return res
return f
def response_redirect(endpoint, o, code):
r = make_response()
r.headers['Location'] = '%(path)s%(id)s' % dict(
path=re.sub('[^/]*$', '', request.path),
id=getattr(o, endpoint.id_key)
)
r.status = str(code)
return r
class Snooze(object):
"""
The API context manager,
The api level means:
every verb takes in and gives out data in the same ways
"""
def __init__(self, app, hooks=None):
self._app = app
hooks = dict() if hooks is None else hooks
self._hook_data_in = hooks.get('data_in', json.loads)
self._hook_data_out = hooks.get('data_out', CoerceToDictEncoder().encode)
self._routes = {}
def add(self, endpoint, name=None, methods=(
'OPTIONS', 'POST', 'GET', 'PUT', 'PATCH', 'DELETE')):
"""
Add an endpoint for a class, the name defaults to a lowercase version
of the class name but can be overriden.
Methods can be specified, note that HEAD is automatically generated by
Flask to execute the GET method without returning a body.
"""
obj_name = endpoint.cls.__name__.lower() if name is None else name
methods = [m.upper() for m in methods]
for verb in 'OPTIONS', 'POST', 'GET', 'PUT', 'PATCH', 'DELETE':
if verb not in methods:
continue
l = wrap_verb_call(call=getattr(self, '_%s' % verb.lower()),
endpoint=endpoint,
data_in=self._hook_data_in,
data_out=self._hook_data_out)
self._register(obj_name=obj_name,
verb=verb,
func=l)
#
# Verbs
#
def _options(self, endpoint, path, data):
"""HTTP Verb endpoint"""
return self._routes
def _post(self, endpoint, path, data):
"""HTTP Verb endpoint"""
o = endpoint.create(path)
if data is not None:
self._fill(endpoint, o, data)
return response_redirect(endpoint, o, 201)
def _get(self, endpoint, path, data):
"""HTTP Verb endpoint"""
return endpoint.read(path)
def _put(self, endpoint, path, data):
"""HTTP Verb endpoint"""
created = False
try:
o = endpoint.read(path)
except NotFoundError:
o = endpoint.create(path)
created = True
self._fill(endpoint, o, data)
if created:
return response_redirect(endpoint, o, 201)
def _patch(self, endpoint, path, data):
"""HTTP Verb endpoint"""
o = endpoint.read(path)
self._update(endpoint, o, data)
def _delete(self, endpoint, path, data):
"""HTTP Verb endpoint"""
endpoint.delete(path)
#
# Tools
#
def _update(self, endpoint, o, data):
for k in data:
assert k in endpoint.writeable_keys, \
"Cannot update key %s, valid keys for update: %s" % \
(k, ', '.join(endpoint.writeable_keys))
setattr(o, k, data[k])
endpoint.finalize(o)
def _fill(self, endpoint, o, data):
items_set = set(endpoint.writeable_keys)
keys_set = set(data.keys())
assert items_set == keys_set, \
"The provided keys (%s) do not match the expected items (%s)" % \
(', '.join(keys_set), ', '.join(items_set))
self._update(endpoint, o, data)
def _register(self, obj_name, verb, func):
func.provide_automatic_options = False
route = '/%s/<path:path>' % obj_name
self._app.route(route,
methods=(verb,),
endpoint="%s:%s" % (verb, route))(func)
self._reg_options(verb, route)
if verb in ('OPTIONS', 'GET', 'POST'):
route = '/%s/' % obj_name
self._app.route(route,
methods=(verb,),
endpoint="%s:%s" % (verb, route),
defaults={'path': None})(func)
self._reg_options(verb, route)
def _reg_options(self, verb, route):
verbs = self._routes.get(route, [])
verbs.append(verb)
if verb == 'GET':
# Flask adds 'HEAD' for GET
verbs.append('HEAD')
self._routes[route] = verbs
class Endpoint(object):
"""
Base Endpoint object.
"""
def __init__(self, cls, id_key, writeable_keys):
"""
cls: Class of object being represented by this endpoint
id_key: Identifying key of an object
writeable_keys: A list of keys that may be written to on an object
"""
self.cls = cls
self.id_key = id_key
self.writeable_keys = writeable_keys
def create(self, path=None):
"""Create a new object"""
raise NotImplementedError()
def read(self, path):
"""Load an existing object"""
raise NotImplementedError()
def finalize(self, obj):
"""Save an object (if required)"""
raise NotImplementedError()
def delete(self, path):
"""Delete the data for the provided ID"""
raise NotImplementedError()
#
# SQLAlchemy Land
#
def row2dict(row):
"""
Convert a SQLAlchemy row/object to a dict, found on:
http://stackoverflow.com/questions/
1958219/convert-sqlalchemy-row-object-to-python-dict
"""
d = {}
for col_name in row.__table__.columns.keys():
d[col_name] = getattr(row, col_name)
return d
class SqlAlchemyEndpoint(Endpoint):
def __init__(self, db, cls, items):
from sqlalchemy.orm import class_mapper
self.db = db
self.pk = class_mapper(cls).primary_key[0]
super(SqlAlchemyEndpoint, self).__init__(cls, self.pk.name, items)
def create(self, path=None):
o = self.cls()
if path is not None:
setattr(o, self.id_key, path)
return o
def read(self, path):
if path == None:
return [pk[0] for pk in \
self.db.session.query(self.pk).all()]
try:
return self.cls.query.filter(self.pk == path).all()[0]
except IndexError:
raise NotFoundError(self.cls, path)
def finalize(self, obj):
self.db.session.add(obj)
self.db.session.commit()
def delete(self, path):
o = self.read(path)
self.db.session.delete(o)