1 | import json |
---|
2 | from collections import OrderedDict |
---|
3 | from gluon import URL, IS_SLUG |
---|
4 | from functools import reduce |
---|
5 | |
---|
6 | # compliant with https://github.com/collection-json/spec |
---|
7 | # also compliant with http://code.ge/media-types/collection-next-json/ |
---|
8 | |
---|
9 | """ |
---|
10 | |
---|
11 | Example controller: |
---|
12 | |
---|
13 | def api(): |
---|
14 | from gluon.contrib.hypermedia import Collection |
---|
15 | policies = { |
---|
16 | 'thing': { |
---|
17 | 'GET':{'query':None,'fields':['id', 'name']}, |
---|
18 | 'POST':{'query':None,'fields':['name']}, |
---|
19 | 'PUT':{'query':None,'fields':['name']}, |
---|
20 | 'DELETE':{'query':None}, |
---|
21 | }, |
---|
22 | 'attr': { |
---|
23 | 'GET':{'query':None,'fields':['id', 'name', 'thing']}, |
---|
24 | 'POST':{'query':None,'fields':['name', 'thing']}, |
---|
25 | 'PUT':{'query':None,'fields':['name', 'thing']}, |
---|
26 | 'DELETE':{'query':None}, |
---|
27 | }, |
---|
28 | } |
---|
29 | return Collection(db).process(request, response, policies) |
---|
30 | """ |
---|
31 | |
---|
32 | __all__ = ['Collection'] |
---|
33 | |
---|
34 | class Collection(object): |
---|
35 | |
---|
36 | VERSION = '1.0' |
---|
37 | MAXITEMS = 100 |
---|
38 | |
---|
39 | def __init__(self,db, extensions=True, compact=False): |
---|
40 | self.db = db |
---|
41 | self.extensions = extensions |
---|
42 | self.compact = compact |
---|
43 | |
---|
44 | def row2data(self,table,row,text=False): |
---|
45 | """ converts a DAL Row object into a collection.item """ |
---|
46 | data = [] |
---|
47 | if self.compact: |
---|
48 | for fieldname in (self.table_policy.get('fields',table.fields)): |
---|
49 | field = table[fieldname] |
---|
50 | if not ((field.type=='text' and text==False) or |
---|
51 | field.type=='blob' or |
---|
52 | field.type.startswith('reference ') or |
---|
53 | field.type.startswith('list:reference ')) and field.name in row: |
---|
54 | data.append(row[field.name]) |
---|
55 | else: |
---|
56 | for fieldname in (self.table_policy.get('fields',table.fields)): |
---|
57 | field = table[fieldname] |
---|
58 | if not ((field.type=='text' and text==False) or |
---|
59 | field.type=='blob' or |
---|
60 | field.type.startswith('reference ') or |
---|
61 | field.type.startswith('list:reference ')) and field.name in row: |
---|
62 | data.append({'name':field.name,'value':row[field.name], |
---|
63 | 'prompt':field.label, 'type':field.type}) |
---|
64 | return data |
---|
65 | |
---|
66 | def row2links(self,table,row): |
---|
67 | """ converts a DAL Row object into a set of links referencing the row """ |
---|
68 | links = [] |
---|
69 | for field in table._referenced_by: |
---|
70 | if field._tablename in self.policies: |
---|
71 | if row: |
---|
72 | href = URL(args=field._tablename,vars={field.name:row.id},scheme=True) |
---|
73 | else: |
---|
74 | href = URL(args=field._tablename,scheme=True)+'?%s={id}' % field.name |
---|
75 | links.append({'rel':'current','href':href,'prompt':str(field), |
---|
76 | 'type':'children'}) |
---|
77 | if row: |
---|
78 | fields = self.table_policy.get('fields', table.fields) |
---|
79 | for fieldname in fields: |
---|
80 | field = table[fieldname] |
---|
81 | if field.type.startswith('reference '): |
---|
82 | href = URL(args=field.type[10:],vars={'id':row[fieldname]}, |
---|
83 | scheme=True) |
---|
84 | links.append({'rel':'current','href':href,'prompt':str(field), |
---|
85 | 'type':'parent'}) |
---|
86 | |
---|
87 | for fieldname in fields: |
---|
88 | field = table[fieldname] |
---|
89 | if field.type=='upload' and row[fieldname]: |
---|
90 | href = URL('download',args=row[fieldname],scheme=True) |
---|
91 | links.append({'rel':'current','href':href,'prompt':str(field), |
---|
92 | 'type':'attachment'}) |
---|
93 | |
---|
94 | # should this be supported? |
---|
95 | for rel,build in (self.table_policy.get('links',{}).items()): |
---|
96 | links.append({'rel':'current','href':build(row),'prompt':rel}) |
---|
97 | # not sure |
---|
98 | return links |
---|
99 | |
---|
100 | def table2template(self,table): |
---|
101 | """ confeverts a table into its form template """ |
---|
102 | data = [] |
---|
103 | fields = self.table_policy.get('fields', table.fields) |
---|
104 | for fieldname in fields: |
---|
105 | field = table[fieldname] |
---|
106 | info = {'name': field.name, 'value': '', 'prompt': field.label} |
---|
107 | policies = self.policies[table._tablename] |
---|
108 | # https://github.com/collection-json/extensions/blob/master/template-validation.md |
---|
109 | info['type'] = str(field.type) # FIX THIS |
---|
110 | if hasattr(field,'regexp_validator'): |
---|
111 | info['regexp'] = field.regexp_validator |
---|
112 | info['required'] = field.required |
---|
113 | info['post_writable'] = field.name in policies['POST'].get('fields',fields) |
---|
114 | info['put_writable'] = field.name in policies['PUT'].get('fields',fields) |
---|
115 | info['options'] = {} # FIX THIS |
---|
116 | data.append(info) |
---|
117 | return {'data':data} |
---|
118 | |
---|
119 | def request2query(self,table,vars): |
---|
120 | """ parses a request and converts it into a query """ |
---|
121 | if len(self.request.args)>1: |
---|
122 | vars.id = self.request.args[1] |
---|
123 | |
---|
124 | fieldnames = table.fields |
---|
125 | queries = [table] |
---|
126 | limitby = [0,self.MAXITEMS+1] |
---|
127 | orderby = 'id' |
---|
128 | for key,value in vars.items(): |
---|
129 | if key=='_offset': |
---|
130 | limitby[0] = int(value) # MAY FAIL |
---|
131 | elif key == '_limit': |
---|
132 | limitby[1] = int(value)+1 # MAY FAIL |
---|
133 | elif key=='_orderby': |
---|
134 | orderby = value |
---|
135 | elif key in fieldnames: |
---|
136 | queries.append(table[key] == value) |
---|
137 | elif key.endswith('.eq') and key[:-3] in fieldnames: # for completeness (useless) |
---|
138 | queries.append(table[key[:-3]] == value) |
---|
139 | elif key.endswith('.lt') and key[:-3] in fieldnames: |
---|
140 | queries.append(table[key[:-3]] < value) |
---|
141 | elif key.endswith('.le') and key[:-3] in fieldnames: |
---|
142 | queries.append(table[key[:-3]] <= value) |
---|
143 | elif key.endswith('.gt') and key[:-3] in fieldnames: |
---|
144 | queries.append(table[key[:-3]] > value) |
---|
145 | elif key.endswith('.ge') and key[:-3] in fieldnames: |
---|
146 | queries.append(table[key[:-3]] >= value) |
---|
147 | elif key.endswith('.contains') and key[:-9] in fieldnames: |
---|
148 | queries.append(table[key[:-9]].contains(value)) |
---|
149 | elif key.endswith('.startswith') and key[:-11] in fieldnames: |
---|
150 | queries.append(table[key[:-11]].startswith(value)) |
---|
151 | elif key.endswith('.ne') and key[:-3] in fieldnames: |
---|
152 | queries.append(table[key][:-3] != value) |
---|
153 | else: |
---|
154 | raise ValueError("Invalid Query") |
---|
155 | filter_query = self.table_policy.get('query') |
---|
156 | if filter_query: |
---|
157 | queries.append(filter_query) |
---|
158 | query = reduce(lambda a,b:a&b,queries[1:]) if len(queries)>1 else queries[0] |
---|
159 | orderby = [table[f] if f[0]!='~' else ~table[f[1:]] for f in orderby.split(',')] |
---|
160 | return (query, limitby, orderby) |
---|
161 | |
---|
162 | def table2queries(self,table, href): |
---|
163 | """ generates a set of collection.queries examples for the table """ |
---|
164 | data = [] |
---|
165 | for fieldname in (self.table_policy.get('fields', table.fields)): |
---|
166 | data.append({'name':fieldname,'value':''}) |
---|
167 | if self.extensions: |
---|
168 | data.append({'name':fieldname+'.ne','value':''}) # NEW !!! |
---|
169 | data.append({'name':fieldname+'.lt','value':''}) |
---|
170 | data.append({'name':fieldname+'.le','value':''}) |
---|
171 | data.append({'name':fieldname+'.gt','value':''}) |
---|
172 | data.append({'name':fieldname+'.ge','value':''}) |
---|
173 | if table[fieldname].type in ['string','text']: |
---|
174 | data.append({'name':fieldname+'.contains','value':''}) |
---|
175 | data.append({'name':fieldname+'.startswith','value':''}) |
---|
176 | data.append({'name':'_limitby','value':''}) |
---|
177 | data.append({'name':'_offset','value':''}) |
---|
178 | data.append({'name':'_orderby','value':''}) |
---|
179 | return [{'rel' : 'search', 'href' : href, 'prompt' : 'Search', 'data' : data}] |
---|
180 | |
---|
181 | def process(self,request,response,policies=None): |
---|
182 | """ the main method, processes a request, filters by policies and produces a JSON response """ |
---|
183 | self.request = request |
---|
184 | self.response = response |
---|
185 | self.policies = policies |
---|
186 | db = self.db |
---|
187 | tablename = request.args(0) |
---|
188 | r = OrderedDict() |
---|
189 | r['version'] = self.VERSION |
---|
190 | tablenames = policies.keys() if policies else db.tables |
---|
191 | # if there is no tables |
---|
192 | if not tablename: |
---|
193 | r['href'] = URL(scheme=True), |
---|
194 | # https://github.com/collection-json/extensions/blob/master/model.md |
---|
195 | r['links'] = [{'rel' : t, 'href' : URL(args=t,scheme=True), 'model':t} |
---|
196 | for t in tablenames] |
---|
197 | response.headers['Content-Type'] = 'application/vnd.collection+json' |
---|
198 | return response.json({'collection':r}) |
---|
199 | # or if the tablenames is invalid |
---|
200 | if not tablename in tablenames: |
---|
201 | return self.error(400,'BAD REQUEST','Invalid table name') |
---|
202 | # of if the method is invalid |
---|
203 | if not request.env.request_method in policies[tablename]: |
---|
204 | return self.error(400,'BAD REQUEST','Method not recognized') |
---|
205 | # get the policies |
---|
206 | self.table_policy = policies[tablename][request.env.request_method] |
---|
207 | # process GET |
---|
208 | if request.env.request_method=='GET': |
---|
209 | table = db[tablename] |
---|
210 | r['href'] = URL(args=tablename) |
---|
211 | r['items'] = items = [] |
---|
212 | try: |
---|
213 | (query, limitby, orderby) = self.request2query(table,request.get_vars) |
---|
214 | fields = [table[fn] for fn in (self.table_policy.get('fields', table.fields))] |
---|
215 | fields = filter(lambda field: field.readable, fields) |
---|
216 | rows = db(query).select(*fields,**dict(limitby=limitby, orderby=orderby)) |
---|
217 | except: |
---|
218 | db.rollback() |
---|
219 | return self.error(400,'BAD REQUEST','Invalid Query') |
---|
220 | r['items_found'] = db(query).count() |
---|
221 | delta = limitby[1]-limitby[0]-1 |
---|
222 | r['links'] = self.row2links(table,None) if self.compact else [] |
---|
223 | text = r['items_found']<2 |
---|
224 | for row in rows[:delta]: |
---|
225 | id = row.id |
---|
226 | for name in ('slug','fullname','title','name'): |
---|
227 | if name in row: |
---|
228 | href = URL(args=(tablename,id),scheme=True) |
---|
229 | break |
---|
230 | else: |
---|
231 | href = URL(args=(tablename,id),scheme=True) |
---|
232 | if self.compact: |
---|
233 | items.append(self.row2data(table,row,text)) |
---|
234 | else: |
---|
235 | items.append({ |
---|
236 | 'href':href, |
---|
237 | 'data':self.row2data(table,row,text), |
---|
238 | 'links':self.row2links(table,row) |
---|
239 | }); |
---|
240 | if self.extensions and len(rows)>delta: |
---|
241 | vars = dict(request.get_vars) |
---|
242 | vars['_offset'] = limitby[1]-1 |
---|
243 | vars['_limit'] = limitby[1]-1+delta |
---|
244 | r['next'] = {'rel':'next', |
---|
245 | 'href':URL(args=request.args,vars=vars,scheme=True)} |
---|
246 | if self.extensions and limitby[0]>0: |
---|
247 | vars = dict(request.get_vars) |
---|
248 | vars['_offset'] = max(0,limitby[0]-delta) |
---|
249 | vars['_limit'] = limitby[0] |
---|
250 | r['previous'] = {'rel':'previous', |
---|
251 | 'href':URL(args=request.args,vars=vars,scheme=True)} |
---|
252 | data = [] |
---|
253 | if not self.compact: |
---|
254 | r['queries'] = self.table2queries(table, r['href']) |
---|
255 | r['template'] = self.table2template(table) |
---|
256 | response.headers['Content-Type'] = 'application/vnd.collection+json' |
---|
257 | return response.json({'collection':r}) |
---|
258 | # process DELETE |
---|
259 | elif request.env.request_method=='DELETE': |
---|
260 | table = db[tablename] |
---|
261 | if not request.get_vars: |
---|
262 | return self.error(400, "BAD REQUEST", "Nothing to delete") |
---|
263 | else: |
---|
264 | try: |
---|
265 | (query, limitby, orderby) = self.request2query(table, request.vars) |
---|
266 | n = db(query).delete() # MAY FAIL |
---|
267 | response.status = 204 |
---|
268 | return '' |
---|
269 | except: |
---|
270 | db.rollback() |
---|
271 | return self.error(400,'BAD REQUEST','Invalid Query') |
---|
272 | return response.json(r) |
---|
273 | # process POST and PUT (on equal footing!) |
---|
274 | elif request.env.request_method in ('POST','PUT'): # we treat them the same! |
---|
275 | table = db[tablename] |
---|
276 | if 'json' in request.env.content_type: |
---|
277 | data = request.post_vars.data |
---|
278 | else: |
---|
279 | data = request.post_vars |
---|
280 | if request.get_vars or len(request.args)>1: # update |
---|
281 | # ADD validate fields and return error |
---|
282 | try: |
---|
283 | (query, limitby, orderby) = self.request2query(table, request.get_vars) |
---|
284 | fields = filter(lambda fn_value:table[fn_value[0]].writable,data.items()) |
---|
285 | res = db(query).validate_and_update(**dict(fields)) # MAY FAIL |
---|
286 | if res.errors: |
---|
287 | return self.error(400,'BAD REQUEST','Validation Error',res.errors) |
---|
288 | else: |
---|
289 | response.status = 200 |
---|
290 | return '' |
---|
291 | except: |
---|
292 | db.rollback() |
---|
293 | return self.error(400,'BAD REQUEST','Invalid Query') |
---|
294 | else: # create |
---|
295 | # ADD validate fields and return error |
---|
296 | try: |
---|
297 | fields = filter(lambda fn_value1:table[fn_value1[0]].writable,data.items()) |
---|
298 | res = table.validate_and_insert(**dict(fields)) # MAY FAIL |
---|
299 | if res.errors: |
---|
300 | return self.error(400,'BAD REQUEST','Validation Error',res.errors) |
---|
301 | else: |
---|
302 | response.status = 201 |
---|
303 | response.headers['location'] = \ |
---|
304 | URL(args=(tablename,res.id),scheme=True) |
---|
305 | return '' |
---|
306 | except SyntaxError as e: #Exception,e: |
---|
307 | db.rollback() |
---|
308 | return self.error(400,'BAD REQUEST','Invalid Query:'+e) |
---|
309 | |
---|
310 | def error(self,code="400", title="BAD REQUEST", message="UNKNOWN", form_errors={}): |
---|
311 | request, response = self.request, self.response |
---|
312 | r = OrderedDict({ |
---|
313 | "version" : self.VERSION, |
---|
314 | "href" : URL(args=request.args,vars=request.vars), |
---|
315 | "error" : { |
---|
316 | "title" : title, |
---|
317 | "code" : code, |
---|
318 | "message" : message}}) |
---|
319 | if self.extensions and form_errors: |
---|
320 | # https://github.com/collection-json/extensions/blob/master/errors.md |
---|
321 | r['errors'] = errors = {} |
---|
322 | for key, value in form_errors.items(): |
---|
323 | errors[key] = {'title':'Validation Error','code':'','message':value} |
---|
324 | response.headers['Content-Type'] = 'application/vnd.collection+json' |
---|
325 | response.status = 400 |
---|
326 | return response.json({'collection':r}) |
---|
327 | |
---|
328 | example_policies = { |
---|
329 | 'thing': { |
---|
330 | 'GET':{'query':None,'fields':['id', 'name']}, |
---|
331 | 'POST':{'query':None,'fields':['name']}, |
---|
332 | 'PUT':{'query':None,'fields':['name']}, |
---|
333 | 'DELETE':{'query':None}, |
---|
334 | }, |
---|
335 | 'attr': { |
---|
336 | 'GET':{'query':None,'fields':['id', 'name', 'thing']}, |
---|
337 | 'POST':{'query':None,'fields':['name', 'thing']}, |
---|
338 | 'PUT':{'query':None,'fields':['name', 'thing']}, |
---|
339 | 'DELETE':{'query':None}, |
---|
340 | }, |
---|
341 | } |
---|