comparison roundup/rest.py @ 5597:de9933cfcfc4 REST-rebased

Added routing decorator committer: Ralf Schlatterbeck <rsc@runtux.com>
author Chau Nguyen <dangchau1991@yahoo.com>
date Wed, 30 Jan 2019 10:26:35 +0100
parents 55fa81de6a57
children be81e8cca38c
comparison
equal deleted inserted replaced
5596:55fa81de6a57 5597:de9933cfcfc4
10 import json 10 import json
11 import pprint 11 import pprint
12 import sys 12 import sys
13 import time 13 import time
14 import traceback 14 import traceback
15 import xml 15 import re
16
16 from roundup import hyperdb 17 from roundup import hyperdb
17 from roundup import date 18 from roundup import date
18 from roundup.exceptions import * 19 from roundup.exceptions import *
20 from roundup.cgi.exceptions import *
19 21
20 22
21 def _data_decorator(func): 23 def _data_decorator(func):
22 """Wrap the returned data into an object.""" 24 """Wrap the returned data into an object."""
23 def format_object(self, *args, **kwargs): 25 def format_object(self, *args, **kwargs):
24 # get the data / error from function 26 # get the data / error from function
25 try: 27 try:
26 code, data = func(self, *args, **kwargs) 28 code, data = func(self, *args, **kwargs)
29 except NotFound, msg:
30 code = 404
31 data = msg
27 except IndexError, msg: 32 except IndexError, msg:
28 code = 404 33 code = 404
29 data = msg 34 data = msg
30 except Unauthorised, msg: 35 except Unauthorised, msg:
31 code = 403 36 code = 403
131 result.append((media_type, dict(media_params), q)) 136 result.append((media_type, dict(media_params), q))
132 result.sort(lambda x, y: -cmp(x[2], y[2])) 137 result.sort(lambda x, y: -cmp(x[2], y[2]))
133 return result 138 return result
134 139
135 140
141 class Routing(object):
142 __route_map = {}
143 __var_to_regex = re.compile(r"<:(\w+)>")
144 url_to_regex = r"([\w.\-~!$&'()*+,;=:@\%%]+)"
145
146 @classmethod
147 def route(cls, rule, methods='GET'):
148 """A decorator that is used to register a view function for a
149 given URL rule:
150 @self.route('/')
151 def index():
152 return 'Hello World'
153
154 rest/ will be added to the beginning of the url string
155
156 Args:
157 rule (string): the URL rule
158 methods (string or tuple or list): the http method
159 """
160 # strip the '/' character from rule string
161 rule = rule.strip('/')
162
163 # add 'rest/' to the rule string
164 if not rule.startswith('rest/'):
165 rule = '^rest/' + rule + '$'
166
167 if isinstance(methods, basestring): # convert string to tuple
168 methods = (methods,)
169 methods = set(item.upper() for item in methods)
170
171 # convert a rule to a compiled regex object
172 # so /data/<:class>/<:id> will become
173 # /data/([charset]+)/([charset]+)
174 # and extract the variable names to a list [(class), (id)]
175 func_vars = cls.__var_to_regex.findall(rule)
176 rule = re.compile(cls.__var_to_regex.sub(cls.url_to_regex, rule))
177
178 # then we decorate it:
179 # route_map[regex][method] = func
180 def decorator(func):
181 rule_route = cls.__route_map.get(rule, {})
182 func_obj = {
183 'func': func,
184 'vars': func_vars
185 }
186 for method in methods:
187 rule_route[method] = func_obj
188 cls.__route_map[rule] = rule_route
189 return func
190 return decorator
191
192 @classmethod
193 def execute(cls, instance, path, method, input):
194 # format the input
195 path = path.strip('/').lower()
196 method = method.upper()
197
198 # find the rule match the path
199 # then get handler match the method
200 for path_regex in cls.__route_map:
201 match_obj = path_regex.match(path)
202 if match_obj:
203 try:
204 func_obj = cls.__route_map[path_regex][method]
205 except KeyError:
206 raise Reject('Method %s not allowed' % method)
207
208 # retrieve the vars list and the function caller
209 list_vars = func_obj['vars']
210 func = func_obj['func']
211
212 # zip the varlist into a dictionary, and pass it to the caller
213 args = dict(zip(list_vars, match_obj.groups()))
214 args['input'] = input
215 return func(instance, **args)
216 raise NotFound('Nothing matches the given URI')
217
218
136 class RestfulInstance(object): 219 class RestfulInstance(object):
137 """The RestfulInstance performs REST request from the client""" 220 """The RestfulInstance performs REST request from the client"""
138 221
139 __default_patch_op = "replace" # default operator for PATCH method 222 __default_patch_op = "replace" # default operator for PATCH method
140 __accepted_content_type = { 223 __accepted_content_type = {
278 else: 361 else:
279 raise UsageError('PATCH Operation %s is not allowed' % op) 362 raise UsageError('PATCH Operation %s is not allowed' % op)
280 363
281 return result 364 return result
282 365
366 @Routing.route("/data/<:class_name>", 'GET')
283 @_data_decorator 367 @_data_decorator
284 def get_collection(self, class_name, input): 368 def get_collection(self, class_name, input):
285 """GET resource from class URI. 369 """GET resource from class URI.
286 370
287 This function returns only items have View permission 371 This function returns only items have View permission
295 int: http status code 200 (OK) 379 int: http status code 200 (OK)
296 list: list of reference item in the class 380 list: list of reference item in the class
297 id: id of the object 381 id: id of the object
298 link: path to the object 382 link: path to the object
299 """ 383 """
384 if class_name not in self.db.classes:
385 raise NotFound('Class %s not found' % class_name)
300 if not self.db.security.hasPermission( 386 if not self.db.security.hasPermission(
301 'View', self.db.getuid(), class_name 387 'View', self.db.getuid(), class_name
302 ): 388 ):
303 raise Unauthorised('Permission to view %s denied' % class_name) 389 raise Unauthorised('Permission to view %s denied' % class_name)
304 390
346 result = result[page_start:page_end] 432 result = result[page_start:page_end]
347 433
348 self.client.setHeader("X-Count-Total", str(len(result))) 434 self.client.setHeader("X-Count-Total", str(len(result)))
349 return 200, result 435 return 200, result
350 436
437 @Routing.route("/data/<:class_name>/<:item_id>", 'GET')
351 @_data_decorator 438 @_data_decorator
352 def get_element(self, class_name, item_id, input): 439 def get_element(self, class_name, item_id, input):
353 """GET resource from object URI. 440 """GET resource from object URI.
354 441
355 This function returns only properties have View permission 442 This function returns only properties have View permission
366 id: id of the object 453 id: id of the object
367 type: class name of the object 454 type: class name of the object
368 link: link to the object 455 link: link to the object
369 attributes: a dictionary represent the attributes of the object 456 attributes: a dictionary represent the attributes of the object
370 """ 457 """
458 if class_name not in self.db.classes:
459 raise NotFound('Class %s not found' % class_name)
371 if not self.db.security.hasPermission( 460 if not self.db.security.hasPermission(
372 'View', self.db.getuid(), class_name, itemid=item_id 461 'View', self.db.getuid(), class_name, itemid=item_id
373 ): 462 ):
374 raise Unauthorised( 463 raise Unauthorised(
375 'Permission to view %s%s denied' % (class_name, item_id) 464 'Permission to view %s%s denied' % (class_name, item_id)
392 'attributes': dict(result) 481 'attributes': dict(result)
393 } 482 }
394 483
395 return 200, result 484 return 200, result
396 485
486 @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'GET')
397 @_data_decorator 487 @_data_decorator
398 def get_attribute(self, class_name, item_id, attr_name, input): 488 def get_attribute(self, class_name, item_id, attr_name, input):
399 """GET resource from attribute URI. 489 """GET resource from attribute URI.
400 490
401 This function returns only attribute has View permission 491 This function returns only attribute has View permission
413 id: id of the object 503 id: id of the object
414 type: class name of the attribute 504 type: class name of the attribute
415 link: link to the attribute 505 link: link to the attribute
416 data: data of the requested attribute 506 data: data of the requested attribute
417 """ 507 """
508 if class_name not in self.db.classes:
509 raise NotFound('Class %s not found' % class_name)
418 if not self.db.security.hasPermission( 510 if not self.db.security.hasPermission(
419 'View', self.db.getuid(), class_name, attr_name, item_id 511 'View', self.db.getuid(), class_name, attr_name, item_id
420 ): 512 ):
421 raise Unauthorised( 513 raise Unauthorised(
422 'Permission to view %s%s %s denied' % 514 'Permission to view %s%s %s denied' %
433 'data': data 525 'data': data
434 } 526 }
435 527
436 return 200, result 528 return 200, result
437 529
530 @Routing.route("/data/<:class_name>", 'POST')
438 @_data_decorator 531 @_data_decorator
439 def post_collection(self, class_name, input): 532 def post_collection(self, class_name, input):
440 """POST a new object to a class 533 """POST a new object to a class
441 534
442 If the item is successfully created, the "Location" header will also 535 If the item is successfully created, the "Location" header will also
450 int: http status code 201 (Created) 543 int: http status code 201 (Created)
451 dict: a reference item to the created object 544 dict: a reference item to the created object
452 id: id of the object 545 id: id of the object
453 link: path to the object 546 link: path to the object
454 """ 547 """
548 if class_name not in self.db.classes:
549 raise NotFound('Class %s not found' % class_name)
455 if not self.db.security.hasPermission( 550 if not self.db.security.hasPermission(
456 'Create', self.db.getuid(), class_name 551 'Create', self.db.getuid(), class_name
457 ): 552 ):
458 raise Unauthorised('Permission to create %s denied' % class_name) 553 raise Unauthorised('Permission to create %s denied' % class_name)
459 554
493 'id': item_id, 588 'id': item_id,
494 'link': link 589 'link': link
495 } 590 }
496 return 201, result 591 return 201, result
497 592
498 @_data_decorator 593 @Routing.route("/data/<:class_name>/<:item_id>", 'PUT')
499 def post_element(self, class_name, item_id, input):
500 """POST to an object of a class is not allowed"""
501 raise Reject('POST to an item is not allowed')
502
503 @_data_decorator
504 def post_attribute(self, class_name, item_id, attr_name, input):
505 """POST to an attribute of an object is not allowed"""
506 raise Reject('POST to an attribute is not allowed')
507
508 @_data_decorator
509 def put_collection(self, class_name, input):
510 """PUT a class is not allowed"""
511 raise Reject('PUT a class is not allowed')
512
513 @_data_decorator 594 @_data_decorator
514 def put_element(self, class_name, item_id, input): 595 def put_element(self, class_name, item_id, input):
515 """PUT a new content to an object 596 """PUT a new content to an object
516 597
517 Replace the content of the existing object 598 Replace the content of the existing object
528 type: class name of the object 609 type: class name of the object
529 link: link to the object 610 link: link to the object
530 attributes: a dictionary represent only changed attributes of 611 attributes: a dictionary represent only changed attributes of
531 the object 612 the object
532 """ 613 """
614 if class_name not in self.db.classes:
615 raise NotFound('Class %s not found' % class_name)
533 class_obj = self.db.getclass(class_name) 616 class_obj = self.db.getclass(class_name)
534 617
535 props = self.props_from_args(class_obj, input.value, item_id) 618 props = self.props_from_args(class_obj, input.value, item_id)
536 for p in props.iterkeys(): 619 for p in props.iterkeys():
537 if not self.db.security.hasPermission( 620 if not self.db.security.hasPermission(
553 'link': self.base_path + class_name + item_id, 636 'link': self.base_path + class_name + item_id,
554 'attribute': result 637 'attribute': result
555 } 638 }
556 return 200, result 639 return 200, result
557 640
641 @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'PUT')
558 @_data_decorator 642 @_data_decorator
559 def put_attribute(self, class_name, item_id, attr_name, input): 643 def put_attribute(self, class_name, item_id, attr_name, input):
560 """PUT an attribute to an object 644 """PUT an attribute to an object
561 645
562 Args: 646 Args:
572 type: class name of the object 656 type: class name of the object
573 link: link to the object 657 link: link to the object
574 attributes: a dictionary represent only changed attributes of 658 attributes: a dictionary represent only changed attributes of
575 the object 659 the object
576 """ 660 """
661 if class_name not in self.db.classes:
662 raise NotFound('Class %s not found' % class_name)
577 if not self.db.security.hasPermission( 663 if not self.db.security.hasPermission(
578 'Edit', self.db.getuid(), class_name, attr_name, item_id 664 'Edit', self.db.getuid(), class_name, attr_name, item_id
579 ): 665 ):
580 raise Unauthorised( 666 raise Unauthorised(
581 'Permission to edit %s%s %s denied' % 667 'Permission to edit %s%s %s denied' %
602 'attribute': result 688 'attribute': result
603 } 689 }
604 690
605 return 200, result 691 return 200, result
606 692
693 @Routing.route("/data/<:class_name>", 'DELETE')
607 @_data_decorator 694 @_data_decorator
608 def delete_collection(self, class_name, input): 695 def delete_collection(self, class_name, input):
609 """DELETE all objects in a class 696 """DELETE all objects in a class
610 697
611 Args: 698 Args:
616 int: http status code 200 (OK) 703 int: http status code 200 (OK)
617 dict: 704 dict:
618 status (string): 'ok' 705 status (string): 'ok'
619 count (int): number of deleted objects 706 count (int): number of deleted objects
620 """ 707 """
708 if class_name not in self.db.classes:
709 raise NotFound('Class %s not found' % class_name)
621 if not self.db.security.hasPermission( 710 if not self.db.security.hasPermission(
622 'Delete', self.db.getuid(), class_name 711 'Delete', self.db.getuid(), class_name
623 ): 712 ):
624 raise Unauthorised('Permission to delete %s denied' % class_name) 713 raise Unauthorised('Permission to delete %s denied' % class_name)
625 714
642 'count': count 731 'count': count
643 } 732 }
644 733
645 return 200, result 734 return 200, result
646 735
736 @Routing.route("/data/<:class_name>/<:item_id>", 'DELETE')
647 @_data_decorator 737 @_data_decorator
648 def delete_element(self, class_name, item_id, input): 738 def delete_element(self, class_name, item_id, input):
649 """DELETE an object in a class 739 """DELETE an object in a class
650 740
651 Args: 741 Args:
656 Returns: 746 Returns:
657 int: http status code 200 (OK) 747 int: http status code 200 (OK)
658 dict: 748 dict:
659 status (string): 'ok' 749 status (string): 'ok'
660 """ 750 """
751 if class_name not in self.db.classes:
752 raise NotFound('Class %s not found' % class_name)
661 if not self.db.security.hasPermission( 753 if not self.db.security.hasPermission(
662 'Delete', self.db.getuid(), class_name, itemid=item_id 754 'Delete', self.db.getuid(), class_name, itemid=item_id
663 ): 755 ):
664 raise Unauthorised( 756 raise Unauthorised(
665 'Permission to delete %s %s denied' % (class_name, item_id) 757 'Permission to delete %s %s denied' % (class_name, item_id)
671 'status': 'ok' 763 'status': 'ok'
672 } 764 }
673 765
674 return 200, result 766 return 200, result
675 767
768 @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'DELETE')
676 @_data_decorator 769 @_data_decorator
677 def delete_attribute(self, class_name, item_id, attr_name, input): 770 def delete_attribute(self, class_name, item_id, attr_name, input):
678 """DELETE an attribute in a object by setting it to None or empty 771 """DELETE an attribute in a object by setting it to None or empty
679 772
680 Args: 773 Args:
686 Returns: 779 Returns:
687 int: http status code 200 (OK) 780 int: http status code 200 (OK)
688 dict: 781 dict:
689 status (string): 'ok' 782 status (string): 'ok'
690 """ 783 """
784 if class_name not in self.db.classes:
785 raise NotFound('Class %s not found' % class_name)
691 if not self.db.security.hasPermission( 786 if not self.db.security.hasPermission(
692 'Edit', self.db.getuid(), class_name, attr_name, item_id 787 'Edit', self.db.getuid(), class_name, attr_name, item_id
693 ): 788 ):
694 raise Unauthorised( 789 raise Unauthorised(
695 'Permission to delete %s%s %s denied' % 790 'Permission to delete %s%s %s denied' %
714 'status': 'ok' 809 'status': 'ok'
715 } 810 }
716 811
717 return 200, result 812 return 200, result
718 813
719 @_data_decorator 814 @Routing.route("/data/<:class_name>/<:item_id>", 'PATCH')
720 def patch_collection(self, class_name, input):
721 """PATCH a class is not allowed"""
722 raise Reject('PATCH a class is not allowed')
723
724 @_data_decorator 815 @_data_decorator
725 def patch_element(self, class_name, item_id, input): 816 def patch_element(self, class_name, item_id, input):
726 """PATCH an object 817 """PATCH an object
727 818
728 Patch an element using 3 operators 819 Patch an element using 3 operators
742 type: class name of the object 833 type: class name of the object
743 link: link to the object 834 link: link to the object
744 attributes: a dictionary represent only changed attributes of 835 attributes: a dictionary represent only changed attributes of
745 the object 836 the object
746 """ 837 """
838 if class_name not in self.db.classes:
839 raise NotFound('Class %s not found' % class_name)
747 try: 840 try:
748 op = input['op'].value.lower() 841 op = input['op'].value.lower()
749 except KeyError: 842 except KeyError:
750 op = self.__default_patch_op 843 op = self.__default_patch_op
751 class_obj = self.db.getclass(class_name) 844 class_obj = self.db.getclass(class_name)
777 'link': self.base_path + class_name + item_id, 870 'link': self.base_path + class_name + item_id,
778 'attribute': result 871 'attribute': result
779 } 872 }
780 return 200, result 873 return 200, result
781 874
875 @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'PATCH')
782 @_data_decorator 876 @_data_decorator
783 def patch_attribute(self, class_name, item_id, attr_name, input): 877 def patch_attribute(self, class_name, item_id, attr_name, input):
784 """PATCH an attribute of an object 878 """PATCH an attribute of an object
785 879
786 Patch an element using 3 operators 880 Patch an element using 3 operators
801 type: class name of the object 895 type: class name of the object
802 link: link to the object 896 link: link to the object
803 attributes: a dictionary represent only changed attributes of 897 attributes: a dictionary represent only changed attributes of
804 the object 898 the object
805 """ 899 """
900 if class_name not in self.db.classes:
901 raise NotFound('Class %s not found' % class_name)
806 try: 902 try:
807 op = input['op'].value.lower() 903 op = input['op'].value.lower()
808 except KeyError: 904 except KeyError:
809 op = self.__default_patch_op 905 op = self.__default_patch_op
810 906
840 'link': self.base_path + class_name + item_id, 936 'link': self.base_path + class_name + item_id,
841 'attribute': result 937 'attribute': result
842 } 938 }
843 return 200, result 939 return 200, result
844 940
941 @Routing.route("/data/<:class_name>", 'OPTIONS')
845 @_data_decorator 942 @_data_decorator
846 def options_collection(self, class_name, input): 943 def options_collection(self, class_name, input):
847 """OPTION return the HTTP Header for the class uri 944 """OPTION return the HTTP Header for the class uri
848 945
849 Returns: 946 Returns:
850 int: http status code 204 (No content) 947 int: http status code 204 (No content)
851 body (string): an empty string 948 body (string): an empty string
852 """ 949 """
950 if class_name not in self.db.classes:
951 raise NotFound('Class %s not found' % class_name)
853 return 204, "" 952 return 204, ""
854 953
954 @Routing.route("/data/<:class_name>/<:item_id>", 'OPTIONS')
855 @_data_decorator 955 @_data_decorator
856 def options_element(self, class_name, item_id, input): 956 def options_element(self, class_name, item_id, input):
857 """OPTION return the HTTP Header for the object uri 957 """OPTION return the HTTP Header for the object uri
858 958
859 Returns: 959 Returns:
860 int: http status code 204 (No content) 960 int: http status code 204 (No content)
861 body (string): an empty string 961 body (string): an empty string
862 """ 962 """
963 if class_name not in self.db.classes:
964 raise NotFound('Class %s not found' % class_name)
863 self.client.setHeader( 965 self.client.setHeader(
864 "Accept-Patch", 966 "Accept-Patch",
865 "application/x-www-form-urlencoded, multipart/form-data" 967 "application/x-www-form-urlencoded, multipart/form-data"
866 ) 968 )
867 return 204, "" 969 return 204, ""
868 970
971 @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'OPTIONS')
869 @_data_decorator 972 @_data_decorator
870 def option_attribute(self, class_name, item_id, attr_name, input): 973 def option_attribute(self, class_name, item_id, attr_name, input):
871 """OPTION return the HTTP Header for the attribute uri 974 """OPTION return the HTTP Header for the attribute uri
872 975
873 Returns: 976 Returns:
874 int: http status code 204 (No content) 977 int: http status code 204 (No content)
875 body (string): an empty string 978 body (string): an empty string
876 """ 979 """
980 if class_name not in self.db.classes:
981 raise NotFound('Class %s not found' % class_name)
877 self.client.setHeader( 982 self.client.setHeader(
878 "Accept-Patch", 983 "Accept-Patch",
879 "application/x-www-form-urlencoded, multipart/form-data" 984 "application/x-www-form-urlencoded, multipart/form-data"
880 ) 985 )
881 return 204, "" 986 return 204, ""
882 987
988 @Routing.route("/summary")
883 @_data_decorator 989 @_data_decorator
884 def summary(self, input): 990 def summary(self, input):
885 """Get a summary of resource from class URI. 991 """Get a summary of resource from class URI.
886 992
887 This function returns only items have View permission 993 This function returns only items have View permission
981 self.client.setHeader( 1087 self.client.setHeader(
982 "Access-Control-Allow-Methods", 1088 "Access-Control-Allow-Methods",
983 "HEAD, OPTIONS, GET, PUT, DELETE, PATCH" 1089 "HEAD, OPTIONS, GET, PUT, DELETE, PATCH"
984 ) 1090 )
985 1091
986 # PATH is split to multiple pieces
987 # 0 - rest
988 # 1 - data
989 # 2 - resource
990 # 3 - attribute
991 uri_split = uri.lower().split("/")
992
993 # Call the appropriate method 1092 # Call the appropriate method
994 if len(uri_split) == 2 and uri_split[1] == 'summary': 1093 try:
995 output = self.summary(input) 1094 output = Routing.execute(self, uri, method, input)
996 elif 4 >= len(uri_split) > 2 and uri_split[1] == 'data': 1095 except NotFound, msg:
997 resource_uri = uri_split[2] 1096 output = self.error_obj(404, msg)
998 try: 1097 except Reject, msg:
999 class_name, item_id = hyperdb.splitDesignator(resource_uri) 1098 output = self.error_obj(405, msg)
1000 except hyperdb.DesignatorError:
1001 class_name = resource_uri
1002 item_id = None
1003
1004 if class_name not in self.db.classes:
1005 output = self.error_obj(404, "Not found")
1006 elif item_id is None:
1007 if len(uri_split) == 3:
1008 output = getattr(
1009 self, "%s_collection" % method.lower()
1010 )(class_name, input)
1011 else:
1012 output = self.error_obj(404, "Not found")
1013 else:
1014 if len(uri_split) == 3:
1015 output = getattr(
1016 self, "%s_element" % method.lower()
1017 )(class_name, item_id, input)
1018 else:
1019 output = getattr(
1020 self, "%s_attribute" % method.lower()
1021 )(class_name, item_id, uri_split[3], input)
1022 else:
1023 output = self.error_obj(404, "Not found")
1024 1099
1025 # Format the content type 1100 # Format the content type
1026 if data_type.lower() == "json": 1101 if data_type.lower() == "json":
1027 self.client.setHeader("Content-Type", "application/json") 1102 self.client.setHeader("Content-Type", "application/json")
1028 if pretty_output: 1103 if pretty_output:

Roundup Issue Tracker: http://roundup-tracker.org/