Mercurial > p > roundup > code
comparison roundup/rest.py @ 5680:f77209ddd579
Refactored REST code that formats an item for display. A GET on
/class_name or /class_name/item_id use the same back end code. So both
now support use of @fields (or @attrs) as a comma (or colon) separated
list of property names to display.
@verbose is respected for all data for /class_name.
This also obscures passwords so they don't leak even in encrypted
form.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Fri, 29 Mar 2019 23:57:13 -0400 |
| parents | df9eb574b717 |
| children | 6457fd696a43 |
comparison
equal
deleted
inserted
replaced
| 5679:df9eb574b717 | 5680:f77209ddd579 |
|---|---|
| 469 item_id): | 469 item_id): |
| 470 raise PreconditionFailed( | 470 raise PreconditionFailed( |
| 471 "If-Match is missing or does not match." | 471 "If-Match is missing or does not match." |
| 472 " Retrieve asset and retry modification if valid.") | 472 " Retrieve asset and retry modification if valid.") |
| 473 | 473 |
| 474 @Routing.route("/data/<:class_name>", 'GET') | 474 def format_item(self, node, item_id, props=None, verbose=1): |
| 475 @_data_decorator | 475 ''' display class obj as requested by verbose and |
| 476 def get_collection(self, class_name, input): | 476 props. |
| 477 """GET resource from class URI. | 477 ''' |
| 478 | |
| 479 This function returns only items have View permission | |
| 480 class_name should be valid already | |
| 481 | |
| 482 Args: | |
| 483 class_name (string): class name of the resource (Ex: issue, msg) | |
| 484 input (list): the submitted form of the user | |
| 485 | |
| 486 Returns: | |
| 487 int: http status code 200 (OK) | |
| 488 list: list of reference item in the class | |
| 489 id: id of the object | |
| 490 link: path to the object | |
| 491 """ | |
| 492 if class_name not in self.db.classes: | |
| 493 raise NotFound('Class %s not found' % class_name) | |
| 494 | |
| 495 uid = self.db.getuid() | 478 uid = self.db.getuid() |
| 496 | 479 class_name = node.cl.classname |
| 497 if not self.db.security.hasPermission( | |
| 498 'View', uid, class_name | |
| 499 ): | |
| 500 raise Unauthorised('Permission to view %s denied' % class_name) | |
| 501 | |
| 502 class_obj = self.db.getclass(class_name) | |
| 503 class_path = '%s/%s/' % (self.data_path, class_name) | |
| 504 | |
| 505 # Handle filtering and pagination | |
| 506 filter_props = {} | |
| 507 page = { | |
| 508 'size': None, | |
| 509 'index': 1 # setting just size starts at page 1 | |
| 510 } | |
| 511 verbose = 1 | |
| 512 for form_field in input.value: | |
| 513 key = form_field.name | |
| 514 value = form_field.value | |
| 515 if key.startswith("@page_"): # serve the paging purpose | |
| 516 key = key[6:] | |
| 517 value = int(value) | |
| 518 page[key] = value | |
| 519 elif key == "@verbose": | |
| 520 verbose = int (value) | |
| 521 else: # serve the filter purpose | |
| 522 prop = class_obj.getprops()[key] | |
| 523 # We drop properties without search permission silently | |
| 524 # This reflects the current behavior of other roundup | |
| 525 # interfaces | |
| 526 if not self.db.security.hasSearchPermission( | |
| 527 uid, class_name, key | |
| 528 ): | |
| 529 continue | |
| 530 if isinstance (prop, (hyperdb.Link, hyperdb.Multilink)): | |
| 531 vals = [] | |
| 532 linkcls = self.db.getclass (prop.classname) | |
| 533 for p in value.split(","): | |
| 534 if prop.try_id_parsing and p.isdigit(): | |
| 535 vals.append(p) | |
| 536 else: | |
| 537 vals.append(linkcls.lookup(p)) | |
| 538 filter_props[key] = vals | |
| 539 else: | |
| 540 filter_props[key] = value | |
| 541 if not filter_props: | |
| 542 obj_list = class_obj.list() | |
| 543 else: | |
| 544 obj_list = class_obj.filter(None, filter_props) | |
| 545 | |
| 546 # extract result from data | |
| 547 result={} | |
| 548 result['collection'] = [ | |
| 549 {'id': item_id, 'link': class_path + item_id} | |
| 550 for item_id in obj_list | |
| 551 if self.db.security.hasPermission( | |
| 552 'View', uid, class_name, itemid=item_id | |
| 553 ) | |
| 554 ] | |
| 555 | |
| 556 # add verbose elements. First identifying label. | |
| 557 if verbose > 1: | |
| 558 label = class_obj.labelprop() | |
| 559 for obj in result['collection']: | |
| 560 id = obj['id'] | |
| 561 if self.db.security.hasPermission( | |
| 562 'View', uid, class_name, property=label, | |
| 563 itemid=id | |
| 564 ): | |
| 565 obj[label] = class_obj.get(id, label) | |
| 566 | |
| 567 result_len = len(result['collection']) | |
| 568 | |
| 569 # pagination - page_index from 1...N | |
| 570 if page['size'] is not None: | |
| 571 page_start = max((page['index']-1) * page['size'], 0) | |
| 572 page_end = min(page_start + page['size'], result_len) | |
| 573 result['collection'] = result['collection'][page_start:page_end] | |
| 574 result['@links'] = {} | |
| 575 for rel in ('next', 'prev', 'self'): | |
| 576 if rel == 'next': | |
| 577 # if current index includes all data, continue | |
| 578 if page['index']*page['size'] > result_len: continue | |
| 579 index=page['index']+1 | |
| 580 if rel == 'prev': | |
| 581 if page['index'] <= 1: continue | |
| 582 index=page['index']-1 | |
| 583 if rel == 'self': index=page['index'] | |
| 584 | |
| 585 result['@links'][rel] = [] | |
| 586 result['@links'][rel].append({ | |
| 587 'rel': rel, | |
| 588 'uri': "%s/%s?@page_index=%s&"%(self.data_path, | |
| 589 class_name,index) \ | |
| 590 + '&'.join([ "%s=%s"%(field.name,field.value) \ | |
| 591 for field in input.value \ | |
| 592 if field.name != "@page_index"]) }) | |
| 593 | |
| 594 result['@total_size'] = result_len | |
| 595 self.client.setHeader("X-Count-Total", str(result_len)) | |
| 596 return 200, result | |
| 597 | |
| 598 @Routing.route("/data/<:class_name>/<:item_id>", 'GET') | |
| 599 @_data_decorator | |
| 600 def get_element(self, class_name, item_id, input): | |
| 601 """GET resource from object URI. | |
| 602 | |
| 603 This function returns only properties have View permission | |
| 604 class_name and item_id should be valid already | |
| 605 | |
| 606 Args: | |
| 607 class_name (string): class name of the resource (Ex: issue, msg) | |
| 608 item_id (string): id of the resource (Ex: 12, 15) | |
| 609 or (if the class has a key property) this can also be | |
| 610 the key name, e.g. class_name = status, item_id = 'open' | |
| 611 input (list): the submitted form of the user | |
| 612 | |
| 613 Returns: | |
| 614 int: http status code 200 (OK) | |
| 615 dict: a dictionary represents the object | |
| 616 id: id of the object | |
| 617 type: class name of the object | |
| 618 link: link to the object | |
| 619 attributes: a dictionary represent the attributes of the object | |
| 620 """ | |
| 621 if class_name not in self.db.classes: | |
| 622 raise NotFound('Class %s not found' % class_name) | |
| 623 class_obj = self.db.getclass(class_name) | |
| 624 uid = self.db.getuid() | |
| 625 # If it's not numeric it is a key | |
| 626 if item_id.isdigit(): | |
| 627 itemid = item_id | |
| 628 else: | |
| 629 keyprop = class_obj.getkey() | |
| 630 try: | |
| 631 k, v = item_id.split('=', 1) | |
| 632 if k != keyprop: | |
| 633 raise UsageError ("Not key property") | |
| 634 except ValueError: | |
| 635 v = item_id | |
| 636 pass | |
| 637 if not self.db.security.hasPermission( | |
| 638 'View', uid, class_name, itemid=item_id, property=keyprop | |
| 639 ): | |
| 640 raise Unauthorised( | |
| 641 'Permission to view %s%s.%s denied' | |
| 642 % (class_name, item_id, keyprop) | |
| 643 ) | |
| 644 itemid = class_obj.lookup(v) | |
| 645 if not self.db.security.hasPermission( | |
| 646 'View', uid, class_name, itemid=itemid | |
| 647 ): | |
| 648 raise Unauthorised( | |
| 649 'Permission to view %s%s denied' % (class_name, itemid) | |
| 650 ) | |
| 651 | |
| 652 node = class_obj.getnode(itemid) | |
| 653 etag = calculate_etag(node, class_name, itemid) | |
| 654 props = None | |
| 655 protected=False | |
| 656 verbose=1 | |
| 657 for form_field in input.value: | |
| 658 key = form_field.name | |
| 659 value = form_field.value | |
| 660 if key == "@fields": | |
| 661 props = value.split(",") | |
| 662 if key == "@protected": | |
| 663 # allow client to request read only | |
| 664 # properties like creator, activity etc. | |
| 665 protected = value.lower() == "true" | |
| 666 if key == "@verbose": | |
| 667 verbose = int (value) | |
| 668 | |
| 669 result = {} | 480 result = {} |
| 670 if props is None: | |
| 671 props = class_obj.getprops(protected=protected) | |
| 672 | |
| 673 try: | 481 try: |
| 482 # pn = propname | |
| 674 for pn in sorted(props): | 483 for pn in sorted(props): |
| 675 prop = props[pn] | 484 prop = props[pn] |
| 676 if not self.db.security.hasPermission( | 485 if not self.db.security.hasPermission( |
| 677 'View', uid, class_name, pn, itemid | 486 'View', uid, class_name, pn, item_id |
| 678 ): | 487 ): |
| 679 continue | 488 continue |
| 680 v = getattr(node, pn) | 489 v = getattr(node, pn) |
| 681 if isinstance (prop, (hyperdb.Link, hyperdb.Multilink)): | 490 if isinstance (prop, (hyperdb.Link, hyperdb.Multilink)): |
| 682 linkcls = self.db.getclass (prop.classname) | 491 linkcls = self.db.getclass (prop.classname) |
| 706 u = self.db.config.TRACKER_WEB | 515 u = self.db.config.TRACKER_WEB |
| 707 p = u + '%s%s/' % (class_name, node.id) | 516 p = u + '%s%s/' % (class_name, node.id) |
| 708 result[pn] = dict(link = p) | 517 result[pn] = dict(link = p) |
| 709 else: | 518 else: |
| 710 result[pn] = v | 519 result[pn] = v |
| 520 elif isinstance(prop, hyperdb.Password): | |
| 521 if v != None: # locked users like anonymous have None | |
| 522 result[pn] = "[password hidden scheme %s]"%v.scheme | |
| 523 else: | |
| 524 # Don't divulge it's a locked account. Choose most | |
| 525 # secure as default. | |
| 526 result[pn] = "[password hidden scheme PBKDF2]" | |
| 711 else: | 527 else: |
| 712 result[pn] = v | 528 result[pn] = v |
| 713 except KeyError as msg: | 529 except KeyError as msg: |
| 714 raise UsageError("%s field not valid" % msg) | 530 raise UsageError("%s field not valid" % msg) |
| 531 | |
| 532 return result | |
| 533 | |
| 534 | |
| 535 @Routing.route("/data/<:class_name>", 'GET') | |
| 536 @_data_decorator | |
| 537 def get_collection(self, class_name, input): | |
| 538 """GET resource from class URI. | |
| 539 | |
| 540 This function returns only items have View permission | |
| 541 class_name should be valid already | |
| 542 | |
| 543 Args: | |
| 544 class_name (string): class name of the resource (Ex: issue, msg) | |
| 545 input (list): the submitted form of the user | |
| 546 | |
| 547 Returns: | |
| 548 int: http status code 200 (OK) | |
| 549 list: list of reference item in the class | |
| 550 id: id of the object | |
| 551 link: path to the object | |
| 552 """ | |
| 553 if class_name not in self.db.classes: | |
| 554 raise NotFound('Class %s not found' % class_name) | |
| 555 | |
| 556 uid = self.db.getuid() | |
| 557 | |
| 558 if not self.db.security.hasPermission( | |
| 559 'View', uid, class_name | |
| 560 ): | |
| 561 raise Unauthorised('Permission to view %s denied' % class_name) | |
| 562 | |
| 563 class_obj = self.db.getclass(class_name) | |
| 564 class_path = '%s/%s/' % (self.data_path, class_name) | |
| 565 | |
| 566 # Handle filtering and pagination | |
| 567 filter_props = {} | |
| 568 page = { | |
| 569 'size': None, | |
| 570 'index': 1 # setting just size starts at page 1 | |
| 571 } | |
| 572 verbose = 1 | |
| 573 display_props = {} | |
| 574 for form_field in input.value: | |
| 575 key = form_field.name | |
| 576 value = form_field.value | |
| 577 if key.startswith("@page_"): # serve the paging purpose | |
| 578 key = key[6:] | |
| 579 value = int(value) | |
| 580 page[key] = value | |
| 581 elif key == "@verbose": | |
| 582 verbose = int (value) | |
| 583 elif key == "@fields" or key == "@attrs": | |
| 584 f = value.split(",") | |
| 585 if len(f) == 1: | |
| 586 f=value.split(",") | |
| 587 for i in f: | |
| 588 display_props[i] = class_obj.properties[i] | |
| 589 else: # serve the filter purpose | |
| 590 prop = class_obj.getprops()[key] | |
| 591 # We drop properties without search permission silently | |
| 592 # This reflects the current behavior of other roundup | |
| 593 # interfaces | |
| 594 if not self.db.security.hasSearchPermission( | |
| 595 uid, class_name, key | |
| 596 ): | |
| 597 continue | |
| 598 if isinstance (prop, (hyperdb.Link, hyperdb.Multilink)): | |
| 599 vals = [] | |
| 600 linkcls = self.db.getclass (prop.classname) | |
| 601 for p in value.split(","): | |
| 602 if prop.try_id_parsing and p.isdigit(): | |
| 603 vals.append(p) | |
| 604 else: | |
| 605 vals.append(linkcls.lookup(p)) | |
| 606 filter_props[key] = vals | |
| 607 else: | |
| 608 filter_props[key] = value | |
| 609 if not filter_props: | |
| 610 obj_list = class_obj.list() | |
| 611 else: | |
| 612 obj_list = class_obj.filter(None, filter_props) | |
| 613 | |
| 614 # Sort list as specified by sortorder | |
| 615 # This is more useful for things where there is an | |
| 616 # explicit order. E.G. status has an order that is | |
| 617 # roughly the progression of the issue through | |
| 618 # the states so open is before closed. | |
| 619 obj_list.sort() | |
| 620 | |
| 621 # add verbose elements. 2 and above get identifying label. | |
| 622 if verbose > 1: | |
| 623 lp = class_obj.labelprop() | |
| 624 display_props[lp] = class_obj.properties[lp] | |
| 625 | |
| 626 # extract result from data | |
| 627 result={} | |
| 628 result['collection']=[] | |
| 629 for item_id in obj_list: | |
| 630 if self.db.security.hasPermission( | |
| 631 'View', uid, class_name, itemid=item_id): | |
| 632 r = {'id': item_id, 'link': class_path + item_id} | |
| 633 if display_props: | |
| 634 r.update(self.format_item(class_obj.getnode(item_id), | |
| 635 item_id, | |
| 636 props=display_props, | |
| 637 verbose=verbose)) | |
| 638 result['collection'].append(r) | |
| 639 | |
| 640 result_len = len(result['collection']) | |
| 641 | |
| 642 # pagination - page_index from 1...N | |
| 643 if page['size'] is not None: | |
| 644 page_start = max((page['index']-1) * page['size'], 0) | |
| 645 page_end = min(page_start + page['size'], result_len) | |
| 646 result['collection'] = result['collection'][page_start:page_end] | |
| 647 result['@links'] = {} | |
| 648 for rel in ('next', 'prev', 'self'): | |
| 649 if rel == 'next': | |
| 650 # if current index includes all data, continue | |
| 651 if page['index']*page['size'] > result_len: continue | |
| 652 index=page['index']+1 | |
| 653 if rel == 'prev': | |
| 654 if page['index'] <= 1: continue | |
| 655 index=page['index']-1 | |
| 656 if rel == 'self': index=page['index'] | |
| 657 | |
| 658 result['@links'][rel] = [] | |
| 659 result['@links'][rel].append({ | |
| 660 'rel': rel, | |
| 661 'uri': "%s/%s?@page_index=%s&"%(self.data_path, | |
| 662 class_name,index) \ | |
| 663 + '&'.join([ "%s=%s"%(field.name,field.value) \ | |
| 664 for field in input.value \ | |
| 665 if field.name != "@page_index"]) }) | |
| 666 | |
| 667 result['@total_size'] = result_len | |
| 668 self.client.setHeader("X-Count-Total", str(result_len)) | |
| 669 return 200, result | |
| 670 | |
| 671 @Routing.route("/data/<:class_name>/<:item_id>", 'GET') | |
| 672 @_data_decorator | |
| 673 def get_element(self, class_name, item_id, input): | |
| 674 """GET resource from object URI. | |
| 675 | |
| 676 This function returns only properties have View permission | |
| 677 class_name and item_id should be valid already | |
| 678 | |
| 679 Args: | |
| 680 class_name (string): class name of the resource (Ex: issue, msg) | |
| 681 item_id (string): id of the resource (Ex: 12, 15) | |
| 682 or (if the class has a key property) this can also be | |
| 683 the key name, e.g. class_name = status, item_id = 'open' | |
| 684 input (list): the submitted form of the user | |
| 685 | |
| 686 Returns: | |
| 687 int: http status code 200 (OK) | |
| 688 dict: a dictionary represents the object | |
| 689 id: id of the object | |
| 690 type: class name of the object | |
| 691 link: link to the object | |
| 692 attributes: a dictionary represent the attributes of the object | |
| 693 """ | |
| 694 if class_name not in self.db.classes: | |
| 695 raise NotFound('Class %s not found' % class_name) | |
| 696 class_obj = self.db.getclass(class_name) | |
| 697 uid = self.db.getuid() | |
| 698 # If it's not numeric it is a key | |
| 699 if item_id.isdigit(): | |
| 700 itemid = item_id | |
| 701 else: | |
| 702 keyprop = class_obj.getkey() | |
| 703 try: | |
| 704 k, v = item_id.split('=', 1) | |
| 705 if k != keyprop: | |
| 706 raise UsageError ("Not key property") | |
| 707 except ValueError: | |
| 708 v = item_id | |
| 709 pass | |
| 710 if not self.db.security.hasPermission( | |
| 711 'View', uid, class_name, itemid=item_id, property=keyprop | |
| 712 ): | |
| 713 raise Unauthorised( | |
| 714 'Permission to view %s%s.%s denied' | |
| 715 % (class_name, item_id, keyprop) | |
| 716 ) | |
| 717 itemid = class_obj.lookup(v) | |
| 718 if not self.db.security.hasPermission( | |
| 719 'View', uid, class_name, itemid=itemid | |
| 720 ): | |
| 721 raise Unauthorised( | |
| 722 'Permission to view %s%s denied' % (class_name, itemid) | |
| 723 ) | |
| 724 | |
| 725 node = class_obj.getnode(itemid) | |
| 726 etag = calculate_etag(node, class_name, itemid) | |
| 727 props = None | |
| 728 protected=False | |
| 729 verbose=1 | |
| 730 for form_field in input.value: | |
| 731 key = form_field.name | |
| 732 value = form_field.value | |
| 733 if key == "@fields" or key == "@attrs": | |
| 734 if props is None: | |
| 735 props = {} | |
| 736 # support , or : separated elements | |
| 737 f=value.split(",") | |
| 738 if len(f) == 1: | |
| 739 f=value.split(":") | |
| 740 for i in f: | |
| 741 props[i] = class_obj.properties[i] | |
| 742 elif key == "@protected": | |
| 743 # allow client to request read only | |
| 744 # properties like creator, activity etc. | |
| 745 # used only if no @fields/@attrs | |
| 746 protected = value.lower() == "true" | |
| 747 elif key == "@verbose": | |
| 748 verbose = int (value) | |
| 749 | |
| 750 result = {} | |
| 751 if props is None: | |
| 752 props = class_obj.getprops(protected=protected) | |
| 753 else: | |
| 754 if verbose > 1: | |
| 755 lp = class_obj.labelprop() | |
| 756 props[lp] = class_obj.properties[lp] | |
| 757 | |
| 715 result = { | 758 result = { |
| 716 'id': itemid, | 759 'id': itemid, |
| 717 'type': class_name, | 760 'type': class_name, |
| 718 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), | 761 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), |
| 719 'attributes': dict(result), | 762 'attributes': self.format_item(node, itemid, props=props, |
| 763 verbose=verbose), | |
| 720 '@etag': etag | 764 '@etag': etag |
| 721 } | 765 } |
| 722 | 766 |
| 723 self.client.setHeader("ETag", etag) | 767 self.client.setHeader("ETag", etag) |
| 724 return 200, result | 768 return 200, result |
