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

Shawn Moore shawn at bestpractical.com
Fri Jun 9 17:47:02 EDT 2017


The branch, master has been updated
       via  f840702d2d80e4e0c8557968fc6ba2a1986e0a5d (commit)
       via  08c849ef84d7feb057e73f67e349c2f072d69138 (commit)
       via  ea7c9eb1994c6dc3d43bde8859664541a6797967 (commit)
       via  b4e8671df1c7938cf4d547cdac15ac891d1d0745 (commit)
       via  3b3a33ce56987ed535a97f75fc5d66c61898443b (commit)
       via  c1a82a36526adc62800584bb661c0f6799e51945 (commit)
       via  68b5828e78af0dfdd024a632d27c071c4c8cf3aa (commit)
       via  da0879486119c396c0c33d7d257d53c151297ee9 (commit)
       via  cdb53fdb5ea95bb59ac55b2c6ca58164c23c4ea3 (commit)
      from  0599547f03fb61ab020429b254235ee1d7cb0a89 (commit)

Summary of changes:
 Makefile.PL                                        |   2 +-
 lib/RT/Extension/REST2.pm                          |   1 -
 lib/RT/Extension/REST2/Dispatcher.pm               |  86 +++++++++----
 lib/RT/Extension/REST2/Resource/Attachment.pm      |  26 ++++
 lib/RT/Extension/REST2/Resource/Attachments.pm     |  30 +++++
 lib/RT/Extension/REST2/Resource/Collection.pm      |  14 +--
 .../REST2/Resource/Collection/QueryByJSON.pm       |   2 +-
 lib/RT/Extension/REST2/Resource/Message.pm         | 110 ++++++++++++++++
 lib/RT/Extension/REST2/Resource/Queue.pm           |  11 ++
 lib/RT/Extension/REST2/Resource/Queues.pm          |   7 ++
 lib/RT/Extension/REST2/Resource/Record.pm          |  31 +++--
 .../Extension/REST2/Resource/Record/Hypermedia.pm  |  10 +-
 lib/RT/Extension/REST2/Resource/Record/Readable.pm |   2 +-
 lib/RT/Extension/REST2/Resource/Root.pm            |   6 +
 lib/RT/Extension/REST2/Resource/Ticket.pm          |  65 ++++++++++
 lib/RT/Extension/REST2/Resource/Tickets.pm         |   7 ++
 lib/RT/Extension/REST2/Resource/Transaction.pm     |  21 +++-
 lib/RT/Extension/REST2/Resource/Transactions.pm    |  16 +++
 t/not_found.t                                      |  11 +-
 t/root.t                                           |   2 +-
 t/tickets.t                                        | 140 ++++++++++++++++++++-
 t/transactions.t                                   |   4 +-
 22 files changed, 527 insertions(+), 77 deletions(-)
 create mode 100644 lib/RT/Extension/REST2/Resource/Attachment.pm
 create mode 100644 lib/RT/Extension/REST2/Resource/Attachments.pm
 create mode 100644 lib/RT/Extension/REST2/Resource/Message.pm

- Log -----------------------------------------------------------------
commit cdb53fdb5ea95bb59ac55b2c6ca58164c23c4ea3
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 16:32:04 2017 +0000

    Switch from Web::Simple to Path::Dispatcher
    
    This enables resources to publish which routes they handle. Among other
    things, this makes it possible, say, for the Transactions collection
    resource to handle /ticket/123/history, impossible with the previous
    simplistic dispatcher.
    
    It also adds a layer of indirection between routes and resources, enabling
    (say) TicketMessage as a resource, but serving it at /ticket/1/reply rather
    than /ticketmessage/1?type=reply or some such
    
    It also opens up more avenues for extensions, since the dispatcher is
    now very flexible.

diff --git a/Makefile.PL b/Makefile.PL
index 9076a71..220849d 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -18,12 +18,12 @@ requires 'Plack::Builder';
 requires 'Scalar::Util';
 requires 'Sub::Exporter';
 requires 'Web::Machine' => '0.12';
-requires 'Web::Simple';
 requires 'Class::Method::Modifiers';
 requires 'Plack::Middleware::RequestHeaders';
 requires 'Plack::Middleware::ReverseProxyPath';
 requires 'Module::Path';
 requires 'Pod::POM';
+requires 'Path::Dispatcher';
 
 recommends 'JSON::XS';
 
diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index a3d6591..c13f5f2 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -7,7 +7,6 @@ package RT::Extension::REST2;
 our $VERSION = '0.10';
 our $REST_PATH = '/REST/2.0';
 
-use UNIVERSAL::require;
 use Plack::Builder;
 use RT::Extension::REST2::Dispatcher;
 
diff --git a/lib/RT/Extension/REST2/Dispatcher.pm b/lib/RT/Extension/REST2/Dispatcher.pm
index c51ace4..449771f 100644
--- a/lib/RT/Extension/REST2/Dispatcher.pm
+++ b/lib/RT/Extension/REST2/Dispatcher.pm
@@ -1,35 +1,69 @@
 package RT::Extension::REST2::Dispatcher;
-
 use strict;
 use warnings;
-use Web::Simple;
+use Moose;
 use Web::Machine;
-use Web::Dispatch::HTTPMethods;
-
-sub dispatch_request {
-    my ($self, $env) = @_;
-    sub (/**) {
-        my ($resource_name) = ucfirst(lc $_[1]) =~ /([^\/]+)\/?/;
-        my $resource = "RT::Extension::REST2::Resource::${resource_name}";
-        if ( $resource->require ) {
-            return Web::Machine->new(
-                resource => $resource,
-            )->to_app;
-        }
-        else {
-            return undef;
+use Path::Dispatcher;
+use Plack::Request;
+
+use Module::Pluggable (
+    search_path => ['RT::Extension::REST2::Resource'],
+    sub_name    => '_resource_classes',
+    require     => 1,
+    max_depth   => 5,
+);
+
+has _dispatcher => (
+    is         => 'ro',
+    isa        => 'Path::Dispatcher',
+    builder    => '_build_dispatcher',
+);
+
+sub _build_dispatcher {
+    my $self = shift;
+    my $dispatcher = Path::Dispatcher->new;
+
+    for my $resource_class ($self->_resource_classes) {
+        if ($resource_class->can('dispatch_rules')) {
+            my @rules = $resource_class->dispatch_rules;
+            for my $rule (@rules) {
+                $rule->{_rest2_resource} = $resource_class;
+                $dispatcher->add_rule($rule);
+            }
         }
-    },
-    sub () {
-        my $resource = "RT::Extension::REST2::Resource::Root";
-        $resource->require;
-        my $root = Web::Machine->new(
-            resource => $resource,
-        )->to_app;
-
-        sub (~) { GET { $root->($env) } },
-        sub (/) { GET { $root->($env) } },
     }
+
+    return $dispatcher;
+}
+
+sub to_psgi_app {
+    my $class = shift;
+    my $self = $class->new;
+
+    return sub {
+        my $env = shift;
+        my $dispatch = $self->_dispatcher->dispatch($env->{PATH_INFO});
+
+        return [404, ['Content-Type' => 'text/plain'], 'Not Found']
+            if !$dispatch->has_matches;
+
+        my @matches = $dispatch->matches;
+        if (@matches > 1) {
+            RT->Logger->error("Path $env->{PATH_INFO} erroneously matched " . scalar(@matches) . " resources: " . (join ', ', map { $_->rule->{_rest2_resource} } @matches) . ". Refusing to dispatch.");
+            return [500, ['Content-Type' => 'text/plain'], 'Internal Server Error']
+        }
+
+        my $match = shift @matches;
+
+        my $rule = $match->rule;
+        my $resource = $rule->{_rest2_resource};
+        my $args = $rule->block ? $match->run(Plack::Request->new($env)) : {};
+        my $machine = Web::Machine->new(
+            resource      => $resource,
+            resource_args => [%$args],
+        );
+        return $machine->call($env);
+    };
 }
 
 1;
diff --git a/lib/RT/Extension/REST2/Resource/Collection.pm b/lib/RT/Extension/REST2/Resource/Collection.pm
index 75ba54f..e29ab9b 100644
--- a/lib/RT/Extension/REST2/Resource/Collection.pm
+++ b/lib/RT/Extension/REST2/Resource/Collection.pm
@@ -13,10 +13,8 @@ use Module::Runtime qw( require_module );
 use RT::Extension::REST2::Util qw( serialize_record expand_uid );
 
 has 'collection_class' => (
-    is          => 'ro',
-    isa         => 'ClassName',
-    required    => 1,
-    lazy_build  => 1,
+    is  => 'ro',
+    isa => 'ClassName',
 );
 
 has 'collection' => (
@@ -26,14 +24,6 @@ has 'collection' => (
     lazy_build  => 1,
 );
 
-sub _build_collection_class {
-    my $self   = shift;
-    my ($type) = blessed($self) =~ /::(\w+)$/;
-    my $class  = "RT::$type";
-    require_module($class);
-    return $class;
-}
-
 sub _build_collection {
     my $self = shift;
     my $collection = $self->collection_class->new( $self->current_user );
diff --git a/lib/RT/Extension/REST2/Resource/Queue.pm b/lib/RT/Extension/REST2/Resource/Queue.pm
index ba1a138..0019277 100644
--- a/lib/RT/Extension/REST2/Resource/Queue.pm
+++ b/lib/RT/Extension/REST2/Resource/Queue.pm
@@ -13,6 +13,17 @@ with (
     'RT::Extension::REST2::Resource::Record::Writable',
 );
 
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/queue/?$},
+        block => sub { { record_class => 'RT::Queue' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/queue/(\d+)$},
+        block => sub { { record_class => 'RT::Queue', record_id => shift->pos(1) } },
+    )
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/Extension/REST2/Resource/Queues.pm b/lib/RT/Extension/REST2/Resource/Queues.pm
index 82d9431..69ca02d 100644
--- a/lib/RT/Extension/REST2/Resource/Queues.pm
+++ b/lib/RT/Extension/REST2/Resource/Queues.pm
@@ -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{^/queues/?$},
+        block => sub { { collection_class => 'RT::Queues' } },
+    ),
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/Extension/REST2/Resource/Record.pm b/lib/RT/Extension/REST2/Resource/Record.pm
index 99d7935..d951208 100644
--- a/lib/RT/Extension/REST2/Resource/Record.pm
+++ b/lib/RT/Extension/REST2/Resource/Record.pm
@@ -7,15 +7,17 @@ use namespace::autoclean;
 
 extends 'RT::Extension::REST2::Resource';
 
-use Web::Machine::Util qw( bind_path create_date );
-use Module::Runtime qw( require_module );
-use RT::Extension::REST2::Util qw(record_class record_type);
+use Web::Machine::Util qw( create_date );
+use RT::Extension::REST2::Util qw( record_type );
 
 has 'record_class' => (
-    is          => 'ro',
-    isa         => 'ClassName',
-    required    => 1,
-    lazy_build  => 1,
+    is  => 'ro',
+    isa => 'ClassName',
+);
+
+has 'record_id' => (
+    is  => 'ro',
+    isa => 'Int',
 );
 
 has 'record' => (
@@ -25,17 +27,14 @@ has 'record' => (
     lazy_build  => 1,
 );
 
-sub _build_record_class {
-    my $self = shift;
-    my $class = record_class($self);
-    require_module($class);
-    return $class;
-}
-
 sub _build_record {
     my $self = shift;
-    my $record = $self->record_class->new( $self->current_user );
-    my ($type, $id) = bind_path('/:type/:id', $self->request->path_info);
+    my $class = $self->record_class;
+    my $id = $self->record_id;
+
+    $class->require;
+
+    my $record = $class->new( $self->current_user );
     $record->Load($id) if $id;
     return $record;
 }
diff --git a/lib/RT/Extension/REST2/Resource/Record/Readable.pm b/lib/RT/Extension/REST2/Resource/Record/Readable.pm
index b347d89..6a41c1f 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Readable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Readable.pm
@@ -11,7 +11,7 @@ requires 'current_user';
 requires 'base_uri';
 
 use JSON ();
-use RT::Extension::REST2::Util qw( serialize_record record_type );
+use RT::Extension::REST2::Util qw( serialize_record );
 use Scalar::Util qw( blessed );
 
 sub serialize {
diff --git a/lib/RT/Extension/REST2/Resource/Root.pm b/lib/RT/Extension/REST2/Resource/Root.pm
index 163d7c6..2ffa895 100644
--- a/lib/RT/Extension/REST2/Resource/Root.pm
+++ b/lib/RT/Extension/REST2/Resource/Root.pm
@@ -8,6 +8,12 @@ use RT::Extension::REST2::PodViewer 'podview_as_html';
 
 extends 'RT::Extension::REST2::Resource';
 
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/?$},
+    );
+}
+
 sub content_types_provided { [{ 'text/html' => 'to_html' }] }
 sub charsets_provided      { [ 'utf-8' ] }
 sub default_charset        {   'utf-8'   }
diff --git a/lib/RT/Extension/REST2/Resource/Ticket.pm b/lib/RT/Extension/REST2/Resource/Ticket.pm
index ed812dc..33fe85e 100644
--- a/lib/RT/Extension/REST2/Resource/Ticket.pm
+++ b/lib/RT/Extension/REST2/Resource/Ticket.pm
@@ -14,6 +14,17 @@ with (
     'RT::Extension::REST2::Resource::Record::Writable',
 );
 
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/ticket/?$},
+        block => sub { { record_class => 'RT::Ticket' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/ticket/(\d+)$},
+        block => sub { { record_class => 'RT::Ticket', record_id => shift->pos(1) } },
+    )
+}
+
 sub create_record {
     my $self = shift;
     my $data = shift;
diff --git a/lib/RT/Extension/REST2/Resource/Tickets.pm b/lib/RT/Extension/REST2/Resource/Tickets.pm
index f74860e..deff2fb 100644
--- a/lib/RT/Extension/REST2/Resource/Tickets.pm
+++ b/lib/RT/Extension/REST2/Resource/Tickets.pm
@@ -8,6 +8,13 @@ use namespace::autoclean;
 extends 'RT::Extension::REST2::Resource::Collection';
 with 'RT::Extension::REST2::Resource::Collection::ProcessPOSTasGET';
 
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/tickets/?$},
+        block => sub { { collection_class => 'RT::Tickets' } },
+    )
+}
+
 use Encode qw( decode_utf8 );
 use RT::Extension::REST2::Util qw( error_as_json );
 use RT::Search::Simple;
diff --git a/lib/RT/Extension/REST2/Resource/Transaction.pm b/lib/RT/Extension/REST2/Resource/Transaction.pm
index 9ddc077..76585da 100644
--- a/lib/RT/Extension/REST2/Resource/Transaction.pm
+++ b/lib/RT/Extension/REST2/Resource/Transaction.pm
@@ -10,6 +10,17 @@ with 'RT::Extension::REST2::Resource::Record::Readable',
      'RT::Extension::REST2::Resource::Record::Hypermedia'
          => { -alias => { hypermedia_links => '_default_hypermedia_links' } };
 
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/transaction/?$},
+        block => sub { { record_class => 'RT::Transaction' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/transaction/(\d+)$},
+        block => sub { { record_class => 'RT::Transaction', record_id => shift->pos(1) } },
+    )
+}
+
 sub hypermedia_links {
     my $self = shift;
     my $links = $self->_default_hypermedia_links(@_);
diff --git a/lib/RT/Extension/REST2/Resource/Transactions.pm b/lib/RT/Extension/REST2/Resource/Transactions.pm
index d37cbed..623839a 100644
--- a/lib/RT/Extension/REST2/Resource/Transactions.pm
+++ b/lib/RT/Extension/REST2/Resource/Transactions.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{^/transactions/?$},
+        block => sub { { collection_class => 'RT::Transactions' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/ticket/(\d+)/history/?$},
+        block => sub {
+            my ($match, $req) = @_;
+            my $ticket = RT::Ticket->new($req->env->{"rt.current_user"});
+            $ticket->Load($match->pos(1));
+            return { collection => $ticket->Transactions };
+        },
+    )
+}
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/t/root.t b/t/root.t
index 3f66799..be7d640 100644
--- a/t/root.t
+++ b/t/root.t
@@ -45,7 +45,7 @@ my $auth = RT::Extension::REST2::Test->authorization_header;
         'Authorization' => $auth,
     );
     is($res->code, 405);
-    is($res->header('allow'), 'GET,HEAD,OPTIONS');
+    is($res->header('allow'), 'GET, HEAD, OPTIONS');
     is($mech->json_response->{message}, 'Method Not Allowed');
 }
 

commit da0879486119c396c0c33d7d257d53c151297ee9
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 16:44:27 2017 +0000

    Avoid throwing JSON decode error if there's no query

diff --git a/lib/RT/Extension/REST2/Resource/Collection/QueryByJSON.pm b/lib/RT/Extension/REST2/Resource/Collection/QueryByJSON.pm
index 346118c..a544735 100644
--- a/lib/RT/Extension/REST2/Resource/Collection/QueryByJSON.pm
+++ b/lib/RT/Extension/REST2/Resource/Collection/QueryByJSON.pm
@@ -27,7 +27,7 @@ sub _build_query {
     my $content = $self->request->method eq 'GET'
                 ? $self->request->param('query')
                 : $self->request->content;
-    return JSON::decode_json($content);
+    return $content ? JSON::decode_json($content) : [];
 }
 
 sub allowed_methods {

commit 68b5828e78af0dfdd024a632d27c071c4c8cf3aa
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 16:44:41 2017 +0000

    Switch history hyperlink to self-link + /history

diff --git a/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm b/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
index 3a06ee3..486a892 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
@@ -31,16 +31,10 @@ sub _self_link {
 
 sub _transaction_history_link {
     my $self = shift;
-    my $record = $self->record;
-
-    my $query = query_string(query => to_json [
-        { field => 'ObjectType', value => blessed($record) },
-        { field => 'ObjectId', value => $record->Id },
-    ]);
-
+    my $self_link = $self->_self_link;
     return {
         ref     => 'history',
-        _url    => RT::Extension::REST2->base_uri . "/transactions?$query",
+        _url    => $self_link->{_url} . '/history',
     };
 }
 
diff --git a/t/tickets.t b/t/tickets.t
index 7c9047d..d6cb15f 100644
--- a/t/tickets.t
+++ b/t/tickets.t
@@ -100,7 +100,7 @@ my ($ticket_url, $ticket_id);
     like($links->[0]{_url}, qr[$rest_base_path/ticket/$ticket_id$]);
 
     is($links->[1]{ref}, 'history');
-    like($links->[1]{_url}, qr[$rest_base_path/transactions\?query=]);
+    like($links->[1]{_url}, qr[$rest_base_path/ticket/$ticket_id/history$]);
 
     my $queue = $content->{Queue};
     is($queue->{id}, 1);

commit c1a82a36526adc62800584bb661c0f6799e51945
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 16:45:01 2017 +0000

    Broken URLs that hadn't 404'd before Path::Dispatcher

diff --git a/t/not_found.t b/t/not_found.t
index 9a68f56..4b0a9d3 100644
--- a/t/not_found.t
+++ b/t/not_found.t
@@ -16,7 +16,16 @@ sub is_404 {
 
 # Proper 404 Response
 {
-    for (qw[/foobar /foo /index.html /ticket.do/1 /1/1]) {
+    for (qw[
+        /foobar
+        /foo
+        /index.html
+        /ticket.do/1
+        /ticket/foo
+        /1/1
+        /record
+        /collection
+    ]) {
         my $path = $rest_base_path . $_;
         is_404($mech->get($path, 'Authorization' => $auth));
         is_404($mech->post($path, { param => 'value' }, 'Authorization' => $auth));

commit 3b3a33ce56987ed535a97f75fc5d66c61898443b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 20:17:22 2017 +0000

    Add Message resource for adding ticket reply/comment

diff --git a/lib/RT/Extension/REST2/Resource/Message.pm b/lib/RT/Extension/REST2/Resource/Message.pm
new file mode 100644
index 0000000..b999118
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/Message.pm
@@ -0,0 +1,90 @@
+package RT::Extension::REST2::Resource::Message;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::Extension::REST2::Resource';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/ticket/(\d+)/(correspond|comment)$},
+        block => sub {
+            my ($match, $req) = @_;
+            my $ticket = RT::Ticket->new($req->env->{"rt.current_user"});
+            $ticket->Load($match->pos(1));
+            return { record => $ticket, type => $match->pos(2) },
+        },
+    );
+}
+
+has record => (
+    is       => 'ro',
+    isa      => 'RT::Record',
+    required => 1,
+);
+
+has type => (
+    is       => 'ro',
+    isa      => 'Str',
+    required => 1,
+);
+
+has created_transaction => (
+    is  => 'rw',
+    isa => 'RT::Transaction',
+);
+
+sub post_is_create            { 1 }
+sub create_path_after_handler { 1 }
+sub allowed_methods           { ['POST'] }
+sub charsets_provided         { [ 'utf-8' ] }
+sub default_charset           { 'utf-8' }
+sub content_types_provided    { [ { 'application/json' => sub {} } ] }
+sub content_types_accepted    { [ { 'text/plain' => 'add_message' }, { 'text/html' => 'add_message' } ] }
+
+sub add_message {
+    my $self = shift;
+
+    my $MIME = HTML::Mason::Commands::MakeMIMEEntity(
+        Interface => 'REST',
+        Body => $self->request->content,
+        Type => $self->request->content_type,
+        @_,
+    );
+
+    my ( $Trans, $msg, $TransObj ) ;
+
+    if ($self->type eq 'correspond') {
+        ( $Trans, $msg, $TransObj ) = $self->record->Correspond(MIMEObj => $MIME);
+    }
+    elsif ($self->type eq 'comment') {
+        ( $Trans, $msg, $TransObj ) = $self->record->Comment(MIMEObj => $MIME);
+    }
+    else {
+        return \400;
+    }
+
+    if (!$Trans) {
+        return error_as_json(
+            $self->response,
+            \400, $msg || "Message failed for unknown reason");
+    }
+
+    $self->created_transaction($TransObj);
+    $self->response->body(JSON::to_json([$msg], { pretty => 1 }));
+
+    return 1;
+}
+
+sub create_path {
+    my $self = shift;
+    my $id = $self->created_transaction->Id;
+    return RT::Extension::REST2->base_path . "/transaction/$id";
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/lib/RT/Extension/REST2/Resource/Ticket.pm b/lib/RT/Extension/REST2/Resource/Ticket.pm
index 33fe85e..204ea06 100644
--- a/lib/RT/Extension/REST2/Resource/Ticket.pm
+++ b/lib/RT/Extension/REST2/Resource/Ticket.pm
@@ -40,8 +40,21 @@ sub forbidden {
 
 sub hypermedia_links {
     my $self = shift;
+    my $self_link = $self->_self_link;
     my $links = $self->_default_hypermedia_links(@_);
+
     push @$links, $self->_transaction_history_link;
+
+    push @$links, {
+            ref     => 'correspond',
+            _url    => $self_link->{_url} . '/correspond',
+    } if $self->record->CurrentUserHasRight('ReplyToTicket');
+
+    push @$links, {
+        ref     => 'comment',
+        _url    => $self_link->{_url} . '/comment',
+    } if $self->record->CurrentUserHasRight('CommentOnTicket');
+
     return $links;
 }
 
diff --git a/t/tickets.t b/t/tickets.t
index d6cb15f..28f8fa4 100644
--- a/t/tickets.t
+++ b/t/tickets.t
@@ -204,4 +204,49 @@ my ($ticket_url, $ticket_id);
     }
 }
 
+# Ticket Reply
+{
+    # we know from earlier tests that look at hypermedia without ReplyToTicket
+    # that correspond wasn't available, so we don't need to check again here
+
+    $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$]);
+
+    $res = $mech->post($mech->url_for_hypermedia('correspond'),
+        'Authorization' => $auth,
+        'Content-Type' => 'text/plain',
+        'Content' => 'Hello from hypermedia!',
+    );
+    is($res->code, 201);
+    is_deeply($mech->json_response, ["Correspondence added"]);
+
+    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}, 'Hello from hypermedia!');
+    is($content->{ContentType}, 'text/plain');
+}
+
 done_testing;

commit b4e8671df1c7938cf4d547cdac15ac891d1d0745
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 20:51:19 2017 +0000

    Add resources for transaction attachments

diff --git a/lib/RT/Extension/REST2/Resource/Attachment.pm b/lib/RT/Extension/REST2/Resource/Attachment.pm
new file mode 100644
index 0000000..165ac85
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/Attachment.pm
@@ -0,0 +1,26 @@
+package RT::Extension::REST2::Resource::Attachment;
+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';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/attachment/?$},
+        block => sub { { record_class => 'RT::Attachment' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/attachment/(\d+)$},
+        block => sub { { record_class => 'RT::Attachment', record_id => shift->pos(1) } },
+    )
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/lib/RT/Extension/REST2/Resource/Attachments.pm b/lib/RT/Extension/REST2/Resource/Attachments.pm
new file mode 100644
index 0000000..76e4a01
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/Attachments.pm
@@ -0,0 +1,30 @@
+package RT::Extension::REST2::Resource::Attachments;
+use strict;
+use warnings;
+
+use Moose;
+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{^/attachments/?$},
+        block => sub { { collection_class => 'RT::Attachments' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/transaction/(\d+)/attachments/?$},
+        block => sub {
+            my ($match, $req) = @_;
+            my $txn = RT::Transaction->new($req->env->{"rt.current_user"});
+            $txn->Load($match->pos(1));
+            return { collection => $txn->Attachments };
+        },
+    )
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+

commit ea7c9eb1994c6dc3d43bde8859664541a6797967
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 20:52:14 2017 +0000

    Add each attachment as hypermedia, not a collection endpoint
    
    The former is way more useful

diff --git a/lib/RT/Extension/REST2/Resource/Transaction.pm b/lib/RT/Extension/REST2/Resource/Transaction.pm
index 76585da..805bf5a 100644
--- a/lib/RT/Extension/REST2/Resource/Transaction.pm
+++ b/lib/RT/Extension/REST2/Resource/Transaction.pm
@@ -25,14 +25,12 @@ sub hypermedia_links {
     my $self = shift;
     my $links = $self->_default_hypermedia_links(@_);
 
-    my $class = 'transaction';
-    my $id = $self->record->id;
-
     my $attachments = $self->record->Attachments;
-    if ($attachments->Count) {
+    while (my $attachment = $attachments->Next) {
+        my $id = $attachment->Id;
         push @$links, {
-            ref  => 'attachments',
-            _url => RT::Extension::REST2->base_uri . "/$class/$id/attachments",
+            ref  => 'attachment',
+            _url => RT::Extension::REST2->base_uri . "/attachment/$id",
         };
     }
 
diff --git a/t/transactions.t b/t/transactions.t
index 21144f5..2a02c43 100644
--- a/t/transactions.t
+++ b/t/transactions.t
@@ -152,8 +152,8 @@ my ($comment_txn_url, $comment_txn_id);
     is($links->[0]{type}, 'transaction');
     is($links->[0]{_url}, $comment_txn_url);
 
-    is($links->[1]{ref}, 'attachments');
-    is($links->[1]{_url}, $comment_txn_url . '/attachments');
+    is($links->[1]{ref}, 'attachment');
+    like($links->[1]{_url}, qr{$rest_base_path/attachment/\d+$});
 
     my $creator = $content->{Creator};
     is($creator->{id}, 'test');

commit 08c849ef84d7feb057e73f67e349c2f072d69138
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 21:10:29 2017 +0000

    Add support for application/json in Message resource
    
    This allows specifying TimeTaken, Subject, and so on

diff --git a/lib/RT/Extension/REST2/Resource/Message.pm b/lib/RT/Extension/REST2/Resource/Message.pm
index b999118..047d7ce 100644
--- a/lib/RT/Extension/REST2/Resource/Message.pm
+++ b/lib/RT/Extension/REST2/Resource/Message.pm
@@ -42,25 +42,45 @@ sub allowed_methods           { ['POST'] }
 sub charsets_provided         { [ 'utf-8' ] }
 sub default_charset           { 'utf-8' }
 sub content_types_provided    { [ { 'application/json' => sub {} } ] }
-sub content_types_accepted    { [ { 'text/plain' => 'add_message' }, { 'text/html' => 'add_message' } ] }
+sub content_types_accepted    { [ { 'text/plain' => 'add_message' }, { 'text/html' => 'add_message' }, { 'application/json' => 'from_json' } ] }
+
+sub from_json {
+    my $self = shift;
+    my $body = JSON::decode_json( $self->request->content );
+
+    if (!$body->{ContentType}) {
+        return error_as_json(
+            $self->response,
+            \400, "ContentType is a required field for application/json");
+    }
+
+    $self->add_message(%$body);
+}
 
 sub add_message {
     my $self = shift;
+    my %args = @_;
 
     my $MIME = HTML::Mason::Commands::MakeMIMEEntity(
         Interface => 'REST',
-        Body => $self->request->content,
-        Type => $self->request->content_type,
-        @_,
+        Body      => $args{Content}     || $self->request->content,
+        Type      => $args{ContentType} || $self->request->content_type,
+        Subject   => $args{Subject},
     );
 
     my ( $Trans, $msg, $TransObj ) ;
 
     if ($self->type eq 'correspond') {
-        ( $Trans, $msg, $TransObj ) = $self->record->Correspond(MIMEObj => $MIME);
+        ( $Trans, $msg, $TransObj ) = $self->record->Correspond(
+            MIMEObj   => $MIME,
+            TimeTaken => ($args{TimeTaken} || 0),
+        );
     }
     elsif ($self->type eq 'comment') {
-        ( $Trans, $msg, $TransObj ) = $self->record->Comment(MIMEObj => $MIME);
+        ( $Trans, $msg, $TransObj ) = $self->record->Comment(
+            MIMEObj   => $MIME,
+            TimeTaken => ($args{TimeTaken} || 0),
+        );
     }
     else {
         return \400;
diff --git a/t/tickets.t b/t/tickets.t
index 28f8fa4..aa4ab0e 100644
--- a/t/tickets.t
+++ b/t/tickets.t
@@ -249,4 +249,64 @@ my ($ticket_url, $ticket_id);
     is($content->{ContentType}, 'text/plain');
 }
 
+# Ticket Comment
+{
+    my $payload = {
+        Content     => '<i>(hello secret camera)</i>',
+        ContentType => 'text/html',
+        Subject     => 'shh',
+        TimeTaken   => 129,
+    };
+
+    # we know from earlier tests that look at hypermedia without ReplyToTicket
+    # that correspond wasn't available, so we don't need to check again here
+
+    $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$]);
+
+    $res = $mech->post_json($mech->url_for_hypermedia('comment'),
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    is_deeply($mech->json_response, ["Comments added"]);
+
+    my $txn_url = $res->header('Location');
+    like($txn_url, qr{$rest_base_path/transaction/\d+$});
+    $res = $mech->get($txn_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 403);
+
+    $user->PrincipalObj->GrantRight( Right => 'ShowTicketComments' );
+
+    $res = $mech->get($txn_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    is($content->{Type}, 'Comment');
+    is($content->{TimeTaken}, 129);
+    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->{Subject}, 'shh');
+    is($content->{Content}, '<i>(hello secret camera)</i>');
+    is($content->{ContentType}, 'text/html');
+}
+
 done_testing;

commit f840702d2d80e4e0c8557968fc6ba2a1986e0a5d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Jun 9 21:24:30 2017 +0000

    Add lifecycle actions to hypermedia links

diff --git a/lib/RT/Extension/REST2/Resource/Ticket.pm b/lib/RT/Extension/REST2/Resource/Ticket.pm
index 204ea06..0545666 100644
--- a/lib/RT/Extension/REST2/Resource/Ticket.pm
+++ b/lib/RT/Extension/REST2/Resource/Ticket.pm
@@ -38,22 +38,63 @@ sub forbidden {
     return !$self->record->CurrentUserHasRight('ShowTicket');
 }
 
+sub lifecycle_hypermedia_links {
+    my $self = shift;
+    my $self_link = $self->_self_link;
+    my $ticket = $self->record;
+    my @links;
+
+    # lifecycle actions
+    my $lifecycle = $ticket->LifecycleObj;
+    my $current = $ticket->Status;
+    my $hide_resolve_with_deps = RT->Config->Get('HideResolveActionsWithDependencies')
+        && $ticket->HasUnresolvedDependencies;
+
+    for my $info ( $lifecycle->Actions($current) ) {
+        my $next = $info->{'to'};
+        next unless $lifecycle->IsTransition( $current => $next );
+
+        my $check = $lifecycle->CheckRight( $current => $next );
+        next unless $ticket->CurrentUserHasRight($check);
+
+        next if $hide_resolve_with_deps
+            && $lifecycle->IsInactive($next)
+            && !$lifecycle->IsInactive($current);
+
+        my $url = $self_link->{_url};
+        $url .= '/correspond' if ($info->{update}||'') eq 'Respond';
+        $url .= '/comment' if ($info->{update}||'') eq 'Comment';
+
+        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 $ticket = $self->record;
 
     push @$links, $self->_transaction_history_link;
 
     push @$links, {
             ref     => 'correspond',
             _url    => $self_link->{_url} . '/correspond',
-    } if $self->record->CurrentUserHasRight('ReplyToTicket');
+    } if $ticket->CurrentUserHasRight('ReplyToTicket');
 
     push @$links, {
         ref     => 'comment',
         _url    => $self_link->{_url} . '/comment',
-    } if $self->record->CurrentUserHasRight('CommentOnTicket');
+    } if $ticket->CurrentUserHasRight('CommentOnTicket');
+
+    push @$links, $self->lifecycle_hypermedia_links;
 
     return $links;
 }
diff --git a/t/tickets.t b/t/tickets.t
index aa4ab0e..db19564 100644
--- a/t/tickets.t
+++ b/t/tickets.t
@@ -177,6 +177,39 @@ my ($ticket_url, $ticket_id);
     my $content = $mech->json_response;
     is($content->{Subject}, 'Ticket update using REST');
     is($content->{Priority}, 42);
+
+    # now that we have ModifyTicket, 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}, 'ticket');
+    like($links->[0]{_url}, qr[$rest_base_path/ticket/$ticket_id$]);
+
+    is($links->[1]{ref}, 'history');
+    like($links->[1]{_url}, qr[$rest_base_path/ticket/$ticket_id/history$]);
+
+    is($links->[2]{ref}, 'lifecycle');
+    like($links->[2]{_url}, qr[$rest_base_path/ticket/$ticket_id/correspond$]);
+    is($links->[2]{label}, 'Open It');
+    is($links->[2]{update}, 'Respond');
+    is($links->[2]{from}, 'new');
+    is($links->[2]{to}, 'open');
+
+    is($links->[3]{ref}, 'lifecycle');
+    like($links->[3]{_url}, qr[$rest_base_path/ticket/$ticket_id/comment$]);
+    is($links->[3]{label}, 'Resolve');
+    is($links->[3]{update}, 'Comment');
+    is($links->[3]{from}, 'new');
+    is($links->[3]{to}, 'resolved');
+
+    is($links->[4]{ref}, 'lifecycle');
+    like($links->[4]{_url}, qr[$rest_base_path/ticket/$ticket_id/correspond$]);
+    is($links->[4]{label}, 'Reject');
+    is($links->[4]{update}, 'Respond');
+    is($links->[4]{from}, 'new');
+    is($links->[4]{to}, 'rejected');
 }
 
 # Transactions

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


More information about the Bps-public-commit mailing list