[Bps-public-commit] rt-extension-rest2 branch, master, updated. fce0cb17639663e2fa7e4b806b8abaf6b8ec9fa1

Shawn Moore shawn at bestpractical.com
Fri Jul 14 18:07:49 EDT 2017


The branch, master has been updated
       via  fce0cb17639663e2fa7e4b806b8abaf6b8ec9fa1 (commit)
       via  843178fe098c7082c6c05bb38563f50306350d3b (commit)
       via  53390945b964e05b75f9c7892082ce3560ab59e1 (commit)
       via  cf77f9bf77b1b9e99f7cbbb7f73081f8dce184ea (commit)
       via  22f8e73a136d95c70f4fc0a340829cd470ed31f2 (commit)
       via  810f0186921d49d748e4924c7ffbb3ff338c2a9c (commit)
       via  cd1ad96a5986a891693bce84432c07900c47ebce (commit)
       via  41b078a0eb5a11df6b0812b902ec7b06adf48bb5 (commit)
       via  3b694ba7572e147df92054706aca74d635d105a4 (commit)
       via  c7aeb7d86c282b247e5da6668a24323b973be234 (commit)
       via  cb68a1406014d305280db83974ac93f6de97750b (commit)
       via  c82d3aaff1785454795d361c68b311267d1c0bbf (commit)
       via  38a0a5309067628645a5207adcb7762bca512bfe (commit)
       via  a369a6d250f07e3019a4e66f346240d37a06bfa6 (commit)
       via  32c4902268a611be5ae4866726c9567f63f9050d (commit)
       via  0586c0641c8e34d441c0a541fe921dce68dd86fa (commit)
       via  4ee13e096970e704240a0c4f580e40d2446b21e0 (commit)
       via  af319fa11527ab73545cc2784987946f09741de7 (commit)
      from  3e893caa122842444ae9d02a7a704751620a1ff5 (commit)

Summary of changes:
 lib/RT/Extension/REST2.pm                          | 246 ++++++++++++++++----
 .../REST2/Resource/{Ticket.pm => Asset.pm}         |  56 ++---
 .../REST2/Resource/{Groups.pm => Assets.pm}        |   9 +-
 lib/RT/Extension/REST2/Resource/Catalog.pm         |  36 +++
 lib/RT/Extension/REST2/Resource/Catalogs.pm        |  16 ++
 lib/RT/Extension/REST2/Resource/Group.pm           |  10 +-
 lib/RT/Extension/REST2/Resource/Queue.pm           |  11 +-
 lib/RT/Extension/REST2/Resource/RT.pm              |  32 +++
 lib/RT/Extension/REST2/Resource/Record/Writable.pm |  35 ++-
 lib/RT/Extension/REST2/Resource/Root.pm            |  13 +-
 lib/RT/Extension/REST2/Resource/Ticket.pm          |  14 ++
 lib/RT/Extension/REST2/Resource/Transactions.pm    |  29 ++-
 lib/RT/Extension/REST2/Resource/User.pm            |  11 +-
 lib/RT/Extension/REST2/Util.pm                     |   6 +
 t/{ticket-customfields.t => asset-customfields.t}  | 217 ++++++++---------
 t/assets.t                                         | 256 +++++++++++++++++++++
 t/catalogs.t                                       | 238 +++++++++++++++++++
 t/queues.t                                         |  10 +-
 t/root.t                                           |   4 +-
 t/ticket-customfields.t                            |  27 ++-
 t/ticket-customroles.t                             |  20 +-
 t/ticket-watchers.t                                |  34 ++-
 t/tickets.t                                        |  10 +-
 23 files changed, 1104 insertions(+), 236 deletions(-)
 copy lib/RT/Extension/REST2/Resource/{Ticket.pm => Asset.pm} (55%)
 copy lib/RT/Extension/REST2/Resource/{Groups.pm => Assets.pm} (65%)
 create mode 100644 lib/RT/Extension/REST2/Resource/RT.pm
 copy t/{ticket-customfields.t => asset-customfields.t} (54%)
 create mode 100644 t/assets.t
 create mode 100644 t/catalogs.t

- Log -----------------------------------------------------------------
commit af319fa11527ab73545cc2784987946f09741de7
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 17:22:37 2017 +0000

    Fix tests for creating and passing users

diff --git a/t/ticket-customroles.t b/t/ticket-customroles.t
index a87a749..3e93351 100644
--- a/t/ticket-customroles.t
+++ b/t/ticket-customroles.t
@@ -28,6 +28,12 @@ my $multi_id = $multi->Id;
 ($ok, $msg) = $multi->AddToObject($queue->id);
 ok($ok, $msg);
 
+for my $email (qw/multi at example.com test at localhost multi2 at example.com single2 at example.com/) {
+    my $user = RT::User->new(RT->SystemUser);
+    my ($ok, $msg) = $user->Create(Name => $email, EmailAddress => $email);
+    ok($ok, $msg);
+}
+
 $user->PrincipalObj->GrantRight( Right => $_ )
     for qw/CreateTicket ShowTicket ModifyTicket OwnTicket AdminUsers/;
 
@@ -144,8 +150,8 @@ $user->PrincipalObj->GrantRight( Right => $_ )
 
     cmp_deeply($content->{$single->GroupType}, {
         type => 'user',
-        id   => 'test',
-        _url => re(qr{$rest_base_path/user/test$}),
+        id   => 'test at localhost',
+        _url => re(qr{$rest_base_path/user/test\@localhost$}),
     }, 'one Single Member');
 }
 
@@ -184,8 +190,8 @@ $user->PrincipalObj->GrantRight( Right => $_ )
 
     cmp_deeply($content->{$single->GroupType}, {
         type => 'user',
-        id   => 'test',
-        _url => re(qr{$rest_base_path/user/test$}),
+        id   => 'test at localhost',
+        _url => re(qr{$rest_base_path/user/test\@localhost}),
     }, 'one Single Member');
 }
 
diff --git a/t/ticket-watchers.t b/t/ticket-watchers.t
index b145ef0..1565ba9 100644
--- a/t/ticket-watchers.t
+++ b/t/ticket-watchers.t
@@ -64,7 +64,7 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         Requestor => 'requestor at example.com',
         Cc        => 'cc at example.com',
         AdminCc   => 'admincc at example.com',
-        Owner     => $user->EmailAddress,
+        Owner     => $user->PrincipalId,
     };
 
     my $res = $mech->post_json("$rest_base_path/ticket",
@@ -114,7 +114,7 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         Requestor => ['requestor at example.com', 'requestor2 at example.com'],
         Cc        => ['cc at example.com', 'cc2 at example.com'],
         AdminCc   => ['admincc at example.com', 'admincc2 at example.com'],
-        Owner     => $user->EmailAddress,
+        Owner     => $user->PrincipalId,
     };
 
     my $res = $mech->post_json("$rest_base_path/ticket",

commit 4ee13e096970e704240a0c4f580e40d2446b21e0
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 17:48:47 2017 +0000

    Avoid CanonicalizePrincipal() not-defined errors on 4.2
    
    If passed a numeric id, we can avoid canonicalizing altogether. On 4.4
    we can pass anything else to CanonicalizePrincipal. On 4.2 we can assume
    it's an email address or user name, which is the important part of
    CanonicalizePrincipal anyway.

diff --git a/lib/RT/Extension/REST2/Resource/Record/Writable.pm b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
index eedbfe8..57fa911 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
@@ -176,16 +176,32 @@ sub _update_role_members {
             my @vals;
 
             for (ref($val) eq 'ARRAY' ? @$val : $val) {
-                my $key = 'User';
-                $key = 'PrincipalId' if /^\d+$/;
-                my ($principal, $msg) = $record->CanonicalizePrincipal($key => $_);
-                if (!$principal) {
+                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}++;
+                push @vals, $principal_id;
+                $count{$principal_id}++;
             }
 
             my $group = $record->RoleGroup($role);

commit 0586c0641c8e34d441c0a541fe921dce68dd86fa
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 17:49:42 2017 +0000

    Avoid generating spurious deprecation warnings in serialize_record
    
    These deprecation warnings are meant to indicate to a developer that
    their customization/extension uses a deprecated feature. That's not the
    case here; we're using an abstraction.
    
    It'd be great if we could somehow flag to RT->Deprecated that we don't
    want to see such warnings but in lieu of that, suppress any deprecation
    warning from calling these methods.
    
    Added for RT::Group->Type on 4.2

diff --git a/lib/RT/Extension/REST2/Util.pm b/lib/RT/Extension/REST2/Util.pm
index e0998d6..8989500 100644
--- a/lib/RT/Extension/REST2/Util.pm
+++ b/lib/RT/Extension/REST2/Util.pm
@@ -57,6 +57,12 @@ sub serialize_record {
     my $record = shift;
     my %data   = $record->Serialize(@_);
 
+    no warnings 'redefine';
+    local *RT::Deprecated = sub {
+        # don't trigger deprecation warnings for $record->$column below
+        # such as RT::Group->Type on 4.2
+    };
+
     for my $column (grep !ref($data{$_}), keys %data) {
         if ($record->_Accessible($column => "read")) {
             # Replace values via the Perl API for consistency, access control,

commit 32c4902268a611be5ae4866726c9567f63f9050d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 17:51:19 2017 +0000

    4.4 added SortOrder and SLADisabled to Queues
    
    Don't fail tests on 4.2 due to their absence

diff --git a/t/queues.t b/t/queues.t
index 2888383..56b211f 100644
--- a/t/queues.t
+++ b/t/queues.t
@@ -47,8 +47,9 @@ my $queue_url;
     is($content->{Lifecycle}, 'default');
     is($content->{Disabled}, 0);
 
-    ok(exists $content->{$_}) for qw(LastUpdated Created SortOrder SLADisabled
-                                     CorrespondAddress CommentAddress);
+    my @fields = qw(LastUpdated Created CorrespondAddress CommentAddress);
+    push @fields, qw(SortOrder SLADisabled) if RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
+    ok(exists $content->{$_}, "got $_") for @fields;
 
     my $links = $content->{_hyperlinks};
     is(scalar @$links, 3);
@@ -185,8 +186,9 @@ my ($features_url, $features_id);
     is($content->{Lifecycle}, 'default');
     is($content->{Disabled}, 0);
 
-    ok(exists $content->{$_}) for qw(LastUpdated Created SortOrder SLADisabled
-                                     CorrespondAddress CommentAddress Description);
+    my @fields = qw(LastUpdated Created CorrespondAddress CommentAddress);
+    push @fields, qw(SortOrder SLADisabled) if RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
+    ok(exists $content->{$_}, "got $_") for @fields;
 
     my $links = $content->{_hyperlinks};
     is(scalar @$links, 3);

commit a369a6d250f07e3019a4e66f346240d37a06bfa6
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 17:52:00 2017 +0000

    Handle 4.2-era messaging differences in tests

diff --git a/t/ticket-watchers.t b/t/ticket-watchers.t
index 1565ba9..d14f5f6 100644
--- a/t/ticket-watchers.t
+++ b/t/ticket-watchers.t
@@ -274,7 +274,11 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         $payload,
         'Authorization' => $auth,
     );
-    is_deeply($mech->json_response, ['Added admincc at example.com as AdminCc for this ticket', 'Added cc at example.com as Cc for this ticket', 'Added requestor at example.com as Requestor for this ticket'], "updated ticket watchers");
+    cmp_deeply($mech->json_response, [
+        re(qr/Added admincc\@example.com as( a)? AdminCc for this ticket/),
+        re(qr/Added cc\@example.com as( a)? Cc for this ticket/),
+        re(qr/Added requestor\@example.com as( a)? Requestor for this ticket/)
+    ], "updated ticket watchers");
 
     $res = $mech->get($ticket_url,
         'Authorization' => $auth,
@@ -309,7 +313,14 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         $payload,
         'Authorization' => $auth,
     );
-    is_deeply($mech->json_response, ['Added admincc2 at example.com as AdminCc for this ticket', 'admincc at example.com is no longer AdminCc for this ticket', 'Added cc2 at example.com as Cc for this ticket', 'cc at example.com is no longer Cc for this ticket', 'Added requestor2 at example.com as Requestor for this ticket', 'requestor at example.com is no longer Requestor for this ticket'], "updated ticket watchers");
+    cmp_deeply($mech->json_response, [
+        re(qr/Added admincc2\@example.com as( a)? AdminCc for this ticket/),
+        re(qr/admincc\@example.com is no longer( a)? AdminCc for this ticket/),
+        re(qr/Added cc2\@example.com as( a)? Cc for this ticket/),
+        re(qr/cc\@example.com is no longer( a)? Cc for this ticket/),
+        re(qr/Added requestor2\@example.com as( a)? Requestor for this ticket/),
+        re(qr/requestor\@example.com is no longer( a)? Requestor for this ticket/),
+    ], "updated ticket watchers");
 
     $res = $mech->get($ticket_url,
         'Authorization' => $auth,
@@ -344,7 +355,11 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         $payload,
         'Authorization' => $auth,
     );
-    is_deeply($mech->json_response, ['Added admincc at example.com as AdminCc for this ticket', 'Added cc at example.com as Cc for this ticket', 'Added requestor at example.com as Requestor for this ticket'], "updated ticket watchers");
+    cmp_deeply($mech->json_response, [
+        re(qr/Added admincc\@example.com as( a)? AdminCc for this ticket/),
+        re(qr/Added cc\@example.com as( a)? Cc for this ticket/),
+        re(qr/Added requestor\@example.com as( a)? Requestor for this ticket/)
+    ], "updated ticket watchers");
 
     $res = $mech->get($ticket_url,
         'Authorization' => $auth,
@@ -504,7 +519,10 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         $payload,
         'Authorization' => $auth,
     );
-    is_deeply($mech->json_response, ['Added admincc at example.com as AdminCc for this ticket', 'Watcher Group is no longer AdminCc for this ticket'], "updated ticket watchers");
+    cmp_deeply($mech->json_response, [
+        re(qr/Added admincc\@example.com as( a)? AdminCc for this ticket/),
+        re(qr/Watcher Group is no longer( a)? AdminCc for this ticket/),
+    ], "updated ticket watchers");
 
     $res = $mech->get($ticket_url,
         'Authorization' => $auth,
@@ -525,7 +543,9 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         $payload,
         'Authorization' => $auth,
     );
-    is_deeply($mech->json_response, ['Added Watcher Group as AdminCc for this ticket'], "updated ticket watchers");
+    cmp_deeply($mech->json_response, [
+        re(qr/Added Watcher Group as( a)? AdminCc for this ticket/),
+    ], "updated ticket watchers");
 
     $res = $mech->get($ticket_url,
         'Authorization' => $auth,
diff --git a/t/tickets.t b/t/tickets.t
index b0d3a28..37a63a9 100644
--- a/t/tickets.t
+++ b/t/tickets.t
@@ -2,6 +2,7 @@ use strict;
 use warnings;
 use lib 't/lib';
 use RT::Extension::REST2::Test tests => undef;
+use Test::Deep;
 
 my $mech = RT::Extension::REST2::Test->mech;
 
@@ -277,7 +278,7 @@ my ($ticket_url, $ticket_id);
         'Content' => 'Hello from hypermedia!',
     );
     is($res->code, 201);
-    is_deeply($mech->json_response, ["Correspondence added"]);
+    cmp_deeply($mech->json_response, [re(qr/Correspondence added|Message recorded/)]);
 
     like($res->header('Location'), qr{$rest_base_path/transaction/\d+$});
     $res = $mech->get($res->header('Location'),
@@ -328,7 +329,7 @@ my ($ticket_url, $ticket_id);
         'Authorization' => $auth,
     );
     is($res->code, 201);
-    is_deeply($mech->json_response, ["Comments added"]);
+    cmp_deeply($mech->json_response, [re(qr/Comments added|Message recorded/)]);
 
     my $txn_url = $res->header('Location');
     like($txn_url, qr{$rest_base_path/transaction/\d+$});

commit 38a0a5309067628645a5207adcb7762bca512bfe
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 18:10:24 2017 +0000

    Duplicate OCFVs have differ in behavior between RT versions

diff --git a/t/ticket-customfields.t b/t/ticket-customfields.t
index fb5fcca..d1013fc 100644
--- a/t/ticket-customfields.t
+++ b/t/ticket-customfields.t
@@ -62,7 +62,7 @@ my ($ticket_url, $ticket_id);
     ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
 
    TODO: {
-       local $TODO = "this warns due to specifying a CF with no permission to see";
+       local $TODO = "this warns due to specifying a CF with no permission to see" if RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
        is(@warnings, 0, "no warnings");
    }
 }
@@ -370,10 +370,17 @@ for my $value (
     modify_multi_ok('replace all', ['replace all added as a value for Multi', 'multiple is no longer a value for custom field Multi', 'new is no longer a value for custom field Multi'], ['replace all'], 'replaced all values');
     modify_multi_ok([], ['replace all is no longer a value for custom field Multi'], [], 'removed all values');
 
-    modify_multi_ok(['foo', 'foo', 'bar'], ['foo added as a value for Multi', undef, 'bar added as a value for Multi'], ['bar', 'foo'], 'multiple values with the same name');
-    modify_multi_ok(['foo', 'bar'], [], ['bar', 'foo'], 'multiple values with the same name');
-    modify_multi_ok(['bar'], ['foo is no longer a value for custom field Multi'], ['bar'], 'multiple values with the same name');
-    modify_multi_ok(['bar', 'bar', 'bar'], [undef, undef], ['bar'], 'multiple values with the same name');
+    if (RT::Handle::cmp_version($RT::VERSION, '4.2.5') >= 0) {
+        modify_multi_ok(['foo', 'foo', 'bar'], ['foo added as a value for Multi', undef, 'bar added as a value for Multi'], ['bar', 'foo'], 'multiple values with the same name');
+        modify_multi_ok(['foo', 'bar'], [], ['bar', 'foo'], 'multiple values with the same name');
+        modify_multi_ok(['bar'], ['foo is no longer a value for custom field Multi'], ['bar'], 'multiple values with the same name');
+        modify_multi_ok(['bar', 'bar', 'bar'], [undef, undef], ['bar'], 'multiple values with the same name');
+    } else {
+        modify_multi_ok(['foo', 'foo', 'bar'], ['foo added as a value for Multi', 'foo added as a value for Multi', 'bar added as a value for Multi'], ['bar', 'foo', 'foo'], 'multiple values with the same name');
+        modify_multi_ok(['foo', 'bar'], ['foo is no longer a value for custom field Multi'], ['bar', 'foo'], 'multiple values with the same name');
+        modify_multi_ok(['bar'], ['foo is no longer a value for custom field Multi'], ['bar'], 'multiple values with the same name');
+        modify_multi_ok(['bar', 'bar', 'bar'], ['bar added as a value for Multi', 'bar added as a value for Multi'], ['bar', 'bar', 'bar'], 'multiple values with the same name');
+    }
 }
 
 done_testing;

commit c82d3aaff1785454795d361c68b311267d1c0bbf
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 18:10:47 2017 +0000

    4.2 doesn't have custom roles, so skip CR tests

diff --git a/t/ticket-customroles.t b/t/ticket-customroles.t
index 3e93351..fe3052a 100644
--- a/t/ticket-customroles.t
+++ b/t/ticket-customroles.t
@@ -2,6 +2,12 @@ use strict;
 use warnings;
 use lib 't/lib';
 use RT::Extension::REST2::Test tests => undef;
+
+BEGIN {
+    plan skip_all => 'RT 4.4 required'
+        unless RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
+}
+
 use Test::Deep;
 
 my $mech = RT::Extension::REST2::Test->mech;

commit cb68a1406014d305280db83974ac93f6de97750b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 18:11:05 2017 +0000

    Implement 403 on POST /ticket with no CreateTicket

diff --git a/lib/RT/Extension/REST2/Resource/Record/Writable.pm b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
index 57fa911..3621faf 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
@@ -303,7 +303,12 @@ sub create_resource {
     }
 
     my ($ok, $msg) = $self->create_record($data);
-    if ($ok) {
+    if (ref($ok)) {
+        return error_as_json(
+            $self->response,
+            $ok, $msg || "Create failed for unknown reason");
+    }
+    elsif ($ok) {
         my $response = $self->response;
         my $body = JSON::encode_json(expand_uid($self->record->UID));
         $response->content_type( "application/json; charset=utf-8" );
diff --git a/lib/RT/Extension/REST2/Resource/Ticket.pm b/lib/RT/Extension/REST2/Resource/Ticket.pm
index 0b81df5..aa19a89 100644
--- a/lib/RT/Extension/REST2/Resource/Ticket.pm
+++ b/lib/RT/Extension/REST2/Resource/Ticket.pm
@@ -29,6 +29,20 @@ sub dispatch_rules {
 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;
+
     my ($ok, $txn, $msg) = $self->_create_record($data);
     return ($ok, $msg);
 }
diff --git a/t/ticket-customfields.t b/t/ticket-customfields.t
index d1013fc..1da94d6 100644
--- a/t/ticket-customfields.t
+++ b/t/ticket-customfields.t
@@ -41,10 +41,7 @@ my ($ticket_url, $ticket_id);
         $payload,
         'Authorization' => $auth,
     );
-    TODO: {
-        local $TODO = "this should return 403";
-        is($res->code, 403);
-    }
+    is($res->code, 403);
 
     my @warnings;
     local $SIG{__WARN__} = sub {
diff --git a/t/tickets.t b/t/tickets.t
index 37a63a9..ae151c1 100644
--- a/t/tickets.t
+++ b/t/tickets.t
@@ -48,10 +48,7 @@ my ($ticket_url, $ticket_id);
         $payload,
         'Authorization' => $auth,
     );
-    TODO: {
-        local $TODO = "this should return 403";
-        is($res->code, 403);
-    }
+    is($res->code, 403);
 
     # Rights Test - With CreateTicket
     $user->PrincipalObj->GrantRight( Right => 'CreateTicket' );

commit c7aeb7d86c282b247e5da6668a24323b973be234
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 20:23:42 2017 +0000

    4.2.3-4.2.8 had a bug with creating tickets with no-permission CFs

diff --git a/t/ticket-customfields.t b/t/ticket-customfields.t
index 1da94d6..641ac3a 100644
--- a/t/ticket-customfields.t
+++ b/t/ticket-customfields.t
@@ -36,6 +36,11 @@ my ($ticket_url, $ticket_id);
         },
     };
 
+    # 4.2.3 introduced a bug (e092e23) in CFs fixed in 4.2.9 (ab7ea15)
+    delete $payload->{CustomFields}
+        if RT::Handle::cmp_version($RT::VERSION, '4.2.3') >= 0
+        && RT::Handle::cmp_version($RT::VERSION, '4.2.8') <= 0;
+
     # Rights Test - No CreateTicket
     my $res = $mech->post_json("$rest_base_path/ticket",
         $payload,

commit 3b694ba7572e147df92054706aca74d635d105a4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 20:22:36 2017 +0000

    Add /rt endpoint
    
    This gives version number and list of plugins, both of which are
    potentially relevant to API consumers.

diff --git a/lib/RT/Extension/REST2/Resource/RT.pm b/lib/RT/Extension/REST2/Resource/RT.pm
new file mode 100644
index 0000000..1c5906a
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/RT.pm
@@ -0,0 +1,32 @@
+package RT::Extension::REST2::Resource::RT;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::Extension::REST2::Resource';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/rt?$},
+    );
+}
+
+sub charsets_provided      { [ 'utf-8' ] }
+sub default_charset        {   'utf-8'   }
+sub allowed_methods        { ['GET'] }
+
+sub content_types_provided { [{ 'application/json' => 'to_json' }] }
+
+sub to_json {
+    my $self = shift;
+    return JSON::to_json({
+        Version => $RT::VERSION,
+        Plugins => [ RT->Config->Get('Plugins') ],
+    }, { pretty => 1 });
+}
+__PACKAGE__->meta->make_immutable;
+
+1;
+

commit 41b078a0eb5a11df6b0812b902ec7b06adf48bb5
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 20:24:09 2017 +0000

    Rewrite endpoint docs

diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index 2e9332b..928cdaa 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -238,69 +238,153 @@ the form of additional hyperlinks.
 
 =head2 Endpoints
 
-Currently provided endpoints under C</REST/2.0/> are:
+Currently provided endpoints under C</REST/2.0/> are described below.
+Wherever possible please consider using C<_hyperlinks> hypermedia
+controls available in response bodies rather than hardcoding URLs.
+
+=head3 Tickets
+
+    GET /tickets?query=<TicketSQL>
+        search for tickets using TicketSQL
+
+    GET /tickets?simple=1;query=<simple search query>
+        search for tickets using simple search syntax
+
+    POST /tickets
+        search for tickets with the 'query' and optional 'simple' parameters
+
+    POST /ticket
+        create a ticket; provide JSON content
 
     GET /ticket/:id
-    PUT /ticket/:id <JSON body>
+        retrieve a ticket
+
+    PUT /ticket/:id
+        update a ticket's metadata; provide JSON content
+
     DELETE /ticket/:id
-        Sets ticket status to "deleted".
+        set status to deleted
+
+    POST /ticket/:id/correspond
+    POST /ticket/:id/comment
+        add a reply or comment to the ticket
+
+    GET /ticket/:id/history
+        retrieve list of transactions for ticket
+
+=head3 Transactions
+
+    GET /transactions?query=<JSON>
+    POST /transactions
+        search for transactions using L</JSON searches> syntax
+
+    GET /ticket/:id/history
+    GET /queue/:id/history
+    GET /queue/:name/history
+        get transactions for record
+
+    GET /transaction/:id
+        retrieve a transaction
+
+=head3 Attachments and Messages
+
+    GET /attachments?query=<JSON>
+    POST /attachments
+        search for attachments using L</JSON searches> syntax
+
+    GET /transaction/:id/attachments
+        get attachments for transaction
+
+    GET /attachment/:id
+        retrieve an attachment
+
+=head3 Queues
+
+    GET /queues/all
+        retrieve list of all queues you can see
+
+    GET /queues?query=<JSON>
+    POST /queues
+        search for queues using L</JSON searches> syntax
+
+    POST /queue
+        create a queue; provide JSON content
 
     GET /queue/:id
-    PUT /queue/:id <JSON body>
+    GET /queue/:name
+        retrieve a queue by numeric id or name
+
+    PUT /queue/:id
+    PUT /queue/:name
+        update a queue's metadata; provide JSON content
+
     DELETE /queue/:id
-        Disables the queue.
+    DELETE /queue/:name
+        disable queue
+
+    GET /queue/:id/history
+    GET /queue/:name/history
+        retrieve list of transactions for queue
+
+=head3 Users
+
+    GET /users?query=<JSON>
+    POST /users
+        search for users using L</JSON searches> syntax
+
+    POST /user
+        create a user; provide JSON content
 
     GET /user/:id
-    PUT /user/:id <JSON body>
-    DELETE /user/:id
-        Disables the user.
+    GET /user/:name
+        retrieve a user by numeric id or username
 
-For queues and users, C<:id> may be the numeric id or the unique name.
+    PUT /user/:id
+    PUT /user/:name
+        update a user's metadata; provide JSON content
 
-When a GET request is made, each endpoint returns a JSON representation of the
-specified resource, or a 404 if not found.
+    DELETE /user/:id
+    DELETE /user/:name
+        disable user
 
-When a PUT request is made, the request body should be a modified copy (or
-partial copy) of the JSON representation of the specified resource, and the
-record will be updated.
+=head3 Groups
 
-A DELETE request to a resource will delete or disable the underlying record.
+    GET /groups?query=<JSON>
+    POST /groups
+        search for groups using L</JSON searches> syntax
 
-=head2 Creating
+    GET /group/:id
+        retrieve a group (including its members)
 
-    POST /ticket
-    POST /queue
-    POST /user
+=head3 Custom Fields
 
-A POST request to a resource endpoint, without a specific id/name, will create
-a new resource of that type.  The request should have a JSON payload similar to
-the ones returned for existing resources.
+    GET /customfields?query=<JSON>
+    POST /customfields
+        search for custom fields using L</JSON searches> syntax
 
-On success, the return status is 201 Created and a Location header points to
-the new resource uri.  On failure, the status code indicates the nature of the
-issue, and a descriptive message is in the response body.
+    GET /customfield/:id
+        retrieve a custom field
 
-=head2 Searching
+=head3 Custom Roles
 
-=head3 Tickets
+    GET /customroles?query=<JSON>
+    POST /customroles
+        search for custom roles using L</JSON searches> syntax
 
-    GET /tickets?query=<TicketSQL>
-    GET /tickets?simple=1;query=<simple search query>
-    POST /tickets
-        With the 'query' and optional 'simple' parameters
+    GET /customrole/:id
+        retrieve a custom role
 
-The C<query> parameter expects TicketSQL by default unless a true value is sent
-for the C<simple> parameter.
+=head3 Miscellaneous
 
-Results are returned in
-L<the format described below|/"Example of plural resources (collections)">.
+    GET /
+        produces this documentation
 
-=head3 Queues and users
+    GET /rt
+        produces system information
 
-    POST /queues
-    POST /users
+=head2 JSON searches
 
-These resources accept a basic JSON structure as the search conditions which
+Some resources accept a basic JSON structure as the search conditions which
 specifies one or more fields to limit on (using specified operators and
 values).  An example:
 

commit cd1ad96a5986a891693bce84432c07900c47ebce
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 20:29:13 2017 +0000

    Produce root doc in text/plain too
    
    plaintext is preferred for curl and other arbitrary consumers, but
    browsers will offer an Accept header that prefers HTML and so they still
    get that

diff --git a/lib/RT/Extension/REST2/Resource/Root.pm b/lib/RT/Extension/REST2/Resource/Root.pm
index 2ffa895..015932f 100644
--- a/lib/RT/Extension/REST2/Resource/Root.pm
+++ b/lib/RT/Extension/REST2/Resource/Root.pm
@@ -14,13 +14,22 @@ sub dispatch_rules {
     );
 }
 
-sub content_types_provided { [{ 'text/html' => 'to_html' }] }
+sub content_types_provided {[
+    { 'text/plain' => 'to_text' },
+    { 'text/html'  => 'to_html' }
+]}
+
 sub charsets_provided      { [ 'utf-8' ] }
 sub default_charset        {   'utf-8'   }
 sub allowed_methods        { ['GET', 'HEAD', 'OPTIONS'] }
 
+sub to_text {
+    my $html = shift->to_html;
+    return RT::Interface::Email::ConvertHTMLToText($html);
+}
+
 sub to_html {
-    return podview_as_html('RT::Extension::REST2') 
+    return podview_as_html('RT::Extension::REST2');
 }
 
 __PACKAGE__->meta->make_immutable;
diff --git a/t/root.t b/t/root.t
index 9ed51e8..cb96d4a 100644
--- a/t/root.t
+++ b/t/root.t
@@ -19,7 +19,7 @@ my $auth = RT::Extension::REST2::Test->authorization_header;
 # Documentation on Root Path
 {
     for my $path ($rest_base_path, "$rest_base_path/") {
-        my $res = $mech->get($path, 'Authorization' => $auth);
+        my $res = $mech->get($path, 'Authorization' => $auth, 'Accept' => 'text/html');
         is($res->code, 200);
         is($res->header('content-type'), 'text/html; charset="utf-8"');
 
@@ -30,7 +30,7 @@ my $auth = RT::Extension::REST2::Test->authorization_header;
         $mech->content_like(qr/INSTALLATION/);
         $mech->content_like(qr/USAGE/);
 
-        $res = $mech->head($path, 'Authorization' => $auth);
+        $res = $mech->head($path, 'Authorization' => $auth, Accept => 'text/html');
         is($res->code, 200);
         is($res->header('content-type'), 'text/html; charset="utf-8"');
     }

commit 810f0186921d49d748e4924c7ffbb3ff338c2a9c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 20:40:41 2017 +0000

    Improve /user/:name regex

diff --git a/lib/RT/Extension/REST2/Resource/User.pm b/lib/RT/Extension/REST2/Resource/User.pm
index 640e05d..c444b55 100644
--- a/lib/RT/Extension/REST2/Resource/User.pm
+++ b/lib/RT/Extension/REST2/Resource/User.pm
@@ -22,7 +22,7 @@ sub dispatch_rules {
         block => sub { { record_class => 'RT::User', record_id => shift->pos(1) } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/user/(.*?)/?$},
+        regex => qr{^/user/([^/]+)/?$},
         block => sub {
             my ($match, $req) = @_;
             my $user = RT::User->new($req->env->{"rt.current_user"});

commit 22f8e73a136d95c70f4fc0a340829cd470ed31f2
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 20:40:33 2017 +0000

    Support for /queue/:name

diff --git a/lib/RT/Extension/REST2/Resource/Queue.pm b/lib/RT/Extension/REST2/Resource/Queue.pm
index af27783..febdd5a 100644
--- a/lib/RT/Extension/REST2/Resource/Queue.pm
+++ b/lib/RT/Extension/REST2/Resource/Queue.pm
@@ -22,7 +22,16 @@ sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
         regex => qr{^/queue/(\d+)/?$},
         block => sub { { record_class => 'RT::Queue', record_id => shift->pos(1) } },
-    )
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/queue/([^/]+)/?$},
+        block => sub {
+            my ($match, $req) = @_;
+            my $queue = RT::Queue->new($req->env->{"rt.current_user"});
+            $queue->Load($match->pos(1));
+            return { record => $queue };
+        },
+    ),
 }
 
 sub hypermedia_links {
diff --git a/lib/RT/Extension/REST2/Resource/Transactions.pm b/lib/RT/Extension/REST2/Resource/Transactions.pm
index 2b82c47..1502ef9 100644
--- a/lib/RT/Extension/REST2/Resource/Transactions.pm
+++ b/lib/RT/Extension/REST2/Resource/Transactions.pm
@@ -30,6 +30,21 @@ sub dispatch_rules {
             $record->Load($id);
             return { collection => $record->Transactions };
         },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/(queue)/([^/]+)/history/?$},
+        block => sub {
+            my ($match, $req) = @_;
+            my ($class, $id) = ($match->pos(1), $match->pos(2));
+
+            my $record;
+            if ($class eq 'queue') {
+                $record = RT::Queue->new($req->env->{"rt.current_user"});
+            }
+
+            $record->Load($id);
+            return { collection => $record->Transactions };
+        },
     )
 }
 

commit cf77f9bf77b1b9e99f7cbbb7f73081f8dce184ea
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 20:41:30 2017 +0000

    Additional conditional requests doc

diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index 928cdaa..85fd18b 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -467,14 +467,26 @@ to authenticate against REST2. This is primarily intended for
 interacting with REST2 via JavaScript in the browser. Other REST
 consumers are advised to use the alternatives above.
 
-=head2 Conditional requests (If-Modified-Since)
-
-You can take advantage of the C<Last-Modified> headers returned by most single
-resource endpoints.  Add a C<If-Modified-Since> header to your requests for
-the same resource, using the most recent C<Last-Modified> value seen, and the
-API may respond with a 304 Not Modified.  You can also use HEAD requests to
-check for updates without receiving the actual content when there is a newer
-version.
+=head2 Conditional requests (If-Modified-Since, If-Match)
+
+You can take advantage of the C<Last-Modified> headers returned by most
+single resource endpoints.  Add a C<If-Modified-Since> header to your
+requests for the same resource, using the most recent C<Last-Modified>
+value seen, and the API may respond with a 304 Not Modified.  You can
+also use HEAD requests to check for updates without receiving the actual
+content when there is a newer version. You may also add an
+C<If-Unmodified-Since> header to your updates to tell the server to
+refuse updates if the record had been changed since you last retrieved
+it.
+
+C<ETag>, C<If-Match>, and C<If-None-Match> work similarly to
+C<Last-Modified>, C<If-Modified-Since>, and C<If-Unmodified-Since>,
+except that they don't use a timestamp, which has its own set of
+tradeoffs. C<ETag> is an opaque value, so it has no meaning to consumers
+(unlike timestamps). However, timestamps have the disadvantage of having
+a resolution of seconds, so two updates happening in the same second
+would produce incorrect results, whereas C<ETag> does not suffer from
+that problem.
 
 =head2 Status codes
 

commit 53390945b964e05b75f9c7892082ce3560ab59e1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 20:41:38 2017 +0000

    Copyright 2015-2017

diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index 85fd18b..08bbecb 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -548,7 +548,7 @@ L<rt.cpan.org|http://rt.cpan.org/Public/Dist/Display.html?Name=RT-Extension-REST
 
 =head1 LICENSE AND COPYRIGHT
 
-This software is Copyright (c) 2015 by Best Practical Solutions, LLC.
+This software is Copyright (c) 2015-2017 by Best Practical Solutions, LLC.
 
 This is free software, licensed under:
 

commit 843178fe098c7082c6c05bb38563f50306350d3b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 21:29:29 2017 +0000

    Add support for assets and catalogs

diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index 08bbecb..7e02f5d 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -281,6 +281,7 @@ controls available in response bodies rather than hardcoding URLs.
     GET /ticket/:id/history
     GET /queue/:id/history
     GET /queue/:name/history
+    GET /asset/:id/history
         get transactions for record
 
     GET /transaction/:id
@@ -326,6 +327,51 @@ controls available in response bodies rather than hardcoding URLs.
     GET /queue/:name/history
         retrieve list of transactions for queue
 
+=head3 Assets
+
+    GET /assets?query=<JSON>
+    POST /assets
+        search for assets using L</JSON searches> syntax
+
+    POST /asset
+        create an asset; provide JSON content
+
+    GET /asset/:id
+        retrieve an asset
+
+    PUT /asset/:id
+        update an asset's metadata; provide JSON content
+
+    DELETE /asset/:id
+        set status to deleted
+
+    GET /asset/:id/history
+        retrieve list of transactions for asset
+
+=head3 Catalogs
+
+    GET /catalogs/all
+        retrieve list of all catalogs you can see
+
+    GET /catalogs?query=<JSON>
+    POST /catalogs
+        search for catalogs using L</JSON searches> syntax
+
+    POST /catalog
+        create a catalog; provide JSON content
+
+    GET /catalog/:id
+    GET /catalog/:name
+        retrieve a catalog by numeric id or name
+
+    PUT /catalog/:id
+    PUT /catalog/:name
+        update a catalog's metadata; provide JSON content
+
+    DELETE /catalog/:id
+    DELETE /catalog/:name
+        disable catalog
+
 =head3 Users
 
     GET /users?query=<JSON>
diff --git a/lib/RT/Extension/REST2/Resource/Asset.pm b/lib/RT/Extension/REST2/Resource/Asset.pm
new file mode 100644
index 0000000..e75420a
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/Asset.pm
@@ -0,0 +1,97 @@
+package RT::Extension::REST2::Resource::Asset;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::Extension::REST2::Resource::Record';
+with (
+    'RT::Extension::REST2::Resource::Record::Readable',
+    'RT::Extension::REST2::Resource::Record::Hypermedia'
+        => { -alias => { hypermedia_links => '_default_hypermedia_links' } },
+    'RT::Extension::REST2::Resource::Record::Deletable',
+    'RT::Extension::REST2::Resource::Record::Writable'
+        => { -alias => { create_record => '_create_record' } },
+);
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/asset/?$},
+        block => sub { { record_class => 'RT::Asset' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/asset/(\d+)/?$},
+        block => sub { { record_class => 'RT::Asset', record_id => shift->pos(1) } },
+    )
+}
+
+sub create_record {
+    my $self = shift;
+    my $data = shift;
+
+    return (\400, "Invalid Catalog") if !$data->{Catalog};
+
+    my $catalog = RT::Catalog->new($self->record->CurrentUser);
+    $catalog->Load($data->{Catalog});
+
+    return (\400, "Invalid Catalog") if !$catalog->Id;
+
+    return (\403, $self->record->loc("Permission Denied", $catalog->Name))
+        unless $catalog->CurrentUserHasRight('CreateAsset');
+
+    return $self->_create_record($data);
+}
+
+sub forbidden {
+    my $self = shift;
+    return 0 unless $self->record->id;
+    return !$self->record->CurrentUserHasRight('ShowAsset');
+}
+
+sub lifecycle_hypermedia_links {
+    my $self = shift;
+    my $self_link = $self->_self_link;
+    my $asset = $self->record;
+    my @links;
+
+    # lifecycle actions
+    my $lifecycle = $asset->LifecycleObj;
+    my $current = $asset->Status;
+
+    for my $info ( $lifecycle->Actions($current) ) {
+        my $next = $info->{'to'};
+        next unless $lifecycle->IsTransition( $current => $next );
+
+        my $check = $lifecycle->CheckRight( $current => $next );
+        next unless $asset->CurrentUserHasRight($check);
+
+        my $url = $self_link->{_url};
+
+        push @links, {
+            %$info,
+            label => $self->current_user->loc($info->{'label'} || ucfirst($next)),
+            ref   => 'lifecycle',
+            _url  => $url,
+        };
+    }
+
+    return @links;
+}
+
+sub hypermedia_links {
+    my $self = shift;
+    my $self_link = $self->_self_link;
+    my $links = $self->_default_hypermedia_links(@_);
+    my $asset = $self->record;
+
+    push @$links, $self->_transaction_history_link;
+    push @$links, $self->lifecycle_hypermedia_links;
+
+    return $links;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/lib/RT/Extension/REST2/Resource/Catalogs.pm b/lib/RT/Extension/REST2/Resource/Assets.pm
similarity index 50%
copy from lib/RT/Extension/REST2/Resource/Catalogs.pm
copy to lib/RT/Extension/REST2/Resource/Assets.pm
index 52b29de..5649ddb 100644
--- a/lib/RT/Extension/REST2/Resource/Catalogs.pm
+++ b/lib/RT/Extension/REST2/Resource/Assets.pm
@@ -1,4 +1,4 @@
-package RT::Extension::REST2::Resource::Catalogs;
+package RT::Extension::REST2::Resource::Assets;
 use strict;
 use warnings;
 
@@ -8,6 +8,13 @@ use namespace::autoclean;
 extends 'RT::Extension::REST2::Resource::Collection';
 with 'RT::Extension::REST2::Resource::Collection::QueryByJSON';
 
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/assets/?$},
+        block => sub { { collection_class => 'RT::Assets' } },
+    )
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/Extension/REST2/Resource/Catalog.pm b/lib/RT/Extension/REST2/Resource/Catalog.pm
index 8eee13d..e1025b1 100644
--- a/lib/RT/Extension/REST2/Resource/Catalog.pm
+++ b/lib/RT/Extension/REST2/Resource/Catalog.pm
@@ -8,10 +8,46 @@ use namespace::autoclean;
 extends 'RT::Extension::REST2::Resource::Record';
 with (
     'RT::Extension::REST2::Resource::Record::Readable',
+    'RT::Extension::REST2::Resource::Record::Hypermedia'
+        => { -alias => { hypermedia_links => '_default_hypermedia_links' } },
     'RT::Extension::REST2::Resource::Record::DeletableByDisabling',
     'RT::Extension::REST2::Resource::Record::Writable',
 );
 
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/catalog/?$},
+        block => sub { { record_class => 'RT::Catalog' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/catalog/(\d+)/?$},
+        block => sub { { record_class => 'RT::Catalog', record_id => shift->pos(1) } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/catalog/([^/]+)/?$},
+        block => sub {
+            my ($match, $req) = @_;
+            my $catalog = RT::Catalog->new($req->env->{"rt.current_user"});
+            $catalog->Load($match->pos(1));
+            return { record => $catalog };
+        },
+    ),
+}
+
+sub hypermedia_links {
+    my $self = shift;
+    my $links = $self->_default_hypermedia_links(@_);
+    my $catalog = $self->record;
+
+    push @$links, {
+        ref  => 'create',
+        type => 'asset',
+        _url => RT::Extension::REST2->base_uri . '/asset?Catalog=' . $catalog->Id,
+    } if $catalog->CurrentUserHasRight('CreateAsset');
+
+    return $links;
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/Extension/REST2/Resource/Catalogs.pm b/lib/RT/Extension/REST2/Resource/Catalogs.pm
index 52b29de..8daad61 100644
--- a/lib/RT/Extension/REST2/Resource/Catalogs.pm
+++ b/lib/RT/Extension/REST2/Resource/Catalogs.pm
@@ -8,6 +8,22 @@ use namespace::autoclean;
 extends 'RT::Extension::REST2::Resource::Collection';
 with 'RT::Extension::REST2::Resource::Collection::QueryByJSON';
 
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/catalogs/?$},
+        block => sub { { collection_class => 'RT::Catalogs' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/catalogs/all/?$},
+        block => sub {
+            my ($match, $req) = @_;
+            my $catalogs = RT::Catalogs->new($req->env->{"rt.current_user"});
+            $catalogs->UnLimit;
+            return { collection => $catalogs };
+        },
+    ),
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/Extension/REST2/Resource/Transactions.pm b/lib/RT/Extension/REST2/Resource/Transactions.pm
index 1502ef9..14d3c72 100644
--- a/lib/RT/Extension/REST2/Resource/Transactions.pm
+++ b/lib/RT/Extension/REST2/Resource/Transactions.pm
@@ -14,7 +14,7 @@ sub dispatch_rules {
         block => sub { { collection_class => 'RT::Transactions' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue)/(\d+)/history/?$},
+        regex => qr{^/(ticket|queue|asset)/(\d+)/history/?$},
         block => sub {
             my ($match, $req) = @_;
             my ($class, $id) = ($match->pos(1), $match->pos(2));
@@ -26,6 +26,9 @@ sub dispatch_rules {
             elsif ($class eq 'queue') {
                 $record = RT::Queue->new($req->env->{"rt.current_user"});
             }
+            elsif ($class eq 'asset') {
+                $record = RT::Asset->new($req->env->{"rt.current_user"});
+            }
 
             $record->Load($id);
             return { collection => $record->Transactions };
diff --git a/t/asset-customfields.t b/t/asset-customfields.t
new file mode 100644
index 0000000..fe6bc8c
--- /dev/null
+++ b/t/asset-customfields.t
@@ -0,0 +1,383 @@
+use strict;
+use warnings;
+use lib 't/lib';
+use RT::Extension::REST2::Test tests => undef;
+use Test::Deep;
+
+BEGIN {
+    plan skip_all => 'RT 4.4 required'
+        unless RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
+}
+
+my $mech = RT::Extension::REST2::Test->mech;
+
+my $auth = RT::Extension::REST2::Test->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Extension::REST2::Test->user;
+
+my $catalog = RT::Catalog->new( RT->SystemUser );
+$catalog->Load('General assets');
+$catalog->Create(Name => 'General assets') if !$catalog->Id;
+ok($catalog->Id, "General assets catalog");
+
+my $single_cf = RT::CustomField->new( RT->SystemUser );
+my ($ok, $msg) = $single_cf->Create( Name => 'Single', Type => 'FreeformSingle', LookupType => RT::Asset->CustomFieldLookupType);
+ok($ok, $msg);
+my $single_cf_id = $single_cf->Id;
+
+($ok, $msg) = $single_cf->AddToObject($catalog);
+ok($ok, $msg);
+
+my $multi_cf = RT::CustomField->new( RT->SystemUser );
+($ok, $msg) = $multi_cf->Create( Name => 'Multi', Type => 'FreeformMultiple', LookupType => RT::Asset->CustomFieldLookupType);
+ok($ok, $msg);
+my $multi_cf_id = $multi_cf->Id;
+
+($ok, $msg) = $multi_cf->AddToObject($catalog);
+ok($ok, $msg);
+
+# Asset Creation with no ModifyCustomField
+my ($asset_url, $asset_id);
+{
+    my $payload = {
+        Name    => 'Asset creation using REST',
+        Catalog => 'General assets',
+        CustomFields => {
+            $single_cf_id => 'Hello world!',
+        },
+    };
+
+    # 4.2.3 introduced a bug (e092e23) in CFs fixed in 4.2.9 (ab7ea15)
+    delete $payload->{CustomFields}
+        if RT::Handle::cmp_version($RT::VERSION, '4.2.3') >= 0
+        && RT::Handle::cmp_version($RT::VERSION, '4.2.8') <= 0;
+
+    # Rights Test - No CreateAsset
+    my $res = $mech->post_json("$rest_base_path/asset",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 403);
+
+    # Rights Test - With CreateAsset
+    $user->PrincipalObj->GrantRight( Right => 'CreateAsset' );
+    $res = $mech->post_json("$rest_base_path/asset",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($asset_url = $res->header('location'));
+    ok(($asset_id) = $asset_url =~ qr[/asset/(\d+)]);
+}
+
+# Asset Display
+{
+    # Rights Test - No ShowAsset
+    my $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 403);
+}
+
+# Rights Test - With ShowAsset but no SeeCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'ShowAsset' );
+
+    my $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{id}, $asset_id);
+    is($content->{Status}, 'new');
+    is($content->{Name}, 'Asset creation using REST');
+    is_deeply($content->{'CustomFields'}, {}, 'Asset custom field not present');
+    is_deeply([grep { $_->{ref} eq 'customfield' } @{ $content->{'_hyperlinks'} }], [], 'No CF hypermedia');
+}
+
+# Rights Test - With ShowAsset and SeeCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'SeeCustomField');
+
+    my $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{id}, $asset_id);
+    is($content->{Status}, 'new');
+    is($content->{Name}, 'Asset creation using REST');
+    is_deeply($content->{CustomFields}, { $single_cf_id => [], $multi_cf_id => [] }, 'No asset custom field values');
+    cmp_deeply(
+        [grep { $_->{ref} eq 'customfield' } @{ $content->{'_hyperlinks'} }],
+        [{
+            ref => 'customfield',
+            id  => $single_cf_id,
+            type => 'customfield',
+            _url => re(qr[$rest_base_path/customfield/$single_cf_id$]),
+        }, {
+            ref => 'customfield',
+            id  => $multi_cf_id,
+            type => 'customfield',
+            _url => re(qr[$rest_base_path/customfield/$multi_cf_id$]),
+        }],
+        'Two CF hypermedia',
+    );
+
+    my ($single_url) = map { $_->{_url} } grep { $_->{ref} eq 'customfield' && $_->{id} == $single_cf_id } @{ $content->{'_hyperlinks'} };
+    my ($multi_url) = map { $_->{_url} } grep { $_->{ref} eq 'customfield' && $_->{id} == $multi_cf_id } @{ $content->{'_hyperlinks'} };
+
+    $res = $mech->get($single_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id         => $single_cf_id,
+        Disabled   => 0,
+        LookupType => RT::Asset->CustomFieldLookupType,
+        MaxValues  => 1,
+	Name       => 'Single',
+	Type       => 'Freeform',
+    }), 'single cf');
+
+    $res = $mech->get($multi_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id         => $multi_cf_id,
+        Disabled   => 0,
+        LookupType => RT::Asset->CustomFieldLookupType,
+        MaxValues  => 0,
+	Name       => 'Multi',
+	Type       => 'Freeform',
+    }), 'multi cf');
+}
+
+# Asset Update without ModifyCustomField
+{
+    my $payload = {
+        Name     => 'Asset update using REST',
+        Status   => 'allocated',
+        CustomFields => {
+            $single_cf_id => 'Modified CF',
+        },
+    };
+
+    # Rights Test - No ModifyAsset
+    my $res = $mech->put_json($asset_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    TODO: {
+        local $TODO = "RT ->Update isn't introspectable";
+        is($res->code, 403);
+    };
+    is_deeply($mech->json_response, ['Asset Asset creation using REST: Permission Denied', 'Asset Asset creation using REST: Permission Denied', 'Could not add new custom field value: Permission Denied']);
+
+    $user->PrincipalObj->GrantRight( Right => 'ModifyAsset' );
+
+    $res = $mech->put_json($asset_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Asset Asset update using REST: Name changed from 'Asset creation using REST' to 'Asset update using REST'", "Asset Asset update using REST: Status changed from 'new' to 'allocated'", 'Could not add new custom field value: Permission Denied']);
+
+    $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{Name}, 'Asset update using REST');
+    is($content->{Status}, 'allocated');
+    is_deeply($content->{CustomFields}, { $single_cf_id => [], $multi_cf_id => [] }, 'No update to CF');
+}
+
+# Asset Update with ModifyCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'ModifyCustomField' );
+    my $payload = {
+        Name  => 'More updates using REST',
+        Status => 'in-use',
+        CustomFields => {
+            $single_cf_id => 'Modified CF',
+        },
+    };
+    my $res = $mech->put_json($asset_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Asset More updates using REST: Name changed from 'Asset update using REST' to 'More updates using REST'", "Asset More updates using REST: Status changed from 'allocated' to 'in-use'", 'Single Modified CF added']);
+
+    $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{Name}, 'More updates using REST');
+    is($content->{Status}, 'in-use');
+    is_deeply($content->{CustomFields}, { $single_cf_id => ['Modified CF'], $multi_cf_id => [] }, 'New CF value');
+
+    # make sure changing the CF doesn't add a second OCFV
+    $payload->{CustomFields}{$single_cf_id} = 'Modified Again';
+    $res = $mech->put_json($asset_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ['Single Modified CF changed to Modified Again']);
+
+    $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    $content = $mech->json_response;
+    is_deeply($content->{CustomFields}, { $single_cf_id => ['Modified Again'], $multi_cf_id => [] }, 'New CF value');
+
+    # stop changing the CF, change something else, make sure CF sticks around
+    delete $payload->{CustomFields}{$single_cf_id};
+    $payload->{Name} = 'No CF change';
+    $res = $mech->put_json($asset_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Asset No CF change: Name changed from 'More updates using REST' to 'No CF change'"]);
+
+    $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    $content = $mech->json_response;
+    is_deeply($content->{CustomFields}, { $single_cf_id => ['Modified Again'], $multi_cf_id => [] }, 'Same CF value');
+}
+
+# Asset Creation with ModifyCustomField
+{
+    my $payload = {
+        Name    => 'Asset creation using REST',
+        Catalog => 'General assets',
+        CustomFields => {
+            $single_cf_id => 'Hello world!',
+        },
+    };
+
+    my $res = $mech->post_json("$rest_base_path/asset",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($asset_url = $res->header('location'));
+    ok(($asset_id) = $asset_url =~ qr[/asset/(\d+)]);
+}
+
+# Rights Test - With ShowAsset and SeeCustomField
+{
+    my $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{id}, $asset_id);
+    is($content->{Status}, 'new');
+    is($content->{Name}, 'Asset creation using REST');
+    is_deeply($content->{'CustomFields'}{$single_cf_id}, ['Hello world!'], 'Asset custom field');
+}
+
+# Asset Creation for multi-value CF
+for my $value (
+    'scalar',
+    ['array reference'],
+    ['multiple', 'values'],
+) {
+    my $payload = {
+        Name => 'Multi-value CF',
+        Catalog => 'General assets',
+        CustomFields => {
+            $multi_cf_id => $value,
+        },
+    };
+
+    my $res = $mech->post_json("$rest_base_path/asset",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($asset_url = $res->header('location'));
+    ok(($asset_id) = $asset_url =~ qr[/asset/(\d+)]);
+
+    $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{id}, $asset_id);
+    is($content->{Status}, 'new');
+    is($content->{Name}, 'Multi-value CF');
+
+    my $output = ref($value) ? $value : [$value]; # scalar input comes out as array reference
+    is_deeply($content->{'CustomFields'}, { $multi_cf_id => $output, $single_cf_id => [] }, 'Asset custom field');
+}
+
+{
+    sub modify_multi_ok {
+        local $Test::Builder::Level = $Test::Builder::Level + 1;
+        my $input = shift;
+        my $messages = shift;
+        my $output = shift;
+        my $name = shift;
+
+        my $payload = {
+            CustomFields => {
+                $multi_cf_id => $input,
+            },
+        };
+        my $res = $mech->put_json($asset_url,
+            $payload,
+            'Authorization' => $auth,
+        );
+        is($res->code, 200);
+        is_deeply($mech->json_response, $messages);
+
+        $res = $mech->get($asset_url,
+            'Authorization' => $auth,
+        );
+        is($res->code, 200);
+
+        my $content = $mech->json_response;
+        my @values = sort @{ $content->{CustomFields}{$multi_cf_id} };
+        is_deeply(\@values, $output, $name || 'New CF value');
+    }
+
+    # starting point: ['multiple', 'values'],
+    modify_multi_ok(['multiple', 'values'], [], ['multiple', 'values'], 'no change');
+    modify_multi_ok(['multiple', 'values', 'new'], ['new added as a value for Multi'], ['multiple', 'new', 'values'], 'added "new"');
+    modify_multi_ok(['multiple', 'new'], ['values is no longer a value for custom field Multi'], ['multiple', 'new'], 'removed "values"');
+    modify_multi_ok('replace all', ['replace all added as a value for Multi', 'multiple is no longer a value for custom field Multi', 'new is no longer a value for custom field Multi'], ['replace all'], 'replaced all values');
+    modify_multi_ok([], ['replace all is no longer a value for custom field Multi'], [], 'removed all values');
+
+    if (RT::Handle::cmp_version($RT::VERSION, '4.2.5') >= 0) {
+        modify_multi_ok(['foo', 'foo', 'bar'], ['foo added as a value for Multi', undef, 'bar added as a value for Multi'], ['bar', 'foo'], 'multiple values with the same name');
+        modify_multi_ok(['foo', 'bar'], [], ['bar', 'foo'], 'multiple values with the same name');
+        modify_multi_ok(['bar'], ['foo is no longer a value for custom field Multi'], ['bar'], 'multiple values with the same name');
+        modify_multi_ok(['bar', 'bar', 'bar'], [undef, undef], ['bar'], 'multiple values with the same name');
+    } else {
+        modify_multi_ok(['foo', 'foo', 'bar'], ['foo added as a value for Multi', 'foo added as a value for Multi', 'bar added as a value for Multi'], ['bar', 'foo', 'foo'], 'multiple values with the same name');
+        modify_multi_ok(['foo', 'bar'], ['foo is no longer a value for custom field Multi'], ['bar', 'foo'], 'multiple values with the same name');
+        modify_multi_ok(['bar'], ['foo is no longer a value for custom field Multi'], ['bar'], 'multiple values with the same name');
+        modify_multi_ok(['bar', 'bar', 'bar'], ['bar added as a value for Multi', 'bar added as a value for Multi'], ['bar', 'bar', 'bar'], 'multiple values with the same name');
+    }
+}
+
+done_testing;
+
diff --git a/t/assets.t b/t/assets.t
new file mode 100644
index 0000000..d3705c6
--- /dev/null
+++ b/t/assets.t
@@ -0,0 +1,256 @@
+use strict;
+use warnings;
+use lib 't/lib';
+use RT::Extension::REST2::Test tests => undef;
+use Test::Deep;
+
+BEGIN {
+    plan skip_all => 'RT 4.4 required'
+        unless RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
+}
+
+my $mech = RT::Extension::REST2::Test->mech;
+
+my $auth = RT::Extension::REST2::Test->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Extension::REST2::Test->user;
+
+# Empty DB
+{
+    my $res = $mech->post_json("$rest_base_path/assets",
+        [{ field => 'id', operator => '>', value => 0 }],
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is($mech->json_response->{count}, 0);
+}
+
+# Missing Catalog
+{
+    my $res = $mech->post_json("$rest_base_path/asset",
+        {
+            Name => 'Asset creation using REST',
+        },
+        'Authorization' => $auth,
+    );
+    is($res->code, 400);
+    is($mech->json_response->{message}, 'Invalid Catalog');
+}
+
+# Asset Creation
+my ($asset_url, $asset_id);
+{
+    my $payload = {
+        Name    => 'Asset creation using REST',
+        Catalog => 'General assets',
+        Content => 'Testing asset creation using REST API.',
+    };
+
+    # Rights Test - No CreateAsset
+    my $res = $mech->post_json("$rest_base_path/asset",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 403);
+
+    # Rights Test - With CreateAsset
+    $user->PrincipalObj->GrantRight( Right => 'CreateAsset' );
+    $res = $mech->post_json("$rest_base_path/asset",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($asset_url = $res->header('location'));
+    ok(($asset_id) = $asset_url =~ qr[/asset/(\d+)]);
+}
+
+# Asset Display
+{
+    # Rights Test - No ShowAsset
+    my $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 403);
+}
+
+# Rights Test - With ShowAsset
+{
+    $user->PrincipalObj->GrantRight( Right => 'ShowAsset' );
+
+    my $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{id}, $asset_id);
+    is($content->{Name}, 'Asset creation using REST');
+    is($content->{Status}, 'new');
+    is($content->{Name}, 'Asset creation using REST');
+
+    ok(exists $content->{$_}) for qw(Creator Created LastUpdated LastUpdatedBy
+                                     HeldBy Contact
+                                     Description);
+
+    my $links = $content->{_hyperlinks};
+    is(scalar @$links, 2);
+
+    is($links->[0]{ref}, 'self');
+    is($links->[0]{id}, 1);
+    is($links->[0]{type}, 'asset');
+    like($links->[0]{_url}, qr[$rest_base_path/asset/$asset_id$]);
+
+    is($links->[1]{ref}, 'history');
+    like($links->[1]{_url}, qr[$rest_base_path/asset/$asset_id/history$]);
+
+    my $catalog = $content->{Catalog};
+    is($catalog->{id}, 1);
+    is($catalog->{type}, 'catalog');
+    like($catalog->{_url}, qr{$rest_base_path/catalog/1$});
+
+    my $owner = $content->{Owner};
+    is($owner->{id}, 'Nobody');
+    is($owner->{type}, 'user');
+    like($owner->{_url}, qr{$rest_base_path/user/Nobody$});
+
+    my $creator = $content->{Creator};
+    is($creator->{id}, 'test');
+    is($creator->{type}, 'user');
+    like($creator->{_url}, qr{$rest_base_path/user/test$});
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is($updated_by->{id}, 'test');
+    is($updated_by->{type}, 'user');
+    like($updated_by->{_url}, qr{$rest_base_path/user/test$});
+}
+
+# Asset Search
+{
+    my $res = $mech->post_json("$rest_base_path/assets",
+        [{ field => 'id', operator => '>', value => 0 }],
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    my $content = $mech->json_response;
+    is($content->{count}, 1);
+    is($content->{page}, 1);
+    is($content->{per_page}, 20);
+    is($content->{total}, 1);
+    is(scalar @{$content->{items}}, 1);
+
+    my $asset = $content->{items}->[0];
+    is($asset->{type}, 'asset');
+    is($asset->{id}, 1);
+    like($asset->{_url}, qr{$rest_base_path/asset/1$});
+}
+
+# Asset Update
+{
+    my $payload = {
+        Name   => 'Asset update using REST',
+        Status => 'allocated',
+    };
+
+    # Rights Test - No ModifyAsset
+    my $res = $mech->put_json($asset_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    TODO: {
+        local $TODO = "RT ->Update isn't introspectable";
+        is($res->code, 403);
+    };
+    is_deeply($mech->json_response, ['Asset Asset creation using REST: Permission Denied', 'Asset Asset creation using REST: Permission Denied']);
+
+    $user->PrincipalObj->GrantRight( Right => 'ModifyAsset' );
+
+    $res = $mech->put_json($asset_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Asset Asset update using REST: Name changed from 'Asset creation using REST' to 'Asset update using REST'", "Asset Asset update using REST: Status changed from 'new' to 'allocated'"]);
+
+    $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{Name}, 'Asset update using REST');
+    is($content->{Status}, 'allocated');
+
+    # now that we have ModifyAsset, we should have additional hypermedia
+    my $links = $content->{_hyperlinks};
+    is(scalar @$links, 5);
+
+    is($links->[0]{ref}, 'self');
+    is($links->[0]{id}, 1);
+    is($links->[0]{type}, 'asset');
+    like($links->[0]{_url}, qr[$rest_base_path/asset/$asset_id$]);
+
+    is($links->[1]{ref}, 'history');
+    like($links->[1]{_url}, qr[$rest_base_path/asset/$asset_id/history$]);
+
+    is($links->[2]{ref}, 'lifecycle');
+    like($links->[2]{_url}, qr[$rest_base_path/asset/$asset_id$]);
+    is($links->[2]{label}, 'Now in-use');
+    is($links->[2]{from}, '*');
+    is($links->[2]{to}, 'in-use');
+
+    is($links->[3]{ref}, 'lifecycle');
+    like($links->[3]{_url}, qr[$rest_base_path/asset/$asset_id$]);
+    is($links->[3]{label}, 'Recycle');
+    is($links->[3]{from}, '*');
+    is($links->[3]{to}, 'recycled');
+
+    is($links->[4]{ref}, 'lifecycle');
+    like($links->[4]{_url}, qr[$rest_base_path/asset/$asset_id$]);
+    is($links->[4]{label}, 'Report stolen');
+    is($links->[4]{from}, '*');
+    is($links->[4]{to}, 'stolen');
+
+    # update again with no changes
+    $res = $mech->put_json($asset_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, []);
+
+    $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    $content = $mech->json_response;
+    is($content->{Name}, 'Asset update using REST');
+    is($content->{Status}, 'allocated');
+}
+
+# Transactions
+{
+    my $res = $mech->get($asset_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    $res = $mech->get($mech->url_for_hypermedia('history'),
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{count}, 3);
+    is($content->{page}, 1);
+    is($content->{per_page}, 20);
+    is($content->{total}, 3);
+    is(scalar @{$content->{items}}, 3);
+
+    for my $txn (@{ $content->{items} }) {
+        is($txn->{type}, 'transaction');
+        like($txn->{_url}, qr{$rest_base_path/transaction/\d+$});
+    }
+}
+
+done_testing;
diff --git a/t/catalogs.t b/t/catalogs.t
new file mode 100644
index 0000000..2e2f404
--- /dev/null
+++ b/t/catalogs.t
@@ -0,0 +1,238 @@
+use strict;
+use warnings;
+use lib 't/lib';
+use RT::Extension::REST2::Test tests => undef;
+
+BEGIN {
+    plan skip_all => 'RT 4.4 required'
+        unless RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
+}
+
+my $mech = RT::Extension::REST2::Test->mech;
+my $auth = RT::Extension::REST2::Test->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Extension::REST2::Test->user;
+
+$user->PrincipalObj->GrantRight( Right => 'SuperUser' );
+
+my $catalog_url;
+# search Name = General assets
+{
+    my $res = $mech->post_json("$rest_base_path/catalogs",
+        [{ field => 'Name', value => 'General assets' }],
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{count}, 1);
+    is($content->{page}, 1);
+    is($content->{per_page}, 20);
+    is($content->{total}, 1);
+    is(scalar @{$content->{items}}, 1);
+
+    my $catalog = $content->{items}->[0];
+    is($catalog->{type}, 'catalog');
+    is($catalog->{id}, 1);
+    like($catalog->{_url}, qr{$rest_base_path/catalog/1$});
+    $catalog_url = $catalog->{_url};
+}
+
+# Catalog display
+{
+    my $res = $mech->get($catalog_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{id}, 1);
+    is($content->{Name}, 'General assets');
+    is($content->{Description}, 'The default catalog');
+    is($content->{Lifecycle}, 'assets');
+    is($content->{Disabled}, 0);
+
+    ok(exists $content->{$_}, "got $_") for qw(LastUpdated Created);
+
+    my $links = $content->{_hyperlinks};
+    is(scalar @$links, 2);
+
+    is($links->[0]{ref}, 'self');
+    is($links->[0]{id}, 1);
+    is($links->[0]{type}, 'catalog');
+    like($links->[0]{_url}, qr[$rest_base_path/catalog/1$]);
+
+    is($links->[1]{ref}, 'create');
+    is($links->[1]{type}, 'asset');
+    like($links->[1]{_url}, qr[$rest_base_path/asset\?Catalog=1$]);
+
+    my $creator = $content->{Creator};
+    is($creator->{id}, 'RT_System');
+    is($creator->{type}, 'user');
+    like($creator->{_url}, qr{$rest_base_path/user/RT_System$});
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is($updated_by->{id}, 'RT_System');
+    is($updated_by->{type}, 'user');
+    like($updated_by->{_url}, qr{$rest_base_path/user/RT_System$});
+
+    is_deeply($content->{Contact}, [], 'no Contact set');
+    is_deeply($content->{HeldBy}, [], 'no HeldBy set');
+
+    ok(!exists($content->{Owner}), 'no Owner at the catalog level');
+}
+
+# Catalog update
+{
+    my $payload = {
+        Name => 'Servers',
+        Description => 'gotta serve em all',
+    };
+
+    my $res = $mech->put_json($catalog_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Catalog General assets: Description changed from 'The default catalog' to 'gotta serve em all'", "Catalog Servers: Name changed from 'General assets' to 'Servers'"]);
+
+    $res = $mech->get($catalog_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{Name}, 'Servers');
+    is($content->{Description}, 'gotta serve em all');
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is($updated_by->{id}, 'test');
+    is($updated_by->{type}, 'user');
+    like($updated_by->{_url}, qr{$rest_base_path/user/test$});
+}
+
+# search Name = Servers
+{
+    my $res = $mech->post_json("$rest_base_path/catalogs",
+        [{ field => 'Name', value => 'Servers' }],
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{count}, 1);
+    is($content->{page}, 1);
+    is($content->{per_page}, 20);
+    is($content->{total}, 1);
+    is(scalar @{$content->{items}}, 1);
+
+    my $catalog = $content->{items}->[0];
+    is($catalog->{type}, 'catalog');
+    is($catalog->{id}, 1);
+    like($catalog->{_url}, qr{$rest_base_path/catalog/1$});
+}
+
+# Catalog delete
+{
+    my $res = $mech->delete($catalog_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 204);
+
+    my $catalog = RT::Catalog->new(RT->SystemUser);
+    $catalog->Load(1);
+    is($catalog->Id, 1, '"deleted" catalog still in the database');
+    ok($catalog->Disabled, '"deleted" catalog disabled');
+
+    $res = $mech->get($catalog_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{Name}, 'Servers');
+    is($content->{Disabled}, 1);
+}
+
+# Catalog create
+my ($laptops_url, $laptops_id);
+{
+    my $payload = {
+        Name => 'Laptops',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/catalog",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($laptops_url = $res->header('location'));
+    ok(($laptops_id) = $laptops_url =~ qr[/catalog/(\d+)]);
+}
+
+# Catalog display
+{
+    my $res = $mech->get($laptops_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{id}, $laptops_id);
+    is($content->{Name}, 'Laptops');
+    is($content->{Lifecycle}, 'assets');
+    is($content->{Disabled}, 0);
+
+    ok(exists $content->{$_}, "got $_") for qw(LastUpdated Created);
+
+    my $links = $content->{_hyperlinks};
+    is(scalar @$links, 2);
+
+    is($links->[0]{ref}, 'self');
+    is($links->[0]{id}, $laptops_id);
+    is($links->[0]{type}, 'catalog');
+    like($links->[0]{_url}, qr[$rest_base_path/catalog/$laptops_id$]);
+
+    is($links->[1]{ref}, 'create');
+    is($links->[1]{type}, 'asset');
+    like($links->[1]{_url}, qr[$rest_base_path/asset\?Catalog=$laptops_id$]);
+
+    my $creator = $content->{Creator};
+    is($creator->{id}, 'test');
+    is($creator->{type}, 'user');
+    like($creator->{_url}, qr{$rest_base_path/user/test$});
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is($updated_by->{id}, 'test');
+    is($updated_by->{type}, 'user');
+    like($updated_by->{_url}, qr{$rest_base_path/user/test$});
+
+    is_deeply($content->{Contact}, [], 'no Contact set');
+    is_deeply($content->{HeldBy}, [], 'no HeldBy set');
+
+    ok(!exists($content->{Owner}), 'no Owner at the catalog level');
+}
+
+# id > 0 (finds new Laptops catalog but not disabled Servers catalog)
+{
+    my $res = $mech->post_json("$rest_base_path/catalogs",
+        [{ field => 'id', operator => '>', value => 0 }],
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    is($content->{count}, 1);
+    is($content->{page}, 1);
+    is($content->{per_page}, 20);
+    is($content->{total}, 1);
+    is(scalar @{$content->{items}}, 1);
+
+    my $catalog = $content->{items}->[0];
+    is($catalog->{type}, 'catalog');
+    is($catalog->{id}, $laptops_id);
+    like($catalog->{_url}, qr{$rest_base_path/catalog/$laptops_id$});
+}
+
+done_testing;
+

commit fce0cb17639663e2fa7e4b806b8abaf6b8ec9fa1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jul 14 21:35:36 2017 +0000

    Add .../:id/history endpoints for users and groups

diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index 7e02f5d..48e5b91 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -282,6 +282,9 @@ controls available in response bodies rather than hardcoding URLs.
     GET /queue/:id/history
     GET /queue/:name/history
     GET /asset/:id/history
+    GET /user/:id/history
+    GET /user/:name/history
+    GET /group/:id/history
         get transactions for record
 
     GET /transaction/:id
@@ -393,6 +396,10 @@ controls available in response bodies rather than hardcoding URLs.
     DELETE /user/:name
         disable user
 
+    GET /user/:id/history
+    GET /user/:name/history
+        retrieve list of transactions for user
+
 =head3 Groups
 
     GET /groups?query=<JSON>
@@ -402,6 +409,9 @@ controls available in response bodies rather than hardcoding URLs.
     GET /group/:id
         retrieve a group (including its members)
 
+    GET /group/:id/history
+        retrieve list of transactions for group
+
 =head3 Custom Fields
 
     GET /customfields?query=<JSON>
diff --git a/lib/RT/Extension/REST2/Resource/Group.pm b/lib/RT/Extension/REST2/Resource/Group.pm
index fa423cf..14910cf 100644
--- a/lib/RT/Extension/REST2/Resource/Group.pm
+++ b/lib/RT/Extension/REST2/Resource/Group.pm
@@ -9,7 +9,8 @@ use RT::Extension::REST2::Util qw(expand_uid);
 extends 'RT::Extension::REST2::Resource::Record';
 with 'RT::Extension::REST2::Resource::Record::Readable'
         => { -alias => { serialize => '_default_serialize' } },
-     'RT::Extension::REST2::Resource::Record::Hypermedia';
+    'RT::Extension::REST2::Resource::Record::Hypermedia'
+        => { -alias => { hypermedia_links => '_default_hypermedia_links' } };
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
@@ -34,6 +35,13 @@ sub serialize {
     return $data;
 }
 
+sub hypermedia_links {
+    my $self = shift;
+    my $links = $self->_default_hypermedia_links(@_);
+    push @$links, $self->_transaction_history_link;
+    return $links;
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/Extension/REST2/Resource/Transactions.pm b/lib/RT/Extension/REST2/Resource/Transactions.pm
index 14d3c72..3c3bfd4 100644
--- a/lib/RT/Extension/REST2/Resource/Transactions.pm
+++ b/lib/RT/Extension/REST2/Resource/Transactions.pm
@@ -14,7 +14,7 @@ sub dispatch_rules {
         block => sub { { collection_class => 'RT::Transactions' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset)/(\d+)/history/?$},
+        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
         block => sub {
             my ($match, $req) = @_;
             my ($class, $id) = ($match->pos(1), $match->pos(2));
@@ -29,13 +29,19 @@ sub dispatch_rules {
             elsif ($class eq 'asset') {
                 $record = RT::Asset->new($req->env->{"rt.current_user"});
             }
+            elsif ($class eq 'user') {
+                $record = RT::User->new($req->env->{"rt.current_user"});
+            }
+            elsif ($class eq 'group') {
+                $record = RT::Group->new($req->env->{"rt.current_user"});
+            }
 
             $record->Load($id);
             return { collection => $record->Transactions };
         },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue)/([^/]+)/history/?$},
+        regex => qr{^/(queue|user)/([^/]+)/history/?$},
         block => sub {
             my ($match, $req) = @_;
             my ($class, $id) = ($match->pos(1), $match->pos(2));
@@ -44,6 +50,9 @@ sub dispatch_rules {
             if ($class eq 'queue') {
                 $record = RT::Queue->new($req->env->{"rt.current_user"});
             }
+            elsif ($class eq 'user') {
+                $record = RT::User->new($req->env->{"rt.current_user"});
+            }
 
             $record->Load($id);
             return { collection => $record->Transactions };
diff --git a/lib/RT/Extension/REST2/Resource/User.pm b/lib/RT/Extension/REST2/Resource/User.pm
index c444b55..c39b7f1 100644
--- a/lib/RT/Extension/REST2/Resource/User.pm
+++ b/lib/RT/Extension/REST2/Resource/User.pm
@@ -10,6 +10,8 @@ with (
     'RT::Extension::REST2::Resource::Record::Readable',
     'RT::Extension::REST2::Resource::Record::DeletableByDisabling',
     'RT::Extension::REST2::Resource::Record::Writable',
+    'RT::Extension::REST2::Resource::Record::Hypermedia'
+        => { -alias => { hypermedia_links => '_default_hypermedia_links' } },
 );
 
 sub dispatch_rules {
@@ -49,6 +51,13 @@ sub forbidden {
     return 1;
 }
 
+sub hypermedia_links {
+    my $self = shift;
+    my $links = $self->_default_hypermedia_links(@_);
+    push @$links, $self->_transaction_history_link;
+    return $links;
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;

-----------------------------------------------------------------------


More information about the Bps-public-commit mailing list