[Rt-commit] rt 01/01: Import changes of RT-Extension-REST2

sunnavy sunnavy at bestpractical.com
Mon Jul 19 21:34:01 UTC 2021


This is an automated email from the git hooks/post-receive script.

sunnavy pushed a commit to branch 5.0/import-rest2-extension-changes
in repository rt.

commit df5092aa7525fd4a34b0092ffc3e0132429f9ece
Author: sunnavy <sunnavy at bestpractical.com>
AuthorDate: Tue Jul 20 03:12:29 2021 +0800

    Import changes of RT-Extension-REST2
    
    The last imported commit is 7a0e515b1d. Besides various fixes, it mainly
    includes the following new features:
    
        Support CustomRoles updates on correspond/comment
        Support Status updates on correspond/comment
        Support _hyperlinks field in collection
        Support roles for ticket/asset searches
        Support additional fields parameters for Roles and CustomFields in Collection
        Support to search tickets from saved searches
        Add /searches/ and /search/ endpoits for saved searches
        Add /tickets/bulk/correspond and /tickets/bulk/comment endpoints
---
 lib/RT/REST2.pm                                   | 123 ++++++++++-
 lib/RT/REST2/Resource.pm                          |  75 ++++++-
 lib/RT/REST2/Resource/Assets.pm                   |  22 ++
 lib/RT/REST2/Resource/Collection.pm               |  55 ++++-
 lib/RT/REST2/Resource/Collection/Search.pm        | 209 ++++++++++++++++++
 lib/RT/REST2/Resource/Message.pm                  |  61 +++++-
 lib/RT/REST2/Resource/Record/Hypermedia.pm        |   3 +-
 lib/RT/REST2/Resource/Record/Writable.pm          | 122 +----------
 lib/RT/REST2/Resource/Search.pm                   | 158 ++++++++++++++
 lib/RT/REST2/Resource/{Tickets.pm => Searches.pm} | 102 +++++----
 lib/RT/REST2/Resource/Ticket.pm                   |  50 +++--
 lib/RT/REST2/Resource/Tickets.pm                  |  38 ++--
 lib/RT/REST2/Resource/TicketsBulk.pm              |  72 +++++-
 lib/RT/REST2/Util.pm                              | 156 ++++++++++++-
 lib/RT/Test/REST2.pm                              |   1 +
 t/rest2/assets.t                                  |  34 +++
 t/rest2/searches.t                                | 215 ++++++++++++++++++
 t/rest2/ticket-correspond-customroles.t           | 254 ++++++++++++++++++++++
 t/rest2/ticket-customroles.t                      |  42 +++-
 t/rest2/ticket-links.t                            |  15 +-
 t/rest2/tickets-bulk.t                            |  76 +++++++
 t/rest2/tickets.t                                 | 146 ++++++++++++-
 22 files changed, 1794 insertions(+), 235 deletions(-)

diff --git a/lib/RT/REST2.pm b/lib/RT/REST2.pm
index f8ed84ea5e..abeef7d3b0 100644
--- a/lib/RT/REST2.pm
+++ b/lib/RT/REST2.pm
@@ -290,6 +290,37 @@ below).
 
 The time, in minutes, you've taken to work on your response/comment, optional.
 
+=item C<Status>
+
+The new status (for example, "open", "rejected", etc.) to set the
+ticket to.  The Status value must be a valid status based on the
+lifecycle of the ticket's current queue.
+
+=item C<CustomRoles>
+
+A hash whose keys are custom role names and values are as described below:
+
+For a single-value custom role, the value must be a string representing an
+email address or user name; the custom role is set to the user with
+that email address or user name.
+
+For a multi-value custom role, the value can be a string representing
+an email address or user name, or can be an array of email addresses
+or user names; in either case, the members of the custom role are set
+to the corresponding users.
+
+=item C<CustomFields>
+
+A hash similar to the C<CustomRoles> hash, but whose keys are custom
+field names that apply to the Ticket; those fields are set to the
+supplied values.
+
+=item C<TxnCustomFields>
+
+A hash similar to the C<CustomRoles> hash, but whose keys are custom
+field names that apply to the Transaction; those fields are set
+to the supplied values.
+
 =back
 
 =head3 Add Attachments
@@ -454,8 +485,19 @@ curl for SSL like --cacert.
     GET /tickets?simple=1;query=<simple search query>
         search for tickets using simple search syntax
 
+    # If there are multiple saved searches using the same description, the
+    # behavior of "which saved search shall be selected" is undefined, use
+    # id instead in this case.
+
+    # If both search and other arguments like "query" are specified, the
+    # latter takes higher precedence than the corresponding fields defined
+    # in the given saved search.
+
+    GET /tickets?search=<saved search id or description>
+        search for tickets using saved search
+
     POST /tickets
-        search for tickets with the 'query' and optional 'simple' parameters
+        search for tickets with the 'search' or 'query' and optional 'simple' parameters 
 
     POST /ticket
         create a ticket; provide JSON content
@@ -487,6 +529,10 @@ curl for SSL like --cacert.
     PUT /tickets/bulk
         update multiple tickets' metadata; provide JSON content(array of hashes)
 
+    POST /tickets/bulk/correspond
+    POST /tickets/bulk/comment
+        add a reply or comment to multiple tickets; provide JSON content(array of hashes)
+
 =head3 Ticket Examples
 
 Below are some examples using the endpoints above.
@@ -542,7 +588,7 @@ Below are some examples using the endpoints above.
         'https://myrt.com/REST/2.0/ticket/6/correspond'
 
     # Comment on a ticket
-    curl -X POST -H "Content-Type: text/plain" -u 'root:password'
+    curl -X POST -H "Content-Type: application/json" -u 'root:password'
         -d 'Testing a comment'
         'https://myrt.com/REST/2.0/ticket/6/comment'
 
@@ -551,6 +597,21 @@ Below are some examples using the endpoints above.
         -d '{ "Content": "Testing a comment", "ContentType": "text/plain", "CustomFields": {"Severity": "High"} }'
         'https://myrt.com/REST/2.0/ticket/6/comment'
 
+    # Comment on a ticket with custom role update
+    curl -X POST -H "Content-Type: application/json" -u 'root:password'
+        -d '{ "Content": "Testing a comment", "ContentType": "text/plain", "CustomRoles": {"Manager": "manager at example.com"} }'
+        'https://myrt.com/REST/2.0/ticket/6/comment'
+
+    # Update many tickets at once with bulk by sending an array with ticket ids
+    # Results are returned for each update in a JSON array with ticket ids and corresponding messages
+    curl -X POST -H "Content-Type: application/json" -u 'root:password'
+        -d '[{ "id": "20", "Content": "Testing a correspondence", "ContentType": "text/plain" },
+             { "id": "18", "Content": "Testing a correspondence", "ContentType": "text/plain", "Status":"resolved", "CustomRoles": {"Manager": "manager at example.com"}, "CustomFields": {"State": "New York"} }]'
+        'https://myrt.com/REST/2.0/tickets/bulk/correspond'
+
+    [["20","Correspondence added"],["18","Correspondence added","State New York added","Added manager at example.com as Manager for this ticket","Status changedfrom 'open' to 'resolved'"]]
+
+
 =head3 Ticket Fields
 
 The following describes some of the values you can send when creating and updating
@@ -698,6 +759,20 @@ Below are some examples using the endpoints above.
     curl -X POST -u 'root:password' -d "query=Catalog='General assets' AND 'CF.{Asset Type}' LIKE 'Computer'"
         'https://myrt.com/REST/2.0/assets'
 
+=head3 Assets Examples
+
+Below are some examples using the endpoints above.
+
+    # Create an Asset
+    curl -X POST -H "Content-Type: application/json" -u 'root:password'
+        -d '{"Name" : "Asset From Rest", "Catalog" : "General assets", "Content" : "Some content"}'
+        'https://myrt.com/REST/2.0/asset'
+
+    # Search Assets
+    curl -X POST -H "Content-Type: application/json" -u 'root:password'
+    -d '[{ "field" : "id", "operator" : ">=", "value" : 0 }]'
+    'https://myrt.com/REST/2.0/assets'
+
 =head3 Catalogs
 
     GET /catalogs/all
@@ -920,6 +995,16 @@ Below are some examples using the endpoints above.
     GET /customrole/:id
         retrieve a custom role
 
+=head3 Saved Searches
+
+    GET /searches?query=<JSON>
+    POST /searches
+        search for saved searches using L</JSON searches> syntax
+
+    GET /search/:id
+    GET /search/:description
+        retrieve a saved search
+
 =head3 Miscellaneous
 
     GET /
@@ -1219,14 +1304,14 @@ You can use additional fields parameters to expand child blocks, for
 example (line wrapping inserted for readability):
 
     XX_RT_URL_XX/REST/2.0/tickets
-      ?fields=Owner,Status,Created,Subject,Queue,CustomFields
+      ?fields=Owner,Status,Created,Subject,Queue,CustomFields,Requestor,Cc,AdminCc,RT::CustomRole-1
       &fields[Queue]=Name,Description
 
 Says that in the result set for tickets, the extra fields for Owner, Status,
-Created, Subject, Queue and CustomFields should be included. But in
-addition, for the Queue block, also include Name and Description. The
-results would be similar to this (only one ticket is displayed in this
-example):
+Created, Subject, Queue, CustomFields, Requestor, Cc, AdminCc and
+CustomRoles should be included. But in addition, for the Queue block, also
+include Name and Description. The results would be similar to this (only one
+ticket is displayed in this example):
 
    "items" : [
       {
@@ -1256,8 +1341,30 @@ example):
                  "name" : "My Custom Field",
                  "values" : [
                      "CustomField value"
-                 },
+                 ]
              }
+         ],
+         "Requestor" : [
+            {
+               "id" : "root",
+               "type" : "user",
+               "_url" : "XX_RT_URL_XX/REST/2.0/user/root"
+            }
+         ],
+         "Cc" : [
+            {
+               "id" : "root",
+               "type" : "user",
+               "_url" : "XX_RT_URL_XX/REST/2.0/user/root"
+            }
+         ],
+         "AdminCc" : [],
+         "RT::CustomRole-1" : [
+            {
+               "_url" : "XX_RT_URL_XX/REST/2.0/user/foo at example.com",
+               "type" : "user",
+               "id" : "foo at example.com"
+            }
          ]
       }
       { … },
diff --git a/lib/RT/REST2/Resource.pm b/lib/RT/REST2/Resource.pm
index 1159ba7802..777023404c 100644
--- a/lib/RT/REST2/Resource.pm
+++ b/lib/RT/REST2/Resource.pm
@@ -84,7 +84,7 @@ sub expand_field {
             while (my $cf = $cfs->Next) {
                 if (! defined $values{$cf->Id}) {
                     $values{$cf->Id} = {
-                        %{ expand_uid($cf->UID) },
+                        %{ $self->_expand_object($cf, $field, $param_prefix) },
                         name   => $cf->Name,
                         values => [],
                     };
@@ -112,6 +112,50 @@ sub expand_field {
         }
     } elsif ($field eq 'ContentLength' && $item->can('ContentLength')) {
         $result = $item->ContentLength;
+    } elsif ($field eq 'CustomRoles') {
+        if ( $item->DOES("RT::Record::Role::Roles") ) {
+            my %data;
+            for my $role ( $item->Roles( ACLOnly => 0 ) ) {
+                next unless $role =~ /^RT::CustomRole-/;
+                $data{$role} = [];
+
+                my $group = $item->RoleGroup($role);
+                if ( !$group->Id ) {
+                    $data{$role} = $self->_expand_object( RT->Nobody->UserObj, $field, $param_prefix )
+                        if $item->_ROLES->{$role}{Single};
+                    next;
+                }
+
+                my $gms = $group->MembersObj;
+                while ( my $gm = $gms->Next ) {
+                    push @{ $data{$role} }, $self->_expand_object( $gm->MemberObj->Object, $field, $param_prefix );
+                }
+
+                # Avoid the extra array ref for single member roles
+                $data{$role} = shift @{$data{$role}} if $group->SingleMemberRoleGroup;
+            }
+            return \%data;
+        }
+    } elsif ($field =~ /^RT::CustomRole-\d+$/) {
+        if ( $item->DOES("RT::Record::Role::Roles") ) {
+            my $result = [];
+
+            my $group = $item->RoleGroup($field);
+            if ( !$group->Id ) {
+                $result = $self->_expand_object( RT->Nobody->UserObj, $field, $param_prefix )
+                    if $item->_ROLES->{$field}{Single};
+                next;
+            }
+
+            my $gms = $group->MembersObj;
+            while ( my $gm = $gms->Next ) {
+                push @$result, $self->_expand_object( $gm->MemberObj->Object, $field, $param_prefix );
+            }
+
+            # Avoid the extra array ref for single member roles
+            $result = shift @$result if $group->SingleMemberRoleGroup;
+            return $result;
+        }
     } elsif ($item->can('_Accessible') && $item->_Accessible($field => 'read')) {
         # RT::Record derived object, so we can check access permissions.
 
@@ -120,14 +164,8 @@ sub expand_field {
         } elsif ($item->can($field . 'Obj')) {
             my $method = $field . 'Obj';
             my $obj = $item->$method;
-            if ( $obj->can('UID') and $result = expand_uid( $obj->UID ) ) {
-                my $param_field = $param_prefix . '[' . $field . ']';
-                my @subfields = split( /,/, $self->request->param($param_field) || '' );
-
-                for my $subfield (@subfields) {
-                    my $subfield_result = $self->expand_field( $obj, $subfield, $param_field );
-                    $result->{$subfield} = $subfield_result if defined $subfield_result;
-                }
+            if ( $obj->can('UID') ) {
+                $result = $self->_expand_object( $obj, $field, $param_prefix );
             }
         }
 
@@ -137,6 +175,25 @@ sub expand_field {
     return $result // '';
 }
 
+sub _expand_object {
+    my $self         = shift;
+    my $object       = shift;
+    my $field        = shift;
+    my $param_prefix = shift || 'fields';
+
+    return unless $object->can('UID');
+
+    my $result      = expand_uid( $object->UID ) or return;
+    my $param_field = $param_prefix . '[' . $field . ']';
+    my @subfields   = split( /,/, $self->request->param($param_field) || '' );
+
+    for my $subfield (@subfields) {
+        my $subfield_result = $self->expand_field( $object, $subfield, $param_field );
+        $result->{$subfield} = $subfield_result if defined $subfield_result;
+    }
+    return $result;
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/REST2/Resource/Assets.pm b/lib/RT/REST2/Resource/Assets.pm
index 0c3069fd38..50360d8926 100644
--- a/lib/RT/REST2/Resource/Assets.pm
+++ b/lib/RT/REST2/Resource/Assets.pm
@@ -64,6 +64,28 @@ sub dispatch_rules {
     )
 }
 
+use RT::REST2::Util qw( expand_uid );
+
+sub expand_field {
+    my $self         = shift;
+    my $item         = shift;
+    my $field        = shift;
+    my $param_prefix = shift;
+    if ( $field =~ /^(Owner|HeldBy|Contact)/ ) {
+        my $role    = $1;
+        my $members = [];
+        if ( my $group = $item->RoleGroup($role) ) {
+            my $gms = $group->MembersObj;
+            while ( my $gm = $gms->Next ) {
+                push @$members, $self->_expand_object( $gm->MemberObj->Object, $field, $param_prefix );
+            }
+            $members = shift @$members if $group->SingleMemberRoleGroup;
+        }
+        return $members;
+    }
+    return $self->SUPER::expand_field( $item, $field, $param_prefix );
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/REST2/Resource/Collection.pm b/lib/RT/REST2/Resource/Collection.pm
index e7feaffc19..587efd9829 100644
--- a/lib/RT/REST2/Resource/Collection.pm
+++ b/lib/RT/REST2/Resource/Collection.pm
@@ -58,8 +58,9 @@ extends 'RT::REST2::Resource';
 use Scalar::Util qw( blessed );
 use Web::Machine::FSM::States qw( is_status_code );
 use Module::Runtime qw( require_module );
-use RT::REST2::Util qw( serialize_record expand_uid format_datetime error_as_json );
+use RT::REST2::Util qw( expand_uid format_datetime error_as_json );
 use POSIX qw( ceil );
+use Encode;
 
 has 'collection_class' => (
     is  => 'ro',
@@ -93,6 +94,21 @@ sub setup_paging {
     $self->collection->GotoPage($page - 1);
 }
 
+sub setup_ordering {
+    my $self = shift;
+    my @orderby_cols;
+    my @orders = $self->request->param('order');
+    foreach my $orderby ($self->request->param('orderby')) {
+        $orderby = decode_utf8($orderby);
+        my $order = shift @orders || 'ASC';
+        $order = uc(decode_utf8($order));
+        $order = 'ASC' unless $order eq 'DESC';
+        push @orderby_cols, {FIELD => $orderby, ORDER => $order};
+    }
+    $self->collection->OrderByCols(@orderby_cols)
+        if @orderby_cols;
+}
+
 sub limit_collection {
     my $self        = shift;
     my $collection  = $self->collection;
@@ -124,6 +140,7 @@ sub limit_collection {
 sub search {
     my $self = shift;
     $self->setup_paging;
+    $self->setup_ordering;
     return $self->limit_collection;
 }
 
@@ -134,13 +151,37 @@ sub serialize {
     my @fields = defined $self->request->param('fields') ? split(/,/, $self->request->param('fields')) : ();
 
     while (my $item = $collection->Next) {
-        my $result = expand_uid( $item->UID );
+        my $result = $self->serialize_record( $item->UID );
 
         # Allow selection of desired fields
         if ($result) {
             for my $field (@fields) {
-                my $field_result = $self->expand_field($item, $field);
-                $result->{$field} = $field_result if defined $field_result;
+                if ( $field eq '_hyperlinks' ) {
+                    my $class = ref $item;
+                    $class =~ s!^RT::!RT::REST2::Resource::!;
+                    if ( $class->require ) {
+                        my $object = $class->new(
+                            record_class => ref $item,
+                            record_id    => $item->id,
+                            record       => $item,
+                            request      => $self->request,
+                            response     => Plack::Response->new,
+                        );
+                        if ( $object->can('hypermedia_links') ) {
+                            $result->{$field} = $object->hypermedia_links;
+                        }
+                        else {
+                            RT->Logger->warning("_hyperlinks is not supported by $class, skipping");
+                        }
+                    }
+                    else {
+                        RT->Logger->warning("Couldn't load $class, skipping _hyperlinks");
+                    }
+                }
+                else {
+                    my $field_result = $self->expand_field($item, $field);
+                    $result->{$field} = $field_result if defined $field_result;
+                }
             }
         }
         push @results, $result;
@@ -180,6 +221,12 @@ sub serialize {
     return \%results;
 }
 
+sub serialize_record {
+    my $self   = shift;
+    my $record = shift;
+    return expand_uid($record);
+}
+
 # XXX TODO: Bulk update via DELETE/PUT on a collection resource?
 
 sub charsets_provided { [ 'utf-8' ] }
diff --git a/lib/RT/REST2/Resource/Collection/Search.pm b/lib/RT/REST2/Resource/Collection/Search.pm
new file mode 100644
index 0000000000..07c9d38098
--- /dev/null
+++ b/lib/RT/REST2/Resource/Collection/Search.pm
@@ -0,0 +1,209 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::REST2::Resource::Collection::Search;
+use strict;
+use warnings;
+
+use Moose::Role;
+use namespace::autoclean;
+
+requires 'collection';
+use Regexp::Common qw/delimited/;
+
+around BUILDARGS => sub {
+    my $orig  = shift;
+    my $class = shift;
+
+    my %args = @_;
+
+    if ( my $id = $args{request}->param('search') ) {
+        my $search = RT::REST2::Resource::Search::_load_search( $args{request}, $id );
+
+        if ( $search && $search->Id ) {
+            if ( !defined $args{query} && !defined $args{request}->param('query') ) {
+                if ( my $query = $search->GetParameter('Query') ) {
+                    $args{request}->parameters->set( query => $query );
+                }
+            }
+
+            if ( !defined $args{order} && !defined $args{request}->param('order') ) {
+                if ( my $order = $search->GetParameter('Order') ) {
+                    $args{request}->parameters->set( order => split /\|/, $order );
+                }
+            }
+
+            if ( !defined $args{orderby} && !defined $args{request}->param('orderby') ) {
+                if ( my $orderby = $search->GetParameter('OrderBy') ) {
+                    $args{request}->parameters->set( orderby => split /\|/, $orderby );
+                }
+            }
+
+            if ( !defined $args{per_page} && !defined $args{request}->param('per_page') ) {
+                if ( my $per_page = $search->GetParameter('RowsPerPage') ) {
+                    $args{request}->parameters->set( per_page => $per_page );
+                }
+            }
+
+            if ( !defined $args{fields} && !defined $args{request}->param('fields') ) {
+                if ( my $format = $search->GetParameter('Format') ) {
+                    my @attrs;
+
+                    # Main logic is copied from share/html/Elements/CollectionAsTable/ParseFormat
+                    while ( $format =~ /($RE{delimited}{-delim=>qq{\'"}}|[{}\w.]+)/go ) {
+                        my $col    = $1;
+                        my $colref = {};
+
+                        if ( $col =~ /^$RE{quoted}$/o ) {
+                            substr( $col, 0,  1 ) = "";
+                            substr( $col, -1, 1 ) = "";
+                            $col =~ s/\\(.)/$1/g;
+                        }
+
+                        while ( $col =~ s{/(STYLE|CLASS|TITLE|ALIGN|SPAN|ATTRIBUTE):([^/]*)}{}i ) {
+                            $colref->{ lc $1 } = $2;
+                        }
+
+                        unless ( length $col ) {
+                            $colref->{'attribute'} = '' unless defined $colref->{'attribute'};
+                        }
+                        elsif ( $col =~ /^__(NEWLINE|NBSP)__$/ || $col =~ /^(NEWLINE|NBSP)$/ ) {
+                            $colref->{'attribute'} = '';
+                        }
+                        elsif ( $col =~ /__(.*?)__/io ) {
+                            while ( $col =~ s/^(.*?)__(.*?)__//o ) {
+                                $colref->{'last_attribute'} = $2;
+                            }
+                            $colref->{'attribute'} = $colref->{'last_attribute'}
+                                unless defined $colref->{'attribute'};
+                        }
+                        else {
+                            $colref->{'attribute'} = $col
+                                unless defined $colref->{'attribute'};
+                        }
+
+                        if ( $colref->{'attribute'} ) {
+                            push @attrs, $colref->{'attribute'};
+                        }
+                    }
+
+                    my %fields;
+
+                    if (@attrs) {
+                        my $record_class = $args{collection_class}->RecordClass;
+                        while ( my $attr = shift @attrs ) {
+                            if ( $attr =~ /^(Requestors?|AdminCc|Cc|CustomRole\.\{.+?\})(?:\.(.+))?/ ) {
+                                my $role  = $1;
+                                my $field = $2;
+
+                                if ( $role eq 'Requestors' ) {
+                                    $role = 'Requestor';
+                                }
+                                elsif ( $role =~ /^CustomRole\.\{(.+?)\}/ ) {
+                                    my $name        = $1;
+                                    my $custom_role = RT::CustomRole->new( $args{request}->env->{"rt.current_user"} );
+                                    $custom_role->Load($name);
+                                    if ( $custom_role->Id ) {
+                                        $role = $custom_role->GroupType;
+                                    }
+                                    else {
+                                        next;
+                                    }
+                                }
+
+                                $fields{$role} = 1;
+                                if ($field) {
+                                    $field = 'CustomFields' if $field =~ /^CustomField\./;
+                                    $args{request}->parameters->set(
+                                        "fields[$role]" => join ',',
+                                        $field,
+                                        $args{request}->parameters->get("fields[$role]") || ()
+                                    );
+                                }
+                            }
+                            elsif ( $attr =~ /^CustomField\./ ) {
+                                $fields{CustomFields} = 1;
+                            }
+                            elsif ( $attr
+                                =~ /^(?:RefersTo|ReferredToBy|DependsOn|DependedOnBy|MemberOf|Members|Parents|Children)$/
+                                )
+                            {
+                                $fields{_hyperlinks} = 1;
+                            }
+                            elsif ( $record_class->can('_Accessible') && $record_class->_Accessible( $attr => 'read' ) )
+                            {
+                                $fields{$attr} = 1;
+                            }
+                            elsif ( $attr =~ s/Relative$// ) {
+
+                                # Date fields like LastUpdatedRelative
+                                push @attrs, $attr;
+                            }
+                            elsif ( $attr =~ s/Name$// ) {
+
+                                # Fields like OwnerName, QueueName
+                                push @attrs, $attr;
+                                $args{request}->parameters->set(
+                                    "fields[$attr]" => join ',',
+                                    'Name',
+                                    $args{request}->parameters->get("fields[$attr]") || ()
+                                );
+                            }
+                        }
+                    }
+
+                    $args{request}->parameters->set( 'fields' => join ',', sort keys %fields );
+                }
+            }
+        }
+    }
+
+    return $class->$orig( %args );
+};
+
+1;
diff --git a/lib/RT/REST2/Resource/Message.pm b/lib/RT/REST2/Resource/Message.pm
index d1d4f00564..5315585e9b 100644
--- a/lib/RT/REST2/Resource/Message.pm
+++ b/lib/RT/REST2/Resource/Message.pm
@@ -55,7 +55,7 @@ use namespace::autoclean;
 use MIME::Base64;
 
 extends 'RT::REST2::Resource';
-use RT::REST2::Util qw( error_as_json update_custom_fields process_uploads );
+use RT::REST2::Util qw( error_as_json update_custom_fields process_uploads update_role_members fix_custom_role_ids );
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
@@ -124,10 +124,9 @@ sub from_json {
                 unless $attachment->{$field};
             }
         }
-
-        $body->{NoContent} = 1 unless $body->{Content};
     }
 
+    $body->{NoContent} = 1 unless $body->{Content};
     if (!$body->{NoContent} && !$body->{ContentType}) {
         return error_as_json(
             $self->response,
@@ -140,8 +139,36 @@ sub from_json {
 sub add_message {
     my $self = shift;
     my %args = @_;
+
+    my ( $return_code, @results ) = $self->_add_message(%args);
+    if ( $return_code != 201 ) {
+        return error_as_json( $self->response, \$return_code, join "\n", @results );
+    }
+
+    $self->response->body( JSON::to_json( \@results, { pretty => 1 } ) );
+    return 1;
+}
+
+sub _add_message {
+    my $self = shift;
+    my %args = @_;
     my @results;
 
+    # update_role_members wants custom role IDs (like RT::CustomRole-ID)
+    # rather than role names.
+    %args = ( %args, %{ fix_custom_role_ids( $self->record, $args{CustomRoles} ) } ) if $args{CustomRoles};
+
+    # Check for any bad input data before making updates
+    my ($ok, $errmsg, $return_code) = $self->validate_input(\%args);
+    if (!$ok) {
+        if ( $return_code ) {
+            return ($return_code, $errmsg);
+        }
+        else {
+            return (400, $errmsg);
+        }
+    }
+
     my $MIME = HTML::Mason::Commands::MakeMIMEEntity(
         Interface => 'REST',
         $args{NoContent} ? () : (Body => $args{Content} || $self->request->content),
@@ -172,23 +199,30 @@ sub add_message {
         );
     }
     else {
-        return \400;
+        push @results, $self->current_user->loc('Unknown type');
+        return ( 400, @results );
     }
 
     if (!$Trans) {
-        return error_as_json(
-            $self->response,
-            \400, $msg || "Message failed for unknown reason");
+        push @results, $msg || $self->current_user->loc("Message failed for unknown reason");
+        return ( 400, @results );
     }
 
     push @results, $msg;
     push @results, update_custom_fields($self->record, $args{CustomFields});
+
+    push @results, update_role_members($self->record, \%args);
     push @results, $self->_update_txn_custom_fields( $TransObj, $args{TxnCustomFields} || $args{TransactionCustomFields} );
 
+    # Set ticket status if we were passed a "Status":"foo" argument
+    if ($args{Status}) {
+        my ($ok, $msg) = $self->record->SetStatus($args{Status});
+        push(@results, $msg);
+    }
+
     $self->created_transaction($TransObj);
-    $self->response->body(JSON::to_json(\@results, { pretty => 1 }));
 
-    return 1;
+    return ( 201, @results );
 }
 
 sub _update_txn_custom_fields {
@@ -238,6 +272,15 @@ sub create_path {
     return "/transaction/$id";
 }
 
+sub validate_input {
+    my $self = shift;
+    my $args = shift;
+
+    # Add CF and other pre-update validation here
+
+    return (1, 'Validation passed');
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/REST2/Resource/Record/Hypermedia.pm b/lib/RT/REST2/Resource/Record/Hypermedia.pm
index 01d26c39eb..61f8366506 100644
--- a/lib/RT/REST2/Resource/Record/Hypermedia.pm
+++ b/lib/RT/REST2/Resource/Record/Hypermedia.pm
@@ -105,12 +105,13 @@ sub _rtlink_links {
         my $mode = $RT::Link::TYPEMAP{$relation}{Mode};
         my $type = $RT::Link::TYPEMAP{$relation}{Type};
         my $method = $mode . "Obj";
+        my $mode_uri = $mode . 'URI';
 
         my $links = $record->$relation;
 
         while (my $link = $links->Next) {
             my $entry;
-            if ( $link->LocalTarget and $link->LocalBase ){
+            if ( $link->$mode_uri->IsLocal ) {
                 # Internal links
                 $entry = expand_uid($link->$method->UID);
             }
diff --git a/lib/RT/REST2/Resource/Record/Writable.pm b/lib/RT/REST2/Resource/Record/Writable.pm
index 5c033c6a05..e31f6d52a7 100644
--- a/lib/RT/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/REST2/Resource/Record/Writable.pm
@@ -53,7 +53,7 @@ use warnings;
 use Moose::Role;
 use namespace::autoclean;
 use JSON ();
-use RT::REST2::Util qw( deserialize_record error_as_json expand_uid update_custom_fields process_uploads );
+use RT::REST2::Util qw( deserialize_record error_as_json expand_uid update_custom_fields process_uploads update_role_members );
 use List::MoreUtils 'uniq';
 
 with 'RT::REST2::Resource::Role::RequestBodyIsJSON'
@@ -158,7 +158,7 @@ sub update_record {
     );
 
     push @results, update_custom_fields($self->record, $data->{CustomFields});
-    push @results, $self->_update_role_members($data);
+    push @results, update_role_members($self->record, $data);
     push @results, $self->_update_links($data);
     push @results, $self->_update_disabled($data->{Disabled})
       unless grep { $_ eq 'Disabled' } $self->record->WritableAttributes;
@@ -170,124 +170,6 @@ sub update_record {
     return @results;
 }
 
-sub _update_role_members {
-    my $self = shift;
-    my $data = shift;
-
-    my $record = $self->record;
-
-    return unless $record->DOES('RT::Record::Role::Roles');
-
-    my @results;
-
-    foreach my $role ($record->Roles) {
-        next unless exists $data->{$role};
-
-        # special case: RT::Ticket->Update already handles Owner for us
-        next if $role eq 'Owner' && $record->isa('RT::Ticket');
-
-        my $val = $data->{$role};
-
-        if ($record->Role($role)->{Single}) {
-            if (ref($val) eq 'ARRAY') {
-                $val = $val->[0];
-            }
-            elsif (ref($val)) {
-                die "Invalid value type for role $role";
-            }
-
-            my ($ok, $msg);
-            if ($record->can('AddWatcher')) {
-                ($ok, $msg) = $record->AddWatcher(
-                    Type => $role,
-                    User => $val,
-                );
-            } else {
-                ($ok, $msg) = $record->AddRoleMember(
-                    Type => $role,
-                    User => $val,
-                );
-            }
-            push @results, $msg;
-        }
-        else {
-            my %count;
-            my @vals;
-
-            for (ref($val) eq 'ARRAY' ? @$val : $val) {
-                my ($principal_id, $msg);
-
-                if (/^\d+$/) {
-                    $principal_id = $_;
-                }
-                elsif ($record->can('CanonicalizePrincipal')) {
-                    ((my $principal), $msg) = $record->CanonicalizePrincipal(User => $_);
-                    $principal_id = $principal->Id;
-                }
-                else {
-                    my $user = RT::User->new($record->CurrentUser);
-                    if (/@/) {
-                        ((my $ok), $msg) = $user->LoadOrCreateByEmail( $_ );
-                    } else {
-                        ((my $ok), $msg) = $user->Load( $_ );
-                    }
-                    $principal_id = $user->PrincipalId;
-                }
-
-                if (!$principal_id) {
-                    push @results, $msg;
-                    next;
-                }
-
-                push @vals, $principal_id;
-                $count{$principal_id}++;
-            }
-
-            my $group = $record->RoleGroup($role);
-            my $members = $group->MembersObj;
-            while (my $member = $members->Next) {
-                $count{$member->MemberId}--;
-            }
-
-            # RT::Ticket has specialized methods
-            my $add_method = $record->can('AddWatcher') ? 'AddWatcher' : 'AddRoleMember';
-            my $del_method = $record->can('DeleteWatcher') ? 'DeleteWatcher' : 'DeleteRoleMember';
-
-            # we want to provide a stable order, so first go by the order
-            # provided in the argument list, and then for any role members
-            # that are being removed, remove in sorted order
-            for my $id (uniq(@vals, sort keys %count)) {
-                my $count = $count{$id};
-                if ($count == 0) {
-                    # new == old, no change needed
-                }
-                elsif ($count > 0) {
-                    # new > old, need to add new
-                    while ($count-- > 0) {
-                        my ($ok, $msg) = $record->$add_method(
-                            Type        => $role,
-                            PrincipalId => $id,
-                        );
-                        push @results, $msg;
-                    }
-                }
-                elsif ($count < 0) {
-                    # old > new, need to remove old
-                    while ($count++ < 0) {
-                        my ($ok, $msg) = $record->$del_method(
-                            Type        => $role,
-                            PrincipalId => $id,
-                        );
-                        push @results, $msg;
-                    }
-                }
-            }
-        }
-    }
-
-    return @results;
-}
-
 sub _update_links {
     my $self = shift;
     my $data = shift;
diff --git a/lib/RT/REST2/Resource/Search.pm b/lib/RT/REST2/Resource/Search.pm
new file mode 100644
index 0000000000..86affb9ece
--- /dev/null
+++ b/lib/RT/REST2/Resource/Search.pm
@@ -0,0 +1,158 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::REST2::Resource::Search;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::REST2::Resource::Record';
+with 'RT::REST2::Resource::Record::Readable',
+    'RT::REST2::Resource::Record::Hypermedia' =>
+    { -alias => { _self_link => '_default_self_link', hypermedia_links => '_default_hypermedia_links' } };
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/search/?$},
+        block => sub { { record_class => 'RT::Attribute' } },
+        ),
+        Path::Dispatcher::Rule::Regex->new(
+            regex => qr{^/search/(.+)/?$},
+            block => sub {
+                my ($match, $req) = @_;
+                my $desc = $match->pos(1);
+                my $record = _load_search($req, $desc);
+
+                return { record_class => 'RT::Attribute', record_id => $record ? $record->Id : 0 };
+            },
+        );
+}
+
+sub _self_link {
+    my $self   = shift;
+    my $result = $self->_default_self_link(@_);
+
+    $result->{type} = 'search';
+    $result->{_url} =~ s!/attribute/!/search/!;
+    return $result;
+}
+
+sub hypermedia_links {
+    my $self = shift;
+    my $links = $self->_default_hypermedia_links;
+    my $record = $self->record;
+    if ( my $content = $record->Content ) {
+        if ( ( $content->{SearchType} || 'Ticket' ) eq 'Ticket' ) {
+            my $id = $record->Id;
+            push @$links,
+                {   _url => RT::REST2->base_uri . "/tickets?search=$id",
+                    type => 'results',
+                    ref  => 'tickets',
+                };
+        }
+    }
+    return $links;
+}
+
+sub base_uri { join '/', RT::REST2->base_uri, 'search' }
+
+sub resource_exists {
+    my $self   = shift;
+    my $record = $self->record;
+    return $record->Id && $record->Name =~ /^(?:SavedSearch$|Search -)/;
+}
+
+sub forbidden {
+    my $self = shift;
+    return 0 unless $self->resource_exists;
+    my $search = RT::SavedSearch->new( $self->current_user );
+    return $search->LoadById( $self->record->Id ) ? 0 : 1;
+}
+
+sub _load_search {
+    my $req = shift;
+    my $id  = shift;
+
+    if ( $id =~ /\D/ ) {
+
+        my $attrs = RT::Attributes->new( $req->env->{"rt.current_user"} );
+
+        $attrs->Limit( FIELD => 'Name',        VALUE => 'SavedSearch' );
+        $attrs->Limit( FIELD => 'Name',        VALUE => 'Search -', OPERATOR => 'STARTSWITH' );
+        $attrs->Limit( FIELD => 'Description', VALUE => $id );
+
+        my @searches;
+        while ( my $attr = $attrs->Next ) {
+            my $search = RT::SavedSearch->new( $req->env->{"rt.current_user"} );
+            if ( $search->LoadById( $attr->Id ) ) {
+                push @searches, $search;
+            }
+        }
+
+        my $record_id;
+        if (@searches) {
+            if ( @searches > 1 ) {
+                RT->Logger->warning("Found multiple searches with description $id");
+            }
+            return $searches[0];
+        }
+    }
+    else {
+        my $search = RT::SavedSearch->new( $req->env->{"rt.current_user"} );
+        if ( $search->LoadById($id) ) {
+            return $search;
+        }
+    }
+    return;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/lib/RT/REST2/Resource/Tickets.pm b/lib/RT/REST2/Resource/Searches.pm
similarity index 52%
copy from lib/RT/REST2/Resource/Tickets.pm
copy to lib/RT/REST2/Resource/Searches.pm
index 3bdccaf8cd..8fe5f488d9 100644
--- a/lib/RT/REST2/Resource/Tickets.pm
+++ b/lib/RT/REST2/Resource/Searches.pm
@@ -46,7 +46,7 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Tickets;
+package RT::REST2::Resource::Searches;
 use strict;
 use warnings;
 
@@ -54,12 +54,13 @@ use Moose;
 use namespace::autoclean;
 
 extends 'RT::REST2::Resource::Collection';
-with 'RT::REST2::Resource::Collection::ProcessPOSTasGET';
+with 'RT::REST2::Resource::Collection::ProcessPOSTasGET',
+    'RT::REST2::Resource::Collection::QueryByJSON';
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/tickets/?$},
-        block => sub { { collection_class => 'RT::Tickets' } },
+        regex => qr{^/searches/?$},
+        block => sub { { collection_class => 'RT::Attributes' } },
     )
 }
 
@@ -67,53 +68,74 @@ use Encode qw( decode_utf8 );
 use RT::REST2::Util qw( error_as_json );
 use RT::Search::Simple;
 
-has 'query' => (
-    is          => 'ro',
-    isa         => 'Str',
-    required    => 1,
-    lazy_build  => 1,
-);
-
-sub _build_query {
-    my $self  = shift;
-    my $query = decode_utf8($self->request->param('query') || "");
-
-    if ($self->request->param('simple') and $query) {
-        # XXX TODO: Note that "normal" ModifyQuery callback isn't invoked
-        # XXX TODO: Special-casing of "#NNN" isn't used
-        my $search = RT::Search::Simple->new(
-            Argument    => $query,
-            TicketsObj  => $self->collection,
-        );
-        $query = $search->QueryToSQL;
-    }
-    return $query;
-}
-
 sub allowed_methods {
     [ 'GET', 'HEAD', 'POST' ]
 }
 
 sub limit_collection {
     my $self = shift;
-    my ($ok, $msg) = $self->collection->FromSQL( $self->query );
-    return error_as_json( $self->response, 0, $msg ) unless $ok;
-
-    my @orderby_cols;
-    my @orders = $self->request->param('order');
-    foreach my $orderby ($self->request->param('orderby')) {
-        $orderby = decode_utf8($orderby);
-        my $order = shift @orders || 'ASC';
-        $order = uc(decode_utf8($order));
-        $order = 'ASC' unless $order eq 'DESC';
-        push @orderby_cols, {FIELD => $orderby, ORDER => $order};
+    my @objects = RT::SavedSearch->new($self->current_user)->ObjectsForLoading;
+    if ( $self->current_user->HasRight( Object => $RT::System, Right => 'ShowSavedSearches' ) ) {
+        push @objects, RT::System->new( $self->current_user );
+    }
+
+    my $query       = $self->query_json;
+    my @fields      = $self->searchable_fields;
+    my %searchable  = map {; $_ => 1 } @fields;
+
+    my @ids;
+    my @attrs;
+    for my $object (@objects) {
+        my $attrs = $object->Attributes;
+        $attrs->Limit( FIELD => 'Name', VALUE => 'SavedSearch' );
+        push @attrs, $attrs;
+    }
+
+    # Default system searches
+    my $attrs = RT::System->new( $self->current_user )->Attributes;
+    $attrs->Limit( FIELD => 'Name', VALUE => 'Search -', OPERATOR => 'STARTSWITH' );
+    push @attrs, $attrs;
+
+    for my $attrs (@attrs) {
+        for my $limit (@$query) {
+            next
+                unless $limit->{field}
+                and $searchable{ $limit->{field} }
+                and defined $limit->{value};
+
+            $attrs->Limit(
+                FIELD => $limit->{field},
+                VALUE => $limit->{value},
+                (   $limit->{operator} ? ( OPERATOR => $limit->{operator} )
+                    : ()
+                ),
+                CASESENSITIVE => ( $limit->{case_sensitive} || 0 ),
+                (   $limit->{entry_aggregator} ? ( ENTRYAGGREGATOR => $limit->{entry_aggregator} )
+                    : ()
+                ),
+            );
+        }
+        push @ids, map { $_->Id } @{ $attrs->ItemsArrayRef };
     }
-    $self->collection->OrderByCols(@orderby_cols)
-        if @orderby_cols;
+
+    while ( @ids > 1000 ) {
+        my @batch = splice( @ids, 0, 1000 );
+        $self->Limit( FIELD => 'id', VALUE => \@ids, OPERATOR => 'IN' );
+    }
+    $self->collection->Limit( FIELD => 'id', VALUE => \@ids, OPERATOR => 'IN' );
 
     return 1;
 }
 
+sub serialize_record {
+    my $self   = shift;
+    my $record = shift;
+    my $result = $self->SUPER::serialize_record($record);
+    $result->{type} = 'search';
+    $result->{_url} =~ s!/attribute/!/search/!;
+    return $result;
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/REST2/Resource/Ticket.pm b/lib/RT/REST2/Resource/Ticket.pm
index 816dd98992..5caff4709f 100644
--- a/lib/RT/REST2/Resource/Ticket.pm
+++ b/lib/RT/REST2/Resource/Ticket.pm
@@ -87,18 +87,11 @@ sub create_record {
     my $self = shift;
     my $data = shift;
 
-    return (\400, "Could not create ticket. Queue not set") if !$data->{Queue};
-
-    my $queue = RT::Queue->new(RT->SystemUser);
-    $queue->Load($data->{Queue});
-
-    return (\400, "Unable to find queue") if !$queue->Id;
-
-    return (\403, $self->record->loc("No permission to create tickets in the queue '[_1]'", $queue->Name))
-    unless $self->record->CurrentUser->HasRight(
-        Right  => 'CreateTicket',
-        Object => $queue,
-    ) and $queue->Disabled != 1;
+    # Check for any bad input data before creating a ticket
+    my ($ok, $msg, $return_code) = $self->validate_input(Data => $data, Action => 'create');
+    if (!$ok) {
+        return (\$return_code, $msg);
+    }
 
     if ( defined $data->{Content} || defined $data->{Attachments} ) {
         $data->{MIMEObj} = HTML::Mason::Commands::MakeMIMEEntity(
@@ -123,7 +116,8 @@ sub create_record {
         }
     }
 
-    my ($ok, $txn, $msg) = $self->_create_record($data);
+    my ($txn);
+    ($ok, $txn, $msg) = $self->_create_record($data);
     return ($ok, $msg);
 }
 
@@ -214,6 +208,36 @@ sub hypermedia_links {
     return $links;
 }
 
+sub validate_input {
+    my $self = shift;
+    my %args = ( Data    => '',
+                 Action  => '',
+                 @_ );
+    my $data = $args{'Data'};
+
+    if ( $args{'Action'} eq 'create' ) {
+        return (0, "Could not create ticket. Queue not set", 400) if !$data->{Queue};
+
+        my $queue = RT::Queue->new(RT->SystemUser);
+        $queue->Load($data->{Queue});
+
+        return (0, "Unable to find queue", 400) if !$queue->Id;
+
+        return (0, $self->record->loc("No permission to create tickets in the queue '[_1]'", $queue->Name), 403)
+            unless $self->record->CurrentUser->HasRight(
+                Right  => 'CreateTicket',
+                Object => $queue,
+            ) and $queue->Disabled != 1;
+    }
+
+    if ( $args{'Action'} eq 'update' ) {
+        # Add pre-update input validation
+    }
+
+    return (1, "Validation passed");
+}
+
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/REST2/Resource/Tickets.pm b/lib/RT/REST2/Resource/Tickets.pm
index 3bdccaf8cd..82299df9a7 100644
--- a/lib/RT/REST2/Resource/Tickets.pm
+++ b/lib/RT/REST2/Resource/Tickets.pm
@@ -54,7 +54,8 @@ use Moose;
 use namespace::autoclean;
 
 extends 'RT::REST2::Resource::Collection';
-with 'RT::REST2::Resource::Collection::ProcessPOSTasGET';
+with 'RT::REST2::Resource::Collection::ProcessPOSTasGET',
+    'RT::REST2::Resource::Collection::Search';
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
@@ -64,7 +65,7 @@ sub dispatch_rules {
 }
 
 use Encode qw( decode_utf8 );
-use RT::REST2::Util qw( error_as_json );
+use RT::REST2::Util qw( error_as_json expand_uid );
 use RT::Search::Simple;
 
 has 'query' => (
@@ -94,24 +95,31 @@ sub allowed_methods {
     [ 'GET', 'HEAD', 'POST' ]
 }
 
-sub limit_collection {
+override 'limit_collection' => sub {
     my $self = shift;
     my ($ok, $msg) = $self->collection->FromSQL( $self->query );
     return error_as_json( $self->response, 0, $msg ) unless $ok;
+    super();
+    return 1;
+};
 
-    my @orderby_cols;
-    my @orders = $self->request->param('order');
-    foreach my $orderby ($self->request->param('orderby')) {
-        $orderby = decode_utf8($orderby);
-        my $order = shift @orders || 'ASC';
-        $order = uc(decode_utf8($order));
-        $order = 'ASC' unless $order eq 'DESC';
-        push @orderby_cols, {FIELD => $orderby, ORDER => $order};
+sub expand_field {
+    my $self         = shift;
+    my $item         = shift;
+    my $field        = shift;
+    my $param_prefix = shift;
+    if ( $field =~ /^(Requestor|AdminCc|Cc)/ ) {
+        my $role    = $1;
+        my $members = [];
+        if ( my $group = $item->RoleGroup($role) ) {
+            my $gms = $group->MembersObj;
+            while ( my $gm = $gms->Next ) {
+                push @$members, $self->_expand_object( $gm->MemberObj->Object, $field, $param_prefix );
+            }
+        }
+        return $members;
     }
-    $self->collection->OrderByCols(@orderby_cols)
-        if @orderby_cols;
-
-    return 1;
+    return $self->SUPER::expand_field( $item, $field, $param_prefix );
 }
 
 __PACKAGE__->meta->make_immutable;
diff --git a/lib/RT/REST2/Resource/TicketsBulk.pm b/lib/RT/REST2/Resource/TicketsBulk.pm
index c3f6e654b5..61f07e03e2 100644
--- a/lib/RT/REST2/Resource/TicketsBulk.pm
+++ b/lib/RT/REST2/Resource/TicketsBulk.pm
@@ -62,9 +62,18 @@ use RT::REST2::Resource::Ticket;
 use JSON ();
 
 sub dispatch_rules {
-    Path::Dispatcher::Rule::Regex->new( regex => qr{^/tickets/bulk/?$} );
+    Path::Dispatcher::Rule::Regex->new( regex => qr{^/tickets/bulk/?$} ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/tickets/bulk/(correspond|comment)$},
+        block => sub { { type => shift->pos(1) } },
+    )
 }
 
+has type => (
+    is       => 'ro',
+    isa      => 'Str',
+);
+
 sub post_is_create    { 1 }
 sub create_path       { '/tickets/bulk' }
 sub charsets_provided { [ 'utf-8' ] }
@@ -100,17 +109,60 @@ sub from_json {
     }
     else {
         for my $param ( @$params ) {
-            my $resource = RT::REST2::Resource::Ticket->new(
-                request      => $self->request,
-                response     => $self->response,
-                record_class => 'RT::Ticket',
-            );
-            my ( $ok, $msg ) = $resource->create_record( $param );
-            if ( ref( $ok ) || !$ok ) {
-                push @results, { message => $msg || "Create failed for unknown reason" };
+            if ( $self->type ) {
+                my $id = delete $param->{id};
+                if ( $id && $id =~ /^\d+$/ ) {
+                    my $ticket = RT::Ticket->new($self->current_user);
+                    $ticket->Load($id);
+                    my $resource = RT::REST2::Resource::Message->new(
+                        request      => $self->request,
+                        response     => $self->response,
+                        type         => $self->type,
+                        record       => $ticket,
+                    );
+
+                    my @errors;
+
+                    # Ported from RT::REST2::Resource::Message::from_json
+                    if ( $param->{Attachments} ) {
+                        foreach my $attachment ( @{ $param->{Attachments} } ) {
+                            foreach my $field ( 'FileName', 'FileType', 'FileContent' ) {
+                                push @errors, "$field is a required field for each attachment in Attachments"
+                                    unless $attachment->{$field};
+                            }
+                        }
+                    }
+
+                    $param->{NoContent} = 1 unless $param->{Content};
+                    if ( !$param->{NoContent} && !$param->{ContentType} ) {
+                        push @errors, "ContentType is a required field for application/json";
+                    }
+
+                    if (@errors) {
+                        push @results, [ $id, @errors ];
+                        next;
+                    }
+
+                    my ( $return_code, @messages ) = $resource->_add_message(%$param);
+                    push @results, [ $id, @messages ];
+                }
+                else {
+                    push @results, [ $id, 'Resource does not exist' ];
+                }
             }
             else {
-                push @results, expand_uid( $resource->record->UID );
+                my $resource = RT::REST2::Resource::Ticket->new(
+                    request      => $self->request,
+                    response     => $self->response,
+                    record_class => 'RT::Ticket',
+                );
+                my ( $ok, $msg ) = $resource->create_record($param);
+                if ( ref($ok) || !$ok ) {
+                    push @results, { message => $msg || "Create failed for unknown reason" };
+                }
+                else {
+                    push @results, expand_uid( $resource->record->UID );
+                }
             }
         }
     }
diff --git a/lib/RT/REST2/Util.pm b/lib/RT/REST2/Util.pm
index 6311cf54a2..e750715e32 100644
--- a/lib/RT/REST2/Util.pm
+++ b/lib/RT/REST2/Util.pm
@@ -70,6 +70,8 @@ use Sub::Exporter -setup => {
         format_datetime
         update_custom_fields
         process_uploads
+        update_role_members
+        fix_custom_role_ids
     ]]
 };
 
@@ -333,7 +335,7 @@ sub update_custom_fields {
         my $val = $data->{$cfid};
 
         my $cf = $record->LoadCustomFieldByIdentifier($cfid);
-        next unless $cf->ObjectTypeFromLookupType($cf->__Value('LookupType'))->isa(ref $record);
+        next unless $cf->Id && $cf->ObjectTypeFromLookupType($cf->__Value('LookupType'))->isa(ref $record);
 
         if ($cf->SingleValue) {
             my %args;
@@ -373,7 +375,7 @@ sub update_custom_fields {
                 Value => $val,
                 %args,
             );
-            push @results, $msg;
+            push @results, $msg // ();
         }
         else {
             my %count;
@@ -475,4 +477,154 @@ sub process_uploads {
     return @ret;
 }
 
+sub update_role_members {
+    my $record = shift;
+    my $data = shift;
+
+    return unless $record->DOES('RT::Record::Role::Roles');
+
+    my @results;
+
+    foreach my $role ($record->Roles) {
+        next unless exists $data->{$role};
+
+        # special case: RT::Ticket->Update already handles Owner for us
+        next if $role eq 'Owner' && $record->isa('RT::Ticket');
+
+        my $val = $data->{$role};
+
+        if ($record->Role($role)->{Single}) {
+            if (ref($val) eq 'ARRAY') {
+                $val = $val->[0];
+            }
+            elsif (ref($val)) {
+                die "Invalid value type for role $role";
+            }
+
+            my ($ok, $msg);
+            if ($record->can('AddWatcher')) {
+                ($ok, $msg) = $record->AddWatcher(
+                    Type => $role,
+                    User => $val,
+                );
+            } else {
+                ($ok, $msg) = $record->AddRoleMember(
+                    Type => $role,
+                    User => $val,
+                );
+            }
+            push @results, $msg;
+        }
+        else {
+            my %count;
+            my @vals;
+
+            for (ref($val) eq 'ARRAY' ? @$val : $val) {
+                my ($principal_id, $msg);
+
+                if (/^\d+$/) {
+                    $principal_id = $_;
+                }
+                elsif ($record->can('CanonicalizePrincipal')) {
+                    ((my $principal), $msg) = $record->CanonicalizePrincipal(User => $_);
+                    $principal_id = $principal->Id;
+                }
+                else {
+                    my $user = RT::User->new($record->CurrentUser);
+                    if (/@/) {
+                        ((my $ok), $msg) = $user->LoadOrCreateByEmail( $_ );
+                    } else {
+                        ((my $ok), $msg) = $user->Load( $_ );
+                    }
+                    $principal_id = $user->PrincipalId;
+                }
+
+                if (!$principal_id) {
+                    push @results, $msg;
+                    next;
+                }
+
+                push @vals, $principal_id;
+                $count{$principal_id}++;
+            }
+
+            my $group = $record->RoleGroup($role);
+            my $members = $group->MembersObj;
+            while (my $member = $members->Next) {
+                $count{$member->MemberId}--;
+            }
+
+            # RT::Ticket has specialized methods
+            my $add_method = $record->can('AddWatcher') ? 'AddWatcher' : 'AddRoleMember';
+            my $del_method = $record->can('DeleteWatcher') ? 'DeleteWatcher' : 'DeleteRoleMember';
+
+            # we want to provide a stable order, so first go by the order
+            # provided in the argument list, and then for any role members
+            # that are being removed, remove in sorted order
+            for my $id (uniq(@vals, sort keys %count)) {
+                my $count = $count{$id};
+                if ($count == 0) {
+                    # new == old, no change needed
+                }
+                elsif ($count > 0) {
+                    # new > old, need to add new
+                    while ($count-- > 0) {
+                        my ($ok, $msg) = $record->$add_method(
+                            Type        => $role,
+                            PrincipalId => $id,
+                        );
+                        push @results, $msg;
+                    }
+                }
+                elsif ($count < 0) {
+                    # old > new, need to remove old
+                    while ($count++ < 0) {
+                        my ($ok, $msg) = $record->$del_method(
+                            Type        => $role,
+                            PrincipalId => $id,
+                        );
+                        push @results, $msg;
+                    }
+                }
+            }
+        }
+    }
+
+    return @results;
+}
+
+=head2 fix_custom_role_ids ( $record, $custom_roles )
+
+$record is the RT object (eg, an RT::Ticket) associated
+with custom roles.
+
+$custom_roles is a hashref where the keys are custom role
+IDs, names or email addresses and the values can be
+anything.  Returns a new hashref where all the keys
+are replaced with "RT::CustomRole-ID" if they were
+not originally in that form, and the values are kept
+the same.
+
+=cut
+
+sub fix_custom_role_ids
+{
+    my ($record, $custom_roles) = @_;
+    my $ret = {};
+    return $ret unless $custom_roles;
+
+    foreach my $key (keys(%$custom_roles)) {
+        if ($key =~ /^RT::CustomRole-\d+$/) {
+            # Already in the correct form
+            $ret->{$key} = $custom_roles->{$key};
+            next;
+        }
+
+        my $cr = RT::CustomRole->new($record->CurrentUser);
+        next unless $cr->Load($key);
+        $ret->{'RT::CustomRole-' . $cr->Id} = $custom_roles->{$key};
+    }
+    return $ret;
+}
+
 1;
diff --git a/lib/RT/Test/REST2.pm b/lib/RT/Test/REST2.pm
index f35d9ff529..78395e9aa0 100644
--- a/lib/RT/Test/REST2.pm
+++ b/lib/RT/Test/REST2.pm
@@ -103,6 +103,7 @@ sub mech { RT::Test::REST2::Mechanize->new }
         $u->Create(
             Name => 'test',
             Password => 'password',
+            EmailAddress => 'test at rt.example',
             Privileged => 1,
         );
         return $u;
diff --git a/t/rest2/assets.t b/t/rest2/assets.t
index 1ebc373002..4c1984f339 100644
--- a/t/rest2/assets.t
+++ b/t/rest2/assets.t
@@ -265,4 +265,38 @@ my ($asset_url, $asset_id);
     }
 }
 
+# Asset Search - Role Fields
+{
+
+    my $payload = {
+        Name        => 'Asset creation using REST',
+        Description => 'Asset description',
+        Catalog     => 'General assets',
+        Content     => 'Testing asset creation using REST API.',
+        Owner       => 'root at localhost',
+        HeldBy      => 'root at example.com',
+        Contact     => 'alice at example.com, bob at example.com',
+    };
+
+    my $res = $mech->post_json( "$rest_base_path/asset", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( my $asset_url = $res->header('location') );
+    ok( my ($asset_id) = $asset_url =~ qr[/asset/(\d+)] );
+
+    $res = $mech->post_json(
+        "$rest_base_path/assets?fields=Owner,HeldBy,Contact",
+        [ { field => 'id', operator => '=', value => $asset_id } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+    my $content = $mech->json_response;
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $asset = $content->{items}->[0];
+    is( $asset->{Owner}{id},      'root',              'Owner id in search result' );
+    is( $asset->{HeldBy}[0]{id},  'root at example.com',  'HeldBy id in search result' );
+    is( $asset->{Contact}[0]{id}, 'alice at example.com', 'Contact id in search result' );
+    is( $asset->{Contact}[1]{id}, 'bob at example.com',   'Contact id in search result' );
+}
+
 done_testing;
diff --git a/t/rest2/searches.t b/t/rest2/searches.t
new file mode 100644
index 0000000000..067ba1fd63
--- /dev/null
+++ b/t/rest2/searches.t
@@ -0,0 +1,215 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+
+my $mech           = RT::Test::REST2->mech;
+my $auth           = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user           = RT::Test::REST2->user;
+
+$user->PrincipalObj->GrantRight( Right => 'ModifySelf' );
+
+my $custom_role = RT::CustomRole->new( RT->SystemUser );
+my ( $ret, $msg ) = $custom_role->Create(
+    Name      => 'Manager',
+    MaxValues => 0,
+);
+ok( $ret, "created custom role: $msg" );
+
+( $ret, $msg ) = $custom_role->AddToObject(1);
+ok( $ret, "added custom role to queue: $msg" );
+my $custom_role_type = $custom_role->GroupType;
+
+my $cf = RT::CustomField->new( RT->SystemUser );
+( $ret, $msg ) = $cf->Create( Name => 'Boss', Type => 'Freeform', LookupType => RT::User->CustomFieldLookupType );
+ok( $ret, "created custom field: $msg" );
+ok( $cf->AddToObject( RT::User->new( RT->SystemUser ) ) );
+
+ok( $user->SetCountry('US') );
+ok( $user->AddCustomFieldValue( Field => $cf, Value => 'root' ) );
+
+my $search1 = RT::SavedSearch->new($user);
+( $ret, $msg ) = $search1->Save(
+    Privacy      => 'RT::User-' . $user->Id,
+    Type         => 'Ticket',
+    Name         => 'My tickets',
+    SearchParams => {
+        RowsPerPage => 50,
+        'Format'    => join( ',',
+            RT->Config->Get('DefaultSearchResultFormat'), '__Requestors.Country__',
+            '__CustomRole.{Manager}.CustomField.{Boss}__' ),
+        'Query' => "Owner = '" . $user->Name . "'",
+    },
+);
+
+ok( $ret, "created $msg" );
+
+my $search2 = RT::SavedSearch->new( RT->SystemUser );
+( $ret, $msg ) = $search2->Save(
+    Privacy      => 'RT::System-1',
+    Type         => 'Ticket',
+    Name         => 'Recently created tickets',
+    SearchParams => {
+        'Format' => RT->Config->Get('DefaultSearchResultFormat'),
+        'Query'  => "Created >= 'yesterday'",
+    },
+);
+
+ok( $ret, "created $msg" );
+
+{
+    my $res = $mech->get( "$rest_base_path/searches", 'Authorization' => $auth, );
+    is( $res->code, 200, 'got /searches' );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             4,  '4 searches' );
+    is( $content->{page},              1,  '1 page' );
+    is( $content->{per_page},          20, '20 per_page' );
+    is( $content->{total},             4,  '4 total' );
+    is( scalar @{ $content->{items} }, 4,  'items count' );
+
+    for my $item ( @{ $content->{items} } ) {
+        ok( $item->{id}, 'search id' );
+        is( $item->{type}, 'search', 'search type' );
+        like( $item->{_url}, qr{$rest_base_path/search/$item->{id}}, 'search url' );
+    }
+
+    is( $content->{items}[-1]{id}, $search1->Id, 'search id' );
+}
+
+{
+    my $res = $mech->get( "$rest_base_path/search/" . $search2->Id, 'Authorization' => $auth, );
+    is( $res->code, 404, 'can not see system search without permission' );
+}
+
+$user->PrincipalObj->GrantRight( Right => 'SuperUser' );
+
+{
+    my $res = $mech->get( "$rest_base_path/search/" . $search2->Id, 'Authorization' => $auth, );
+    is( $res->code, 200, 'can see system search with permission' );
+}
+
+{
+    my $res = $mech->get( "$rest_base_path/searches", 'Authorization' => $auth, );
+    is( $res->code, 200, 'got /searches' );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             5,  '5 searches' );
+    is( $content->{page},              1,  '1 page' );
+    is( $content->{per_page},          20, '20 per_page' );
+    is( $content->{total},             5,  '5 total' );
+    is( scalar @{ $content->{items} }, 5,  'items count' );
+
+    for my $item ( @{ $content->{items} } ) {
+        ok( $item->{id}, 'search id' );
+        is( $item->{type}, 'search', 'search type' );
+        like( $item->{_url}, qr{$rest_base_path/search/$item->{id}}, 'search url' );
+    }
+
+    is( $content->{items}[3]{id}, $search1->Id, 'search id' );
+    is( $content->{items}[4]{id}, $search2->Id, 'search id' );
+}
+
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/searches",
+        [ { field => 'Description', value => 'My tickets' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200, "got $rest_base_path/searches" );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             1,  '1 search' );
+    is( $content->{page},              1,  '1 page' );
+    is( $content->{per_page},          20, '20 per_page' );
+    is( $content->{total},             1,  '1 total' );
+    is( scalar @{ $content->{items} }, 1,  'items count' );
+
+    is( $content->{items}[0]{id}, $search1->Id, 'search id' );
+}
+
+# Single search
+{
+    my $res = $mech->get( "$rest_base_path/search/" . $search1->Id, 'Authorization' => $auth, );
+    is( $res->code, 200, "got $rest_base_path/search/" . $search1->Id );
+
+    my $content = $mech->json_response;
+    is( $content->{id},          $search1->id,  'id' );
+    is( $content->{Name},        'SavedSearch', 'Name' );
+    is( $content->{Description}, 'My tickets',  'Description' );
+
+    my $links = $content->{_hyperlinks};
+    is( scalar @$links, 2, 'links count' );
+
+    is( $links->[0]{ref},  'self',       'self link ref' );
+    is( $links->[0]{id},   $search1->Id, 'self link id' );
+    is( $links->[0]{type}, 'search',     'self link type' );
+    like( $links->[0]{_url}, qr[$rest_base_path/search/$links->[0]{id}$], 'self link url' );
+
+    is( $links->[1]{ref},  'tickets', 'results link ref' );
+    is( $links->[1]{type}, 'results', 'results link type' );
+    like( $links->[1]{_url}, qr[$rest_base_path/tickets\?search=$content->{id}$], 'results link url' );
+
+    $res = $mech->get( "$rest_base_path/search/My tickets", 'Authorization' => $auth, );
+    is( $res->code, 200, "got $rest_base_path/search/" . $search1->Id );
+    is_deeply( $content, $mech->json_response, 'Access via search name' );
+}
+
+{
+    my $ticket1 = RT::Test->create_ticket(
+        Queue             => 1,
+        Subject           => 'test ticket',
+        Owner             => $user->Id,
+        Requestor         => $user->Id,
+        $custom_role_type => $user->Id
+    );
+    my $ticket2 = RT::Test->create_ticket( Queue => 1, Subject => 'test ticket' );
+    my $res     = $mech->get( "$rest_base_path/tickets?search=" . $search1->Id, 'Authorization' => $auth, );
+    is( $res->code, 200, "got $rest_base_path/tickets?search=" . $search1->Id );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             1,  '1 search' );
+    is( $content->{page},              1,  '1 page' );
+    is( $content->{per_page},          50, '50 per_page' );
+    is( $content->{total},             1,  '1 total' );
+    is( scalar @{ $content->{items} }, 1,  'items count' );
+
+    my $item = $content->{items}[0];
+    is( $item->{id}, $ticket1->Id, 'ticket id' );
+    for my $field ( qw/Requestor Owner Status TimeLeft Subject Priority Created LastUpdated Told Queue/,
+        $custom_role_type )
+    {
+        ok( length $item->{$field}, "$field value not empty" );
+    }
+    is( $item->{Subject},               'test ticket', 'Subject value' );
+    is( $item->{Queue}{Name},           'General',     'Queue name' );
+    is( $item->{Requestor}[0]{id},      $user->Name,   'Requestor id' );
+    is( $item->{Requestor}[0]{Country}, 'US',          'Requestor Country' );
+
+    is( $item->{$custom_role_type}[0]{id},                         $user->Name, 'Manager id' );
+    is( $item->{$custom_role_type}[0]{CustomFields}[0]{name},      'Boss',      'Manager Boss' );
+    is( $item->{$custom_role_type}[0]{CustomFields}[0]{values}[0], 'root',      'Manager Boss name' );
+
+    $res = $mech->get( "$rest_base_path/tickets?search=My tickets", 'Authorization' => $auth, );
+    is( $res->code, 200, "got $rest_base_path/tickets?search=My tickets" );
+    is_deeply( $content, $mech->json_response, 'search tickets via search name' );
+
+    $res = $mech->get( "$rest_base_path/tickets?search=My tickets&per_page=10&fields=id", 'Authorization' => $auth, );
+    is( $res->code, 200, "got $rest_base_path/tickets?search=My tickets" );
+    $content = $mech->json_response;
+    is( $content->{count},             1,  '1 search' );
+    is( $content->{page},              1,  '1 page' );
+    is( $content->{per_page},          10, '10 per_page' );
+    is( $content->{total},             1,  '1 total' );
+    is( scalar @{ $content->{items} }, 1,  'items count' );
+
+    $item = $content->{items}[0];
+    is( $item->{id}, $ticket1->Id, 'ticket id' );
+    for my $field ( qw/Requestor Owner Status TimeLeft Subject Priority Created LastUpdated Told Queue/,
+        $custom_role_type )
+    {
+        ok( !exists $item->{$field}, "$field not exists" );
+    }
+}
+
+done_testing;
diff --git a/t/rest2/ticket-correspond-customroles.t b/t/rest2/ticket-correspond-customroles.t
new file mode 100644
index 0000000000..f52cb5a947
--- /dev/null
+++ b/t/rest2/ticket-correspond-customroles.t
@@ -0,0 +1,254 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+use Test::Deep;
+use MIME::Base64;
+
+BEGIN {
+    plan skip_all => 'RT 4.4 required'
+        unless RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
+}
+
+my $mech = RT::Test::REST2->mech;
+
+my $auth = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Test::REST2->user;
+
+# Set up a couple of custom roles
+my $queue = RT::Test->load_or_create_queue( Name => "General" );
+
+my $single = RT::CustomRole->new(RT->SystemUser);
+my ($ok, $msg) = $single->Create(Name => 'Single Member', MaxValues => 1);
+ok($ok, $msg);
+my $single_id = $single->Id;
+
+($ok, $msg) = $single->AddToObject($queue->id);
+ok($ok, $msg);
+
+my $multi = RT::CustomRole->new(RT->SystemUser);
+($ok, $msg) = $multi->Create(Name => 'Multi Member');
+ok($ok, $msg);
+my $multi_id = $multi->Id;
+
+($ok, $msg) = $multi->AddToObject($queue->id);
+ok($ok, $msg);
+
+$user->PrincipalObj->GrantRight( Right => $_ )
+    for qw/CreateTicket ShowTicket ModifyTicket OwnTicket AdminUsers SeeGroup SeeQueue/;
+
+# Ticket Creation
+my ($ticket_url, $ticket_id);
+{
+    my $payload = {
+        Subject => 'Ticket creation using REST',
+        Queue   => 'General',
+        Content => 'Testing ticket creation using REST API.',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+}
+
+# Ticket Display
+{
+    $user->PrincipalObj->GrantRight( Right => 'ShowTicket' );
+
+    my $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+
+    is($content->{id}, $ticket_id);
+    is($content->{Type}, 'ticket');
+    is($content->{Status}, 'new');
+    is($content->{Subject}, 'Ticket creation using REST');
+
+    ok(exists $content->{$_}, "Content exists for $_") for qw(AdminCc TimeEstimated Started Cc
+                                     LastUpdated TimeWorked Resolved
+                                     RT::CustomRole-1 RT::CustomRole-2
+                                     Created Due Priority EffectiveId);
+}
+
+diag "Correspond with custom roles";
+{
+    $user->PrincipalObj->GrantRight( Right => 'ReplyToTicket' );
+
+    my $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    my $content = $mech->json_response;
+
+    my ($hypermedia) = grep { $_->{ref} eq 'correspond' } @{ $content->{_hyperlinks} };
+    ok($hypermedia, 'got correspond hypermedia');
+    like($hypermedia->{_url}, qr[$rest_base_path/ticket/$ticket_id/correspond$]);
+
+    my $correspond_url = $mech->url_for_hypermedia('correspond');
+    my $comment_url = $correspond_url;
+    $comment_url =~ s/correspond/comment/;
+
+    $res = $mech->post_json($correspond_url,
+        {
+            Content => 'Hello from hypermedia!',
+            ContentType => 'text/plain',
+            CustomRoles => {
+                'Single Member' => 'foo at bar.example',
+                'Multi Member' => 'quux at cabbage.example',
+            },
+        },
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    $content = $mech->json_response;
+
+    # Because CustomRoles are set in an unpredictable order, sort the
+    # responses so we have a predictable order.
+    @$content = sort { $a cmp $b } (@$content);
+    cmp_deeply($content, ['Added quux at cabbage.example as Multi Member for this ticket', re(qr/Correspondence added|Message recorded/), 'Single Member changed from Nobody to foo at bar.example']);
+    like($res->header('Location'), qr{$rest_base_path/transaction/\d+$});
+    $res = $mech->get($res->header('Location'),
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    is($content->{Type}, 'Correspond');
+    is($content->{TimeTaken}, 0);
+    is($content->{Object}{type}, 'ticket');
+    is($content->{Object}{id}, $ticket_id);
+
+    $res = $mech->get($mech->url_for_hypermedia('attachment'),
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    is($content->{Content}, encode_base64('Hello from hypermedia!'));
+    is($content->{ContentType}, 'text/plain');
+
+    # Load the ticket and check the custom roles
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+
+    is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'foo at bar.example',
+       "Single Member role set correctly");
+    is($ticket->RoleAddresses("RT::CustomRole-$multi_id"), 'quux at cabbage.example',
+       "Multi Member role set correctly");
+}
+
+diag "Comment with custom roles";
+{
+    $user->PrincipalObj->GrantRight( Right => 'CommentOnTicket' );
+
+    my $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    my $content = $mech->json_response;
+
+    my ($hypermedia) = grep { $_->{ref} eq 'comment' } @{ $content->{_hyperlinks} };
+    ok($hypermedia, 'got comment hypermedia');
+    like($hypermedia->{_url}, qr[$rest_base_path/ticket/$ticket_id/comment$]);
+
+    my $comment_url = $mech->url_for_hypermedia('comment');
+
+    $res = $mech->post_json($comment_url,
+        {
+            Content => 'Hello from hypermedia!',
+            ContentType => 'text/plain',
+            CustomRoles => {
+                'Single Member' => 'foo-new at bar.example',
+                'Multi Member' => 'quux-new at cabbage.example',
+            },
+        },
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+
+    # Load the ticket and check the custom roles
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+
+    is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'foo-new at bar.example',
+       "Single Member role set correctly");
+    is($ticket->RoleAddresses("RT::CustomRole-$multi_id"), 'quux-new at cabbage.example',
+       "Multi Member role updated correctly");
+
+    # Supply an array for multi-member role
+    $res = $mech->post_json($comment_url,
+        {
+            Content => 'Hello from hypermedia!',
+            ContentType => 'text/plain',
+            CustomRoles => {
+                'Multi Member' => ['abacus at example.com', 'quux-new at cabbage.example'],
+            },
+        },
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+
+    is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'foo-new at bar.example',
+       "Single Member role unchanged");
+    is($ticket->RoleAddresses("RT::CustomRole-$multi_id"), 'abacus at example.com, quux-new at cabbage.example',
+       "Multi Member role set correctly");
+
+    # Add an existing user to multi-member role
+    $res = $mech->post_json($comment_url,
+        {
+            Content => 'Hello from hypermedia!',
+            ContentType => 'text/plain',
+            CustomRoles => {
+                'Multi Member' => 'abacus at example.com',
+            },
+        },
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+
+    is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'foo-new at bar.example',
+       "Single Member role unchanged");
+    is($ticket->RoleAddresses("RT::CustomRole-$multi_id"), 'abacus at example.com',
+       "Multi Member role unchanged");
+
+    # Supply an array for single-member role
+    $res = $mech->post_json($comment_url,
+        {
+            Content => 'Hello from hypermedia!',
+            ContentType => 'text/plain',
+            CustomRoles => {
+                'Single Member' => ['abacus at example.com', 'quux-new at cabbage.example'],
+            },
+        },
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    $content = $mech->json_response;
+    cmp_deeply($content, ['Comments added', 'Single Member changed from foo-new at bar.example to abacus at example.com'], "Got expected respose");
+    is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'abacus at example.com',
+       "Single Member role changed to first member of array");
+
+    # Try using a username instead of password
+    $res = $mech->post_json($comment_url,
+        {
+            Content => 'Hello from hypermedia!',
+            ContentType => 'text/plain',
+            CustomRoles => {
+                'Single Member' => 'test',
+            },
+        },
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    $content = $mech->json_response;
+    cmp_deeply($content, ['Comments added', 'Single Member changed from abacus at example.com to test'], "Got expected respose");
+    is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'test at rt.example',
+       "Single Member role changed");
+}
+
+done_testing;
diff --git a/t/rest2/ticket-customroles.t b/t/rest2/ticket-customroles.t
index e35378dc37..4a09314efb 100644
--- a/t/rest2/ticket-customroles.t
+++ b/t/rest2/ticket-customroles.t
@@ -558,5 +558,45 @@ $user->PrincipalObj->GrantRight( Right => $_ )
     }, 'Later Single Member is Nobody');
 }
 
-done_testing;
 
+# Ticket Search
+{
+
+    my $payload = {
+        Subject            => 'Ticket creation using REST',
+        Queue              => 'General',
+        Content            => 'Testing ticket creation using REST API.',
+        $single->GroupType => 'single2 at example.com',
+        $multi->GroupType  => 'multi at example.com, multi2 at example.com',
+    };
+
+    my $res = $mech->post_json( "$rest_base_path/ticket", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( my $ticket_url = $res->header('location') );
+    ok( my ($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)] );
+
+    $res = $mech->get(
+        "$rest_base_path/tickets?query=id=$ticket_id&fields=" . join( ',', $single->GroupType, $multi->GroupType ),
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+    my $content = $mech->json_response;
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $ticket = $content->{items}->[0];
+    is( $ticket->{ $single->GroupType }{id},   'single2 at example.com', 'Single Member id in search result' );
+    is( $ticket->{ $multi->GroupType }[0]{id}, 'multi at example.com',   'Multi Member id in search result' );
+    is( $ticket->{ $multi->GroupType }[1]{id}, 'multi2 at example.com',  'Multi Member id in search result' );
+
+    $res = $mech->get( "$rest_base_path/tickets?query=id=$ticket_id&fields=CustomRoles", 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    $content = $mech->json_response;
+    is( scalar @{ $content->{items} }, 1 );
+
+    $ticket = $content->{items}->[0];
+    is( $ticket->{CustomRoles}{ $single->GroupType }{id}, 'single2 at example.com',  'Single Member id in search result' );
+    is( $ticket->{CustomRoles}{ $multi->GroupType }[0]{id}, 'multi at example.com',  'Multi Member id in search result' );
+    is( $ticket->{CustomRoles}{ $multi->GroupType }[1]{id}, 'multi2 at example.com', 'Multi Member id in search result' );
+}
+
+done_testing;
diff --git a/t/rest2/ticket-links.t b/t/rest2/ticket-links.t
index ab67c2946f..7717a251be 100644
--- a/t/rest2/ticket-links.t
+++ b/t/rest2/ticket-links.t
@@ -27,6 +27,14 @@ ok($ok, $msg);
 ($ok, $msg) = $child->AddLink(Type => 'RefersTo', Target => 'https://bestpractical.com');
 ok($ok, $msg);
 
+my $article = RT::Article->new(RT->SystemUser);
+($ok, $msg) = $article->Create(Class => 'General', Name => 'article foo');
+ok($ok, $msg);
+my $article_id = $article->Id;
+
+($ok, $msg) = $article->AddLink(Type => 'RefersTo', Target => $parent->URI);
+ok($ok, $msg);
+
 $user->PrincipalObj->GrantRight( Right => 'ShowTicket' );
 
 # Inspect existing ticket links (parent)
@@ -47,7 +55,12 @@ $user->PrincipalObj->GrantRight( Right => 'ShowTicket' );
     cmp_deeply($links{'depended-on-by'}, undef, 'no depended-on-by links');
     cmp_deeply($links{'parent'}, undef, 'no parent links');
     cmp_deeply($links{'refers-to'}, undef, 'no refers-to links');
-    cmp_deeply($links{'referred-to-by'}, undef, 'no referred-to-by links');
+    cmp_deeply($links{'referred-to-by'}, [{
+        ref  => 'referred-to-by',
+        type => 'article',
+        id   => $article_id,
+        _url => re(qr{$rest_base_path/article/$article_id$}),
+    }], 'one referred-to-by link');
 
     cmp_deeply($links{'child'}, [{
         ref  => 'child',
diff --git a/t/rest2/tickets-bulk.t b/t/rest2/tickets-bulk.t
index 09d8cb50b7..8153049769 100644
--- a/t/rest2/tickets-bulk.t
+++ b/t/rest2/tickets-bulk.t
@@ -176,6 +176,7 @@ my @ticket_ids;
         ],
         'json response content'
     );
+    $user->PrincipalObj->RevokeRight( Right => 'ModifyTicket' );
 }
 
 {
@@ -185,5 +186,80 @@ my @ticket_ids;
     }
 }
 
+$user->PrincipalObj->GrantRight( Right => 'ShowTicket' );
+
+{
+    diag "no ReplyToTicket right";
+    my $res = $mech->post_json(
+        "$rest_base_path/tickets/bulk/correspond",
+        [ { id => $ticket_ids[0], Content => 'test correspond', ContentType => 'text/plain' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 201, "status code" );
+    is_deeply( $mech->json_response, [ [ $ticket_ids[0], "Permission Denied", ] ], 'permission denied' );
+
+    diag "grant ReplyToTicket right";
+    $user->PrincipalObj->GrantRight( Right => 'ReplyToTicket' );
+
+    $res = $mech->post_json(
+        "$rest_base_path/tickets/bulk/correspond",
+        [ { id => $ticket_ids[0], Content => 'test correspond', ContentType => 'text/plain' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 201, 'status code' );
+    is_deeply( $mech->json_response, [ [ $ticket_ids[0], "Correspondence added", ] ], 'Correspondence added' );
+
+    $user->PrincipalObj->GrantRight( Right => 'ModifyTicket' );
+    $res = $mech->post_json(
+        "$rest_base_path/tickets/bulk/correspond",
+        [ { id => $ticket_ids[0], Content => 'test correspond', ContentType => 'text/plain', Status => 'new' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 201, 'status code' );
+    is_deeply(
+        $mech->json_response,
+        [ [ $ticket_ids[0], "Correspondence added", "Status changed from 'open' to 'new'" ] ],
+        'Correspondence added'
+    );
+    $user->PrincipalObj->RevokeRight( Right => $_ ) for qw/ReplyToTicket ModifyTicket/;
+}
+
+{
+    diag "no CommentOnTicket right";
+    my $res = $mech->post_json(
+        "$rest_base_path/tickets/bulk/comment",
+        [ { id => $ticket_ids[0], Content => 'test comment', ContentType => 'text/plain' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 201, "status code" );
+    is_deeply( $mech->json_response, [ [ $ticket_ids[0], "Permission Denied", ] ], 'permission denied' );
+
+    diag "grant CommentOnTicket right";
+    $user->PrincipalObj->GrantRight( Right => 'CommentOnTicket' );
+
+    $res = $mech->post_json(
+        "$rest_base_path/tickets/bulk/comment",
+        [ { id => $ticket_ids[0], Content => 'test comment', ContentType => 'text/plain' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 201, 'status code' );
+    is_deeply( $mech->json_response, [ [ $ticket_ids[0], "Comments added", ] ], 'Comments added' );
+
+    # Do status change along with comment
+    $user->PrincipalObj->GrantRight( Right => $_ ) for qw/ShowTicket ModifyTicket/;
+    $res = $mech->post_json(
+        "$rest_base_path/tickets/bulk/comment",
+        [ { id => $ticket_ids[0], Content => 'test comment', ContentType => 'text/plain', Status => 'open' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 201, 'status code' );
+    is_deeply(
+        $mech->json_response,
+        [ [ $ticket_ids[0], "Comments added", "Status changed from 'new' to 'open'" ] ],
+        'Comments added'
+    );
+    $user->PrincipalObj->RevokeRight( Right => $_ ) for qw/CommentOnTicket ModifyTicket/;
+}
+
 done_testing;
 
diff --git a/t/rest2/tickets.t b/t/rest2/tickets.t
index 1fdce910f2..7c6a378be2 100644
--- a/t/rest2/tickets.t
+++ b/t/rest2/tickets.t
@@ -199,7 +199,7 @@ my ($ticket_url, $ticket_id);
 
 # Ticket Search - Fields
 {
-    my $res = $mech->get("$rest_base_path/tickets?query=id>0&fields=Status,Subject",
+    my $res = $mech->get("$rest_base_path/tickets?query=id>0&fields=Status,Subject,_hyperlinks",
         'Authorization' => $auth,
     );
     is($res->code, 200);
@@ -209,7 +209,13 @@ my ($ticket_url, $ticket_id);
     my $ticket = $content->{items}->[0];
     is($ticket->{Subject}, 'Ticket creation using REST');
     is($ticket->{Status}, 'new');
-    is(scalar keys %$ticket, 5);
+
+    my $links = $ticket->{_hyperlinks};
+    is( @$links, 2, '2 links by default' );
+    like( $links->[0]{_url}, qr{$rest_base_path/ticket/1$},         'Self link' );
+    like( $links->[1]{_url}, qr{$rest_base_path/ticket/1/history$}, 'History link' );
+
+    is(scalar keys %$ticket, 6);
 }
 
 # Ticket Search - Fields, sub objects, no right to see Queues
@@ -508,6 +514,112 @@ my ($ticket_url, $ticket_id);
     is($content->{ContentType}, 'text/html');
 }
 
+# Ticket Reply, JSON request, missing Content
+{
+    my $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    my $content = $mech->json_response;
+
+    my ($hypermedia) = grep { $_->{ref} eq 'correspond' } @{ $content->{_hyperlinks} };
+    ok($hypermedia, 'got correspond hypermedia');
+    like($hypermedia->{_url}, qr[$rest_base_path/ticket/$ticket_id/correspond$]);
+
+    $res = $mech->post($mech->url_for_hypermedia('correspond'),
+        'Authorization' => $auth,
+        'Content-Type' => 'application/json',
+        'Content' => '{"Subject":"No body!"}',
+    );
+    is($res->code, 201);
+
+    cmp_deeply($mech->json_response, [re(qr/Correspondence added|Message recorded/)]);
+}
+
+# Ticket Reply, changing status
+{
+    my $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    my $content = $mech->json_response;
+
+    my ($hypermedia) = grep { $_->{ref} eq 'correspond' } @{ $content->{_hyperlinks} };
+    ok($hypermedia, 'got correspond hypermedia');
+    like($hypermedia->{_url}, qr[$rest_base_path/ticket/$ticket_id/correspond$]);
+
+    $res = $mech->post($mech->url_for_hypermedia('correspond'),
+        'Authorization' => $auth,
+        'Content-Type' => 'application/json',
+        'Content' => '{"Subject":"I am a-changing the status!","ContentType":"text/plain","Content":"Foo","Status":"rejected"}',
+    );
+    is($res->code, 201);
+
+    cmp_deeply($mech->json_response, [re(qr/Correspondence added|Message recorded/), "Status changed from 'open' to 'rejected'"]);
+
+    like($res->header('Location'), qr{$rest_base_path/transaction/\d+$});
+    $res = $mech->get($res->header('Location'),
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    is($content->{Type}, 'Correspond');
+    is($content->{TimeTaken}, 0);
+    is($content->{Object}{type}, 'ticket');
+    is($content->{Object}{id}, $ticket_id);
+
+    $res = $mech->get($mech->url_for_hypermedia('attachment'),
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    is($content->{Content}, encode_base64('Foo')),
+    is($content->{ContentType}, 'text/plain');
+
+    # Check that ticket status was updated
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    is($content->{Status}, 'rejected', "Ticket status really was changed");
+
+    # Try an invalid status
+    $res = $mech->post($mech->url_for_hypermedia('correspond'),
+        'Authorization' => $auth,
+        'Content-Type' => 'application/json',
+        'Content' => '{"Subject":"I am a-changing the status!","ContentType":"text/plain","Content":"Foo","Status":"bahaha-youre-so-funny"}',
+    );
+    is($res->code, 201);
+
+    cmp_deeply($mech->json_response, [re(qr/Correspondence added|Message recorded/), "Status 'bahaha-youre-so-funny' isn't a valid status for this ticket."]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    is($content->{Status}, 'open', "Ticket status really was not changed to illegal value");
+
+    # Comment and change status
+    $res = $mech->post($mech->url_for_hypermedia('comment'),
+        'Authorization' => $auth,
+        'Content-Type' => 'application/json',
+        'Content' => '{"Subject":"I am a-changing the status in a comment!","ContentType":"text/plain","Content":"Foo","Status":"rejected"}',
+    );
+    is($res->code, 201);
+
+    cmp_deeply($mech->json_response, ['Comments added', "Status changed from 'open' to 'rejected'"]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    is($content->{Status}, 'rejected', "Ticket status really was changed during a comment");
+
+}
+
 # Ticket Sorted Search
 {
     my $ticket2 = RT::Ticket->new($RT::SystemUser);
@@ -712,4 +824,34 @@ my $json = JSON->new->utf8;
     is( $value->LargeContent, $img_content, 'image file content');
 }
 
+# Ticket Search - Role Fields
+{
+
+    my $payload = {
+        Subject   => 'Ticket creation using REST',
+        Queue     => 'General',
+        Content   => 'Testing ticket creation using REST API.',
+        Requestor => 'alice at example.com',
+        Cc        => 'alice at example.com, bob at example.com',
+        AdminCc   => 'root at example.com',
+    };
+
+    my $res = $mech->post_json( "$rest_base_path/ticket", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( my $ticket_url = $res->header('location') );
+    ok( my ($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)] );
+
+    $res = $mech->get( "$rest_base_path/tickets?query=id=$ticket_id&fields=Requestor,Cc,AdminCc",
+        'Authorization' => $auth, );
+    is( $res->code, 200 );
+    my $content = $mech->json_response;
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $ticket = $content->{items}->[0];
+    is( $ticket->{Requestor}[0]{id}, 'alice at example.com', 'Requestor id in search result' );
+    is( $ticket->{Cc}[0]{id},        'alice at example.com', 'Cc id in search result' );
+    is( $ticket->{Cc}[1]{id},        'bob at example.com',   'Cc id in search result' );
+    is( $ticket->{AdminCc}[0]{id},   'root at example.com',  'AdminCc id in search result' );
+}
+
 done_testing;

-- 
To stop receiving notification emails like this one, please contact
sysadmin at bestpractical.com.


More information about the rt-commit mailing list