File Coverage

blib/lib/RT/Client/REST.pm
Criterion Covered Total %
statement 265 425 62.3
branch 73 156 46.7
condition 22 76 28.9
subroutine 45 59 76.2
pod 23 23 100.0
total 428 739 57.9


line stmt bran cond sub pod time code
1             #!perl
2             # vim: softtabstop=4 tabstop=4 shiftwidth=4 ft=perl expandtab smarttab
3             # PODNAME: RT::Client::REST
4             # ABSTRACT: Client for RT using REST API
5             #
6             # Dmitri Tikhonov <dtikhonov@yahoo.com>
7             #
8             # Part of the source is Copyright (c) 2007-2008 Damien Krotkine <dams@cpan.org>
9             #
10             # This code is adapted from /usr/bin/rt that came with RT. As of version 0.49,
11             # this module is licensed using Perl Artistic License, with permission from the
12             # original author of rt utility, Abhijit Menon-Sen.
13             #
14             # Original notice:
15             #------------------------
16             # COPYRIGHT:
17             # This software is Copyright (c) 1996-2005 Best Practical Solutions, LLC
18             # <jesse@bestpractical.com>
19             # Designed and implemented for Best Practical Solutions, LLC by
20             # Abhijit Menon-Sen <ams@wiw.org>
21             #------------------------
22              
23              
24 21     21   1966767 use strict;
  21         217  
  21         641  
25 21     21   116 use warnings;
  21         53  
  21         1126  
26              
27             package RT::Client::REST;
28             $RT::Client::REST::VERSION = '0.71';
29 21     21   5965 use Try::Tiny;
  21         24275  
  21         1206  
30 21     21   11279 use HTTP::Cookies;
  21         244921  
  21         679  
31 21     21   10303 use HTTP::Request::Common;
  21         544809  
  21         1671  
32 21     21   7446 use RT::Client::REST::Exception;
  21         72  
  21         184  
33 21     21   10812 use RT::Client::REST::Forms;
  21         63  
  21         1712  
34 21     21   8266 use RT::Client::REST::HTTPClient;
  21         83  
  21         1051  
35              
36             # Generate accessors/mutators
37             for my $method (qw(server _cookie timeout verbose_errors user_agent_args)) {
38 21     21   175 no strict 'refs'; ## no critic (ProhibitNoStrict)
  21         46  
  21         1348  
39             *{__PACKAGE__ . '::' . $method} = sub {
40 153     153   2125 my $self = shift;
41 153 100       526 if (@_) {
42 35         195 my $val = shift;
43             {
44 21     21   130 no warnings 'uninitialized';
  21         46  
  21         107495  
  35         128  
45 35         267 $self->logger->debug("set `$method' to $val");
46             }
47 35         313 $self->{'_' . $method} = $val;
48             }
49 153         1765 return $self->{'_' . $method};
50             };
51             }
52              
53             sub new {
54 25     25 1 73791 my $class = shift;
55              
56 25         568 $class->_assert_even(@_);
57              
58 25   33     509 my $self = bless {
59             _logger => RT::Client::REST::NoopLogger->new,
60             }, ref($class) || $class;
61 25         396 my %opts = @_;
62              
63 25         324 while (my ($k, $v) = each(%opts)) {
64             # in _rest we concatenate server with '/REST/1.0';
65 31 100       276 if ($k eq 'server') {
66 13         220 $v =~ s!/$!!;
67             }
68 31         508 $self->$k($v);
69             }
70              
71 24         173 return $self;
72             }
73              
74             sub login {
75 5     5 1 8142 my $self = shift;
76              
77 5         33 $self->_assert_even(@_);
78              
79 5         40 my %opts = @_;
80 5 100       42 unless (scalar(keys %opts) > 0) {
81 1         18 RT::Client::REST::InvalidParameterValueException->throw(
82             "You must provide credentials (user and pass) to log in",
83             );
84             }
85             # back-compat hack
86 4 50       22 if (defined $opts{username}){ $opts{user} = $opts{username}; delete $opts{username} }
  4         33  
  4         14  
87 4 50       16 if (defined $opts{password}){ $opts{pass} = $opts{password}; delete $opts{password} }
  4         20  
  4         18  
88              
89             # OK, here's how login works. We request to see ticket 1. We don't
90             # even care if it exists. We watch exceptions: auth. failures and
91             # server-side errors we bubble up and ignore all others.
92             try {
93 4     4   263 $self->_cookie(undef); # Start a new session.
94 4         48 $self->_submit('ticket/1', undef, \%opts);
95             }
96             catch {
97 4 50 33 4   6308 die $_ unless blessed $_ && $_->can('rethrow');
98              
99 4         19 my $err = $_;
100 4 50       16 if (grep { $err->isa($_) } (
  16         148  
101             'RT::Client::REST::AuthenticationFailureException',
102             'RT::Client::REST::MalformedRTResponseException',
103             'RT::Client::REST::RequestTimedOutException',
104             'RT::Client::REST::HTTPException',
105             )) {
106             shift->rethrow
107 4         24 }
108 0 0       0 if (! $err->isa('Exception::Class::Base')) {
109 0         0 die $err
110             }
111             # ignore others.
112 4         68 };
113             }
114              
115             sub show {
116 1     1 1 10 my $self = shift;
117              
118 1         4 $self->_assert_even(@_);
119              
120 1         5 my %opts = @_;
121              
122 1         5 my $type = $self->_valid_type(delete($opts{type}));
123 1         2 my $id;
124              
125 1 50       3 if (grep { $type eq $_ } (qw(user queue group))) {
  3         11  
126             # User or queue ID does not have to be numeric
127 0         0 $id = delete($opts{id});
128             } else {
129 1         4 $id = $self->_valid_numeric_object_id(delete($opts{id}));
130             }
131              
132 1         9 my $form = form_parse($self->_submit("$type/$id")->decoded_content);
133 0         0 my ($c, $o, $k) = @{$$form[0]}; # my ($c, $o, $k, $e)
  0         0  
134              
135 0 0 0     0 if (!@$o && $c) {
136 0         0 RT::Client::REST::Exception->_rt_content_to_exception($c)->throw;
137             }
138              
139 0         0 return $k;
140             }
141              
142             sub get_attachment_ids {
143 1     1 1 3 my $self = shift;
144              
145 1         5 $self->_assert_even(@_);
146              
147 1         4 my %opts = @_;
148              
149 1   50     10 my $type = $self->_valid_type(delete($opts{type}) || 'ticket');
150 1         5 my $id = $self->_valid_numeric_object_id(delete($opts{id}));
151              
152 1         6 my $form = form_parse(
153             $self->_submit("$type/$id/attachments/")->decoded_content
154             );
155 0         0 my ($c, $o, $k) = @{$$form[0]}; # my ($c, $o, $k, $e)
  0         0  
156              
157 0 0 0     0 if (!@$o && $c) {
158 0         0 RT::Client::REST::Exception->_rt_content_to_exception($c)->throw;
159             }
160              
161 0         0 return $k->{Attachments} =~ m/^\s*(\d+):/mg;
162             }
163              
164             sub get_attachments_metadata {
165 0     0 1 0 my $self = shift;
166              
167 0         0 $self->_assert_even(@_);
168              
169 0         0 my %opts = @_;
170              
171 0   0     0 my $type = $self->_valid_type(delete($opts{type}) || 'ticket');
172 0         0 my $id = $self->_valid_numeric_object_id(delete($opts{id}));
173              
174 0         0 my $form = form_parse(
175             $self->_submit("$type/$id/attachments/")->decoded_content
176             );
177 0         0 my ($c, $o, $k) = @{$$form[0]}; # my ($c, $o, $k, $e)
  0         0  
178              
179 0 0 0     0 if (!@$o && $c) {
180 0         0 RT::Client::REST::Exception->_rt_content_to_exception($c)->throw;
181             }
182             return map {
183             # Matches: '50008989: (Unnamed) (text/plain / 1.9k),'
184 0         0 my @c = $_ =~ m/^\s*(\d+):\s+(.+)\s+\(([^\s]+)\s+\/\s+([^\s]+)\)\s*,?\s*$/;
185 0 0 0     0 { id => $c[0], Filename => ( defined($c[1]) && ( $c[1] eq '(Unnamed)' ) ) ? undef : $c[1], Type => $c[2], Size => $c[3] };
186 0         0 } split(/\n/, $k->{Attachments});
187             }
188              
189             sub get_attachment {
190 6     6 1 2368 my $self = shift;
191              
192 6         72 $self->_assert_even(@_);
193              
194 6         97 my %opts = @_;
195              
196 6   50     102 my $type = $self->_valid_type(delete($opts{type}) || 'ticket');
197 6         39 my $parent_id = $self->_valid_numeric_object_id(delete($opts{parent_id}));
198 6         61 my $id = $self->_valid_numeric_object_id(delete($opts{id}));
199              
200 6         82 my $res = $self->_submit("$type/$parent_id/attachments/$id");
201 6         14 my $content;
202 6 100       31 if ($opts{undecoded}) {
203 3         34 $content = $res->content;
204             }
205             else {
206 3         14 $content = $res->decoded_content;
207             }
208 6         3102 my $form = form_parse($content);
209              
210 6         13 my ($c, $o, $k) = @{$$form[0]}; # my ($c, $o, $k, $e)
  6         25  
211              
212 6 50 33     27 if (!@$o && $c) {
213 0         0 RT::Client::REST::Exception->_rt_content_to_exception($c)->throw;
214             }
215              
216 6         127 return $k;
217             }
218              
219             sub get_links {
220 0     0 1 0 my $self = shift;
221              
222 0         0 $self->_assert_even(@_);
223              
224 0         0 my %opts = @_;
225              
226 0   0     0 my $type = $self->_valid_type(delete($opts{type}) || 'ticket');
227 0         0 my $id = $self->_valid_numeric_object_id(delete($opts{id}));
228              
229 0         0 my $form = form_parse(
230             $self->_submit("$type/$id/links/$id")->decoded_content
231             );
232 0         0 my ($c, $o, $k) = @{$$form[0]}; # my ($c, $o, $k, $e)
  0         0  
233              
234 0 0 0     0 if (!@$o && $c) {
235 0         0 RT::Client::REST::Exception->_rt_content_to_exception($c)->throw;
236             }
237              
238             # Turn the links into id lists
239 0         0 for my $key (keys(%$k)) {
240             try {
241 0     0   0 $self->_valid_link_type($key);
242 0         0 my @list = split(/\s*,\s*/,$k->{$key});
243             #use Data::Dumper;
244             #print STDERR Dumper(\@list);
245 0         0 my @newlist = ();
246 0         0 for my $val (@list) {
247 0 0       0 if ($val =~ /^fsck\.com-\w+\:\/\/(.*?)\/(.*?)\/(\d+)$/) {
248             # We just want the ids, not the URI
249 0         0 push(@newlist, {'type' => $2, 'instance' => $1, 'id' => $3 });
250             } else {
251             # Something we don't recognise
252 0         0 push(@newlist, { 'url' => $val });
253             }
254             }
255             # Copy the newly created list
256 0         0 $k->{$key} = ();
257 0         0 $k->{$key} = \@newlist;
258             }
259             catch {
260 0 0 0 0   0 die $_ unless blessed $_ && $_->can('rethrow');
261              
262 0 0       0 if (! $_->isa('RT::Client::REST::InvalidParameterValueException')) {
263 0         0 $_->rethrow;
264             }
265             # Skip it because the keys are not always valid e.g., 'id'
266             }
267 0         0 }
268              
269 0         0 return $k;
270             }
271              
272             sub get_transaction_ids {
273 1     1 1 3 my $self = shift;
274              
275 1         4 $self->_assert_even(@_);
276              
277 1         3 my %opts = @_;
278              
279 1         4 my $parent_id = $self->_valid_numeric_object_id(delete($opts{parent_id}));
280 1   50     13 my $type = $self->_valid_type(delete($opts{type}) || 'ticket');
281              
282 1         4 my $path;
283 1         4 my $tr_type = delete($opts{transaction_type});
284 1 50       4 if (!defined($tr_type)) {
    0          
285             # Gotta catch 'em all!
286 1         4 $path = "$type/$parent_id/history";
287             } elsif ('ARRAY' eq ref($tr_type)) {
288             # OK, more than one type. Call ourselves for each.
289             # NOTE: this may be very expensive.
290             my @return = sort map {
291 0         0 $self->get_transaction_ids(
292             parent_id => $parent_id,
293             transaction_type => $_,
294             )
295             } map {
296             # Check all the types before recursing, cheaper to catch an
297             # error this way.
298 0         0 $self->_valid_transaction_type($_)
  0         0  
299             } @$tr_type;
300             return @return
301 0         0 } else {
302 0         0 $tr_type = $self->_valid_transaction_type($tr_type);
303 0         0 $path = "$type/$parent_id/history/type/$tr_type"
304             }
305              
306 1         4 my $form = form_parse( $self->_submit($path)->decoded_content );
307 0         0 my ($c, $o, $k, $e) = @{$$form[0]};
  0         0  
308              
309 0 0       0 if (!length($e)) {
310 0         0 my $ex = RT::Client::REST::Exception->_rt_content_to_exception($c);
311 0 0       0 unless ($ex->message =~ m~^0/~) {
312             # We do not throw exception if the error is that no values
313             # were found.
314 0         0 $ex->throw;
315             }
316             }
317              
318 0         0 return $e =~ m/^(?:>> )?(\d+):/mg;
319             }
320              
321             sub get_transaction {
322 0     0 1 0 my $self = shift;
323              
324 0         0 $self->_assert_even(@_);
325              
326 0         0 my %opts = @_;
327              
328 0   0     0 my $type = $self->_valid_type(delete($opts{type}) || 'ticket');
329 0         0 my $parent_id = $self->_valid_numeric_object_id(delete($opts{parent_id}));
330 0         0 my $id = $self->_valid_numeric_object_id(delete($opts{id}));
331              
332 0         0 my $form = form_parse(
333             $self->_submit("$type/$parent_id/history/id/$id")->decoded_content
334             );
335 0         0 my ($c, $o, $k) = @{$$form[0]}; # my ($c, $o, $k, $e)
  0         0  
336              
337 0 0 0     0 if (!@$o && $c) {
338 0         0 RT::Client::REST::Exception->_rt_content_to_exception($c)->throw;
339             }
340              
341 0         0 return $k;
342             }
343              
344             sub search {
345 2     2 1 11 my $self = shift;
346              
347 2         8 $self->_assert_even(@_);
348              
349 2         7 my %opts = @_;
350              
351 2         6 my $type = $self->_valid_type(delete($opts{type}));
352 2         4 my $query = delete($opts{query});
353 2         4 my $orderby = delete($opts{orderby});
354 2         4 my $format = delete($opts{format});
355 2 50       5 if (defined($format)) {
356 0 0       0 $format = undef if $format ne 's'
357             }
358              
359 2 50       15 my $r = $self->_submit("search/$type", {
    50          
360             query => $query,
361             (defined($orderby) ? (orderby => $orderby) : ()),
362             (defined($format) ? (format => $format) : ()),
363             });
364              
365 0 0 0     0 if (defined($format) and $format eq 's') {
366 0         0 my @results;
367             # while() never stops if the method is used in the regex
368 0         0 my $text = $r->decoded_content;
369 0         0 while ($text =~ m/^(\d+): (.*)/gm) {
370 0         0 push @results, [$1, $2]
371             }
372             return @results
373 0         0 }
374 0         0 return $r->decoded_content =~ m/^(\d+):/gm;
375             }
376              
377             sub edit {
378 1     1 1 3 my $self = shift;
379 1         5 $self->_assert_even(@_);
380 1         5 my %opts = @_;
381              
382 1         3 my $type = $self->_valid_type(delete($opts{type}));
383              
384 1         4 my $id = delete($opts{id});
385 1 50       4 unless ('new' eq $id) {
386 1         3 $id = $self->_valid_numeric_object_id($id);
387             }
388              
389 1         2 my %set;
390 1 50       4 if (defined(my $set = delete($opts{set}))) {
391 1         4 while (my ($k, $v) = each(%$set)) {
392 0         0 vpush(\%set, lc($k), $v);
393             }
394             }
395 1 50       4 if (defined(my $text = delete($opts{text}))) {
396 0         0 $text =~ s/(\n\r?)/$1 /g;
397 0         0 vpush(\%set, 'text', $text);
398             }
399 1         5 $set{id} = "$type/$id";
400              
401 1         8 my $r = $self->_submit('edit', {
402             content => form_compose([['', [keys %set], \%set]])
403             });
404              
405             # This seems to be a bug on the server side: returning 200 Ok when
406             # ticket creation (for instance) fails. We check it here:
407 0 0       0 if ($r->decoded_content =~ /not/) {
408 0         0 RT::Client::REST::Exception->_rt_content_to_exception($r->decoded_content)
409             ->throw(
410             code => $r->code,
411             message => "RT server returned this error: " . $r->decoded_content,
412             );
413             }
414              
415 0 0       0 if ($r->decoded_content =~ /^#[^\d]+(\d+) (?:created|updated)/) {
416 0         0 return $1;
417             } else {
418 0         0 RT::Client::REST::MalformedRTResponseException->throw(
419             message => "Cound not read ID of the modified object",
420             );
421             }
422             }
423              
424 0     0 1 0 sub create { shift->edit(@_, id => 'new') }
425              
426             sub comment {
427 4     4 1 9 my $self = shift;
428 4         10 $self->_assert_even(@_);
429 4         13 my %opts = @_;
430             my $action = $self->_valid_comment_action(
431 4   100     23 delete($opts{comment_action}) || 'comment');
432 4         14 my $ticket_id = $self->_valid_numeric_object_id(delete($opts{ticket_id}));
433 4         12 my $msg = $self->_valid_comment_message(delete($opts{message}));
434              
435 4         11 my @objects = ('Ticket', 'Action', 'Text');
436 4         13 my %values = (
437             Ticket => $ticket_id,
438             Action => $action,
439             Text => $msg,
440             );
441              
442 4 50       13 if (exists($opts{html})) {
443 0 0       0 if ($opts{html}) {
444 0         0 push @objects, 'Content-Type';
445 0         0 $values{'Content-Type'} = 'text/html';
446             }
447 0         0 delete($opts{html});
448             }
449              
450 4 50       8 if (exists($opts{cc})) {
451 0         0 push @objects, 'Cc';
452 0         0 $values{Cc} = delete($opts{cc});
453             }
454              
455 4 50       11 if (exists($opts{bcc})) {
456 0         0 push @objects, 'Bcc';
457 0         0 $values{Bcc} = delete($opts{bcc});
458             }
459              
460 4         7 my %data;
461 4 100       19 if (exists($opts{attachments})) {
462 2         3 my $files = delete($opts{attachments});
463 2 50       8 unless ('ARRAY' eq ref($files)) {
464 0         0 RT::Client::REST::InvalidParameterValueException->throw(
465             "'attachments' must be an array reference",
466             );
467             }
468 2         5 push @objects, 'Attachment';
469 2         4 $values{Attachment} = $files;
470              
471 2         7 for (my $i = 0; $i < @$files; ++$i) {
472 2 50 33     130 unless (-f $files->[$i] && -r _) {
473 2         26 RT::Client::REST::CannotReadAttachmentException->throw(
474             "File '" . $files->[$i] . "' is not readable",
475             );
476             }
477              
478 0         0 my $index = $i + 1;
479 0         0 $data{"attachment_$index"} = bless([ $files->[$i] ], 'Attachment');
480             }
481             }
482              
483 2         24 my $text = form_compose([[ '', \@objects, \%values, ]]);
484 2         8 $data{content} = $text;
485              
486 2         12 $self->_submit("ticket/$ticket_id/comment", \%data);
487              
488 0         0 return;
489             }
490              
491 2     2 1 8 sub correspond { shift->comment(@_, comment_action => 'correspond') }
492              
493             sub merge_tickets {
494 0     0 1 0 my $self = shift;
495 0         0 $self->_assert_even(@_);
496 0         0 my %opts = @_;
497 0         0 my ($src, $dst) = map { $self->_valid_numeric_object_id($_) }
498 0         0 @opts{qw(src dst)};
499 0         0 $self->_submit("ticket/$src/merge/$dst");
500 0         0 return;
501             }
502              
503             sub _link {
504 0     0   0 my $self = shift;
505 0         0 $self->_assert_even(@_);
506 0         0 my %opts = @_;
507 0         0 my ($src, $dst) = map { $self->_valid_numeric_object_id($_) }
508 0         0 @opts{qw(src dst)};
509 0         0 my $ltype = $self->_valid_link_type(delete($opts{link_type}));
510 0 0       0 my $del = (exists($opts{'unlink'}) ? 1 : '');
511 0   0     0 my $type = $self->_valid_type(delete($opts{type}) || 'ticket');
512              
513             #$self->_submit("$type/$src/link", {
514             #id => $from, rel => $rel, to => $to, del => $del
515             #}
516              
517 0         0 $self->_submit("$type/link", {
518             id => $src,
519             rel => $ltype,
520             to => $dst,
521             del => $del,
522             });
523              
524 0         0 return;
525             }
526              
527 0     0 1 0 sub link_tickets { shift->_link(@_, type => 'ticket') }
528              
529             # sub unlink { shift->_link(@_, unlink => 1) } ## nothing calls this & undocumented, so commenting out for now
530 0     0 1 0 sub unlink_tickets { shift->_link(@_, type => 'ticket', unlink => 1) }
531              
532             sub _ticket_action {
533 3     3   7 my $self = shift;
534              
535 3         10 $self->_assert_even(@_);
536              
537 3         12 my %opts = @_;
538              
539 3         8 my $id = delete $opts{id};
540 3         6 my $action = delete $opts{action};
541              
542 3         15 my $text = form_compose([[ '', ['Action'], { Action => $action }, ]]);
543              
544 3         20 my $form = form_parse(
545             $self->_submit("/ticket/$id/take", { content => $text })->decoded_content
546             );
547 0         0 my ($c, $o, $k, $e) = @{$$form[0]};
  0         0  
548              
549 0 0       0 if ($e) {
550 0         0 RT::Client::REST::Exception->_rt_content_to_exception($c)->throw;
551             }
552             }
553              
554 1     1 1 6 sub take { shift->_ticket_action(@_, action => 'take') }
555 1     1 1 5 sub untake { shift->_ticket_action(@_, action => 'untake') }
556 1     1 1 4 sub steal { shift->_ticket_action(@_, action => 'steal') }
557              
558             sub _submit {
559 28     28   6530 my ($self, $uri, $content, $auth) = @_;
560 28         119 my ($req, $data);
561              
562             # Did the caller specify any data to send with the request?
563 28         125 $data = [];
564 28 100       179 if (defined $content) {
565 9 100 33     49 unless (ref $content) {
566             # If it's just a string, make sure LWP handles it properly.
567             # (By pretending that it's a file!)
568 1         18 $content = [ content => [undef, q(), Content => $content] ];
569             }
570             elsif (ref $content eq 'HASH') {
571             my @data;
572             for my $k (keys %$content) {
573             if (ref $content->{$k} eq 'ARRAY') {
574             for my $v (@{ $content->{$k} }) {
575             push @data, $k, $v;
576             }
577             }
578             else { push @data, $k, $content->{$k} }
579             }
580             $content = \@data;
581             }
582 9         26 $data = $content;
583             }
584              
585             # Should we send authentication information to start a new session?
586 28 100 66     210 unless ($self->_cookie || $self->basic_auth_cb) {
587 22 100       76 unless (defined($auth)) {
588 11         55 RT::Client::REST::RequiredAttributeUnsetException->throw(
589             'You must log in first',
590             );
591             }
592 11         69 push @$data, %$auth;
593             }
594              
595             # Now, we construct the request.
596 17 100       93 if (@$data) {
597             # The request object expects "bytes", not strings
598 11 100       66 map { utf8::encode($_) unless ref($_)} @$data;
  46         248  
599              
600 11         125 $req = POST($self->_uri($uri), $data, Content_Type => 'form-data');
601             }
602             else {
603 6         74 $req = GET($self->_uri($uri));
604             }
605             #$session->add_cookie_header($req);
606 17 50       93698 if ($self->_cookie) {
607 0         0 $self->_cookie->add_cookie_header($req);
608             }
609              
610             # Then we send the request and parse the response.
611 17         113 $self->logger->debug('request: ', $req->as_string);
612 17         124 my $res = $self->_ua->request($req);
613 17         20780914 $self->logger->debug('response: ', $res->as_string);
614              
615 17 100 33     240 if ($res->is_success) {
    100 66        
    100 66        
616             # The content of the response we get from the RT server consists
617             # of an HTTP-like status line followed by optional header lines,
618             # a blank line, and arbitrary text.
619              
620 8         326 my ($head, $text) = split /\n\n/, $res->decoded_content(charset => 'none'), 2;
621 8         2022 my ($status) = split /\n/, $head; # my ($status, @headers) = split /\n/, $head;
622              
623             # Example:
624             # "RT/3.0.1 401 Credentials required"
625 8 100       161 if ($status !~ m#^RT/\d+(?:\S+) (\d+) ([\w\s]+)$#) {
626 1         22 my $err_msg = 'Malformed RT response received from ' . $self->server;
627 1 50       11 if ($self->verbose_errors) {
628 1   50     17 $err_msg = "Malformed RT response received from " . $self->_uri($uri) .
629             " with this response: " . substr($text || '', 0, 200) . '....';
630             }
631 1         71 RT::Client::REST::MalformedRTResponseException->throw($err_msg);
632             }
633              
634             # Our caller can pretend that the server returned a custom HTTP
635             # response code and message. (Doing that directly is apparently
636             # not sufficiently portable and uncomplicated.)
637 7         54 $res->code($1);
638 7         146 $res->message($2);
639 7         94 $res->content($text);
640             #$session->update($res) if ($res->is_success || $res->code != 401);
641 7 50       221 if ($res->header('set-cookie')) {
642 0         0 my $jar = HTTP::Cookies->new;
643 0         0 $jar->extract_cookies($res);
644 0         0 $self->_cookie($jar);
645             }
646              
647 7 50       565 if (!$res->is_success) {
648             # We can deal with authentication failures ourselves. Either
649             # we sent invalid credentials, or our session has expired.
650 0 0       0 if ($res->code == 401) {
651 0         0 my %d = @$data;
652 0 0       0 if (exists $d{user}) {
    0          
653 0         0 RT::Client::REST::AuthenticationFailureException->throw(
654             code => $res->code,
655             message => 'Incorrect username or password',
656             );
657             }
658             elsif ($req->header('Cookie')) {
659             # We'll retry the request with credentials, unless
660             # we only wanted to logout in the first place.
661             #$session->delete;
662             #return submit(@_) unless $uri eq "$REST/logout";
663             }
664             else {
665 0         0 RT::Client::REST::AuthenticationFailureException->throw(
666             code => $res->code,
667             message => 'Server said: '. $res->message,
668             );
669             }
670             }
671             else {
672 0         0 RT::Client::REST::Exception->_rt_content_to_exception(
673             $res->decoded_content)
674             ->throw(
675             code => $res->code,
676             message => 'RT server returned this error: ' .
677             $res->decoded_content,
678             );
679             }
680             }
681             } elsif (
682             500 == $res->code &&
683             # Older versions of HTTP::Response populate 'message', newer
684             # versions populate 'content'. This catches both cases.
685             ($res->decoded_content || $res->message) =~ m/read timeout/
686             ) {
687 5         6448 RT::Client::REST::RequestTimedOutException->throw(
688             'Your request to ' . $self->server . ' timed out',
689             );
690             } elsif (302 == $res->code && !$self->{'_redirected'}) {
691 2         122 $self->{'_redirected'} = 1; # We only allow one redirection
692             # Figure out the new value of 'server'. We assume that the /REST/..
693             # part of the URI stays the same.
694 2         14 my $new_location = $res->header('Location');
695 2         114 $self->logger->info("We're being redirected to $new_location");
696 2         12 my $orig_server = $self->server;
697 2         60 (my $suffix = $self->_uri($uri)) =~ s/^\Q$orig_server//;
698 2         26 (my $new_server = $new_location) =~ s/\Q$suffix\E$//;
699 2         8 $self->server($new_server);
700 2         46 return $self->_submit($uri, $content, $auth);
701             } else {
702 2         62 my $err_msg = $res->message;
703 2 50       24 if ($self->verbose_errors) {
704 2         6 $err_msg = $res->message . ' fetching ' . $self->_uri($uri);
705             };
706 2         8 RT::Client::REST::HTTPException->throw(
707             code => $res->code,
708             message => $err_msg,
709             );
710             }
711              
712 7         116 return $res;
713             }
714              
715             sub _ua {
716 21     21   85 my $self = shift;
717              
718 21 100       120 unless (exists($self->{_ua})) {
719              
720 11   100     52 my $args = $self->user_agent_args || {};
721 11 50       76 die "user_agent_args must be a hashref" unless ref($args) eq 'HASH';
722 11         105 $self->{_ua} = RT::Client::REST::HTTPClient->new(
723             agent => $self->_ua_string,
724             env_proxy => 1,
725             max_redirect => 1,
726             %$args,
727             );
728 11 50       162648 if ($self->timeout) {
729 11         134 $self->{_ua}->timeout($self->timeout);
730             }
731 11 100       409 if ($self->basic_auth_cb) {
732 3         10 $self->{_ua}->basic_auth_cb($self->basic_auth_cb);
733             }
734             }
735              
736 21         325 return $self->{_ua};
737             }
738              
739             sub user_agent {
740 4     4 1 5186 shift->_ua;
741             }
742              
743              
744             sub basic_auth_cb {
745 48     48 1 3592 my $self = shift;
746              
747 48 100       276 if (@_) {
748 6         21 my $sub = shift;
749 6 100       90 unless ('CODE' eq ref($sub)) {
750 2         6 RT::Client::REST::InvalidParameterValueException->throw(
751             "'basic_auth_cb' must be a code reference",
752             );
753             }
754 4         38 $self->{_basic_auth_cb} = $sub;
755             }
756              
757 46         307 return $self->{_basic_auth_cb};
758             }
759              
760             # Sometimes PodCoverageTests think LOGGER_METHODS is a vanilla sub
761              
762 21     21   207 use constant LOGGER_METHODS => (qw(debug warn info error));
  21         60  
  21         22293  
763              
764             sub logger {
765 73     73 1 415 my $self = shift;
766 73 100       354 if (@_) {
767 2         3 my $new_logger = shift;
768 2         6 for my $method (LOGGER_METHODS) {
769 6 100       34 unless ($new_logger->can($method)) {
770 1         9 RT::Client::REST::InvalidParameterValueException->throw(
771             "logger does not know how to `$method'",
772             );
773             }
774             }
775 1         4 $self->{'_logger'} = $new_logger;
776             }
777 72         1903 return $self->{'_logger'};
778             }
779              
780              
781             # Not a constant so that it can be overridden.
782             sub _list_of_valid_transaction_types {
783 0     0   0 sort +(qw(
784             Create Set Status Correspond Comment Give Steal Take Told
785             CustomField AddLink DeleteLink AddWatcher DelWatcher EmailRecord
786             ));
787             }
788              
789             sub _valid_type {
790 12     12   47 my ($self, $type) = @_;
791              
792 12 50       192 unless ($type =~ /^[A-Za-z0-9_.-]+$/) {
793 0         0 RT::Client::REST::InvaildObjectTypeException->throw(
794             "'$type' is not a valid object type",
795             );
796             }
797              
798 12         45 return $type;
799             }
800              
801             sub _valid_objects {
802 0     0   0 my ($self, $objects) = @_;
803              
804 0 0       0 unless ('ARRAY' eq ref($objects)) {
805 0         0 RT::Client::REST::InvalidParameterValueException->throw(
806             "'objects' must be an array reference",
807             );
808             }
809              
810 0         0 return $objects;
811             }
812              
813             sub _valid_numeric_object_id {
814 20     20   56 my ($self, $id) = @_;
815              
816 20 50       155 unless ($id =~ m/^\d+$/) {
817 0         0 RT::Client::REST::InvalidParameterValueException->throw(
818             "'$id' is not a valid numeric object ID",
819             );
820             }
821              
822 20         70 return $id;
823             }
824              
825             sub _valid_comment_action {
826 4     4   9 my ($self, $action) = @_;
827              
828 4 50       9 unless (grep { $_ eq lc($action) } (qw(comment correspond))) {
  8         26  
829 0         0 RT::Client::REST::InvalidParameterValueException->throw(
830             "'$action' is not a valid comment action",
831             );
832             }
833              
834 4         10 return lc($action);
835             }
836              
837             sub _valid_comment_message {
838 4     4   9 my ($self, $message) = @_;
839              
840 4 50 33     49 unless (defined($message) and length($message)) {
841 0         0 RT::Client::REST::InvalidParameterValueException->throw(
842             "Comment cannot be empty (specify 'message' parameter)",
843             );
844             }
845              
846 4         9 return $message;
847             }
848              
849             sub _valid_link_type {
850 0     0   0 my ($self, $type) = @_;
851 0         0 my @types = qw(DependsOn DependedOnBy RefersTo ReferredToBy HasMember Members
852             MemberOf RunsOn IsRunning ComponentOf HasComponent);
853              
854 0 0       0 unless (grep { lc($type) eq lc($_) } @types) {
  0         0  
855 0         0 RT::Client::REST::InvalidParameterValueException->throw(
856             "'$type' is not a valid link type",
857             );
858             }
859              
860 0         0 return lc($type);
861             }
862              
863             sub _valid_transaction_type {
864 0     0   0 my ($self, $type) = @_;
865              
866 0 0       0 unless (grep { $type eq $_ } $self->_list_of_valid_transaction_types) {
  0         0  
867 0         0 RT::Client::REST::InvalidParameterValueException->throw(
868             "'$type' is not a valid transaction type. Allowed types: " .
869             join(', ', $self->_list_of_valid_transaction_types)
870             );
871             }
872              
873 0         0 return $type;
874             }
875              
876             sub _assert_even {
877 49     49   193 shift;
878 49 50       362 RT::Client::REST::OddNumberOfArgumentsException->throw(
879             "odd number of arguments passed") if @_ & 1;
880             }
881              
882             sub _rest {
883 24     24   81 my $self = shift;
884 24         287 my $server = $self->server;
885              
886 24 50       124 unless (defined($server)) {
887 0         0 RT::Client::REST::RequiredAttributeUnsetException->throw(
888             "'server' attribute is not set",
889             );
890             }
891              
892 24         500 return $server . '/REST/1.0';
893             }
894              
895 22     22   138 sub _uri { shift->_rest . '/' . shift }
896              
897             sub _ua_string {
898 11     11   240 my $self = shift;
899 11   50     62 return ref($self) . '/' . ($self->_version || '???');
900             }
901              
902 11     11   800 sub _version { $RT::Client::REST::VERSION }
903              
904             {
905             # This is a noop logger: it discards all log messages. It is the default
906             # logger. I think this approach is better than doing either checks all
907             # over the place like this:
908             #
909             # if ($self->logger) {
910             # $self->logger->warn("message");
911             # }
912             #
913             # or creating our own logging methods which will hide the checks:
914             #
915             # sub warn {
916             # my $self = shift;
917             # if ($self->logger) {
918             # $self->logger->warn(@_);
919             # }
920             # }
921             # # and later:
922             # sub xyz {
923             # ...
924             # $self->warn("message");
925             # }
926             #
927             # The problem with the second approach is that it creates unrelated
928             # methods in RT::Client::REST namespace.
929             package RT::Client::REST::NoopLogger;
930             $RT::Client::REST::NoopLogger::VERSION = '0.71';
931 25     25   703 sub new { bless \(my $logger), __PACKAGE__ }
932             for my $method (RT::Client::REST::LOGGER_METHODS) {
933 21     21   210 no strict 'refs'; ## no critic (ProhibitNoStrict)
  21         81  
  21         1809  
934       71     *{$method} = sub {};
935             }
936             }
937              
938             1;
939              
940             __END__
941              
942             =pod
943              
944             =encoding UTF-8
945              
946             =head1 NAME
947              
948             RT::Client::REST - Client for RT using REST API
949              
950             =head1 VERSION
951              
952             version 0.71
953              
954             =head1 SYNOPSIS
955              
956             use Try::Tiny;
957             use RT::Client::REST;
958              
959             my $rt = RT::Client::REST->new(
960             server => 'http://example.com/rt',
961             timeout => 30,
962             );
963              
964             try {
965             $rt->login(username => $user, password => $pass);
966             }
967             catch {
968             if ($_->isa('Exception::Class::Base') {
969             die "problem logging in: ", shift->message;
970             }
971             };
972              
973             try {
974             # Get ticket #10
975             $ticket = $rt->show(type => 'ticket', id => 10);
976             }
977             catch {
978             if ($_->isa('RT::Client::REST::UnauthorizedActionException')) {
979             print "You are not authorized to view ticket #10\n";
980             }
981             if ($_->isa('RT::Client::REST::Exception')) {
982             # something went wrong.
983             }
984             };
985              
986             =head1 DESCRIPTION
987              
988             B<RT::Client::REST> is B</usr/bin/rt> converted to a Perl module. I needed
989             to implement some RT interactions from my application, but did not feel that
990             invoking a shell command is appropriate. Thus, I took B<rt> tool, written
991             by Abhijit Menon-Sen, and converted it to an object-oriented Perl module.
992              
993             =for Pod::Coverage LOGGER_METHODS
994              
995             =head1 USAGE NOTES
996              
997             This API mimics that of 'rt'. For a more OO-style APIs, please use
998             L<RT::Client::REST::Object>-derived classes:
999             L<RT::Client::REST::Ticket> and L<RT::Client::REST::User>.
1000             not implemented yet).
1001              
1002             =head1 METHODS
1003              
1004             =over
1005              
1006             =item new ()
1007              
1008             The constructor can take these options (note that these can also
1009             be called as their own methods):
1010              
1011             =over 2
1012              
1013             =item B<server>
1014              
1015             B<server> is a URI pointing to your RT installation.
1016              
1017             If you have already authenticated against RT in some other
1018             part of your program, you can use B<_cookie> parameter to supply an object
1019             of type B<HTTP::Cookies> to use for credentials information.
1020              
1021             =item B<timeout>
1022              
1023             B<timeout> is the number of seconds HTTP client will wait for the
1024             server to respond. Defaults to LWP::UserAgent's default timeout, which
1025             is 180 seconds (please check LWP::UserAgent's documentation for accurate
1026             timeout information).
1027              
1028             =item B<basic_auth_cb>
1029              
1030             This callback is to provide the HTTP client (based on L<LWP::UserAgent>)
1031             with username and password for basic authentication. It takes the
1032             same arguments as C<get_basic_credentials()> of LWP::UserAgent and
1033             returns username and password:
1034              
1035             $rt->basic_auth_cb( sub {
1036             my ($realm, $uri, $proxy) = @_;
1037             # do some evil things
1038             return ($username, $password);
1039             }
1040              
1041             =item B<user_agent_args>
1042              
1043             A hashref which will be passed to the user agent's constructor for
1044             maximum flexibility.
1045              
1046             =item B<user_agent>
1047              
1048             Accessor to the user_agent object.
1049              
1050             =item B<logger>
1051              
1052             A logger object. It should be able to debug(), info(), warn() and
1053             error(). It is not widely used in the code (yet), and so it is
1054             mostly useful for development.
1055              
1056             Something like this will get you started:
1057              
1058             use Log::Dispatch;
1059             my $log = Log::Dispatch->new(
1060             outputs => [ [ 'Screen', min_level => 'debug' ] ],
1061             );
1062             my $rt = RT::Client::REST->new(
1063             server => ... etc ...
1064             logger => $log
1065             );
1066              
1067             =item B<verbose_errors>
1068              
1069             On user-agent errors, report some more information about what is going
1070             wrong. Defaults are pretty laconic about the "Malformed RT response".
1071              
1072             =back
1073              
1074             =item login (username => 'root', password => 'password')
1075             =item login (my_userfield => 'root', my_passfield => 'password')
1076              
1077             Log in to RT. Throws an exception on error.
1078              
1079             Usually, if the other side uses basic HTTP authentication, you do not
1080             have to log in, but rather provide HTTP username and password instead.
1081             See B<basic_auth_cb> above.
1082              
1083             =item show (type => $type, id => $id)
1084              
1085             Return a reference to a hash with key-value pair specifying object C<$id>
1086             of type C<$type>. The keys are the names of RT's fields. Keys for custom
1087             fields are in the form of "CF.{CUST_FIELD_NAME}".
1088              
1089             =item edit (type => $type, id => $id, set => { status => 1 })
1090              
1091             Set fields specified in parameter B<set> in object C<$id> of type
1092             C<$type>.
1093              
1094             =item create (type => $type, set => \%params, text => $text)
1095              
1096             Create a new object of type B<$type> and set initial parameters to B<%params>.
1097             For a ticket object, 'text' parameter can be supplied to set the initial
1098             text of the ticket.
1099             Returns numeric ID of the new object. If numeric ID cannot be parsed from
1100             the response, B<RT::Client::REST::MalformedRTResponseException> is thrown.
1101              
1102             =item search (type => $type, query => $query, format => $format, %opts)
1103              
1104             Search for object of type C<$type> by using query C<$query>. For
1105             example:
1106              
1107             # Find all stalled tickets
1108             my @ids = $rt->search(
1109             type => 'ticket',
1110             query => "Status = 'stalled'",
1111             );
1112              
1113             C<%opts> is a list of key-value pairs:
1114              
1115             =for stopwords orderby
1116              
1117             =over 4
1118              
1119             =item B<orderby>
1120              
1121             The value is the name of the field you want to sort by. Plus or minus
1122             sign in front of it signifies ascending order (plus) or descending
1123             order (minus). For example:
1124              
1125             # Get all stalled tickets in reverse order:
1126             my @ids = $rt->search(
1127             type => 'ticket',
1128             query => "Status = 'stalled'",
1129             orderby => '-id',
1130             );
1131              
1132             =back
1133              
1134             By default, C<search> returns the list of numeric IDs of objects that matched
1135             your query. You can then use these to retrieve object information
1136             using C<show()> method:
1137              
1138             my @ids = $rt->search(
1139             type => 'ticket',
1140             query => "Status = 'stalled'",
1141             );
1142             for my $id (@ids) {
1143             my ($ticket) = $rt->show(type => 'ticket', id => $id);
1144             say "Subject: ", $ticket->{Subject}
1145             }
1146              
1147             C<search> can return a list of lists of ID and Subject when asked for format 's'.
1148              
1149             my @results = $rt->search(
1150             type => 'ticket',
1151             query => "Status = 'stalled'",
1152             format => 's',
1153             );
1154             for my $result (@results) {
1155             say "ID: $result[0], Subject: $result[1]"
1156             }
1157              
1158             =item comment (ticket_id => $id, message => $message, %opts)
1159              
1160             =for stopwords bcc
1161              
1162             Comment on a ticket with ID B<$id>.
1163              
1164             Optionally takes arguments:
1165              
1166             =over 2
1167              
1168             =item B<cc> and B<bcc>
1169              
1170             References to lists of e-mail addresses
1171              
1172             =item B<attachments>
1173              
1174             A list of filenames to be attached to the ticket
1175              
1176             =for stopwords html
1177              
1178             =item B<html>
1179              
1180             When true, indicates to RT that the message is html
1181              
1182             =back
1183              
1184             $rt->comment(
1185             ticket_id => 5,
1186             message => "Wild thing, you make my heart sing",
1187             cc => [qw(dmitri@localhost some@otherdude.com)],
1188             );
1189              
1190             $rt->comment(
1191             ticket_id => 5,
1192             message => "<b>Wild thing</b>, you make my <i>heart sing</i>",
1193             html => 1
1194             );
1195              
1196             =item correspond (ticket_id => $id, message => $message, %opts)
1197              
1198             Add correspondence to ticket ID B<$id>. Takes optional B<cc>,
1199             B<bcc>, and B<attachments> parameters (see C<comment> above).
1200              
1201             =item get_attachment_ids (id => $id)
1202              
1203             Get a list of numeric attachment IDs associated with ticket C<$id>.
1204              
1205             =for stopwords undecoded
1206              
1207             =item get_attachments_metadata (id => $id)
1208              
1209             Get a list of the metadata related to every attachment of the ticket <$id>
1210             Every member of the list is a hashref with the shape:
1211              
1212             {
1213             id => $attachment_id,
1214             Filename => $attachment_filename,
1215             Type => $attachment_type,
1216             Size => $attachment_size,
1217             }
1218              
1219             =item get_attachment (parent_id => $parent_id, id => $id, undecoded => $bool)
1220              
1221             Returns reference to a hash with key-value pair describing attachment
1222             C<$id> of ticket C<$parent_id>. (parent_id because -- who knows? --
1223             maybe attachments won't be just for tickets anymore in the future).
1224              
1225             If the option undecoded is set to a true value, the attachment will be
1226             returned verbatim and undecoded (this is probably what you want with
1227             images and binary data).
1228              
1229             =item get_links (type =E<gt> $type, id =E<gt> $id)
1230              
1231             Get link information for object of type $type whose id is $id.
1232             If type is not specified, 'ticket' is used.
1233              
1234             =item get_transaction_ids (parent_id => $id, %opts)
1235              
1236             Get a list of numeric IDs associated with parent ID C<$id>. C<%opts>
1237             have the following options:
1238              
1239             =over 2
1240              
1241             =item B<type>
1242              
1243             Type of the object transactions are associated with. Defaults to "ticket"
1244             (I do not think server-side supports anything else). This is designed with
1245             the eye on the future, as transactions are not just for tickets, but for
1246             other objects as well.
1247              
1248             =item B<transaction_type>
1249              
1250             If not specified, IDs of all transactions are returned. If set to a
1251             scalar, only transactions of that type are returned. If you want to specify
1252             more than one type, pass an array reference.
1253              
1254             Transactions may be of the following types (case-sensitive):
1255              
1256             =for stopwords AddLink AddWatcher CustomField DelWatcher DeleteLink DependedOnBy DependsOn EmailRecord HasMember MemberOf ReferredToBy RefersTo
1257              
1258             =over 2
1259              
1260             =item AddLink
1261              
1262             =item AddWatcher
1263              
1264             =item Comment
1265              
1266             =item Correspond
1267              
1268             =item Create
1269              
1270             =item CustomField
1271              
1272             =item DeleteLink
1273              
1274             =item DelWatcher
1275              
1276             =item EmailRecord
1277              
1278             =item Give
1279              
1280             =item Set
1281              
1282             =item Status
1283              
1284             =item Steal
1285              
1286             =item Take
1287              
1288             =item Told
1289              
1290             =back
1291              
1292             =back
1293              
1294             =item get_transaction (parent_id => $id, id => $id, %opts)
1295              
1296             Get a hashref representation of transaction C<$id> associated with
1297             parent object C<$id>. You can optionally specify parent object type in
1298             C<%opts> (defaults to 'ticket').
1299              
1300             =for stopwords dst src
1301              
1302             =item merge_tickets (src => $id1, dst => $id2)
1303              
1304             Merge ticket B<$id1> into ticket B<$id2>.
1305              
1306             =item link_tickets (src => $id1, dst => $id2, link_type => $type)
1307              
1308             Create a link between two tickets. A link type can be one of the following:
1309              
1310             =over 2
1311              
1312             =item
1313              
1314             DependsOn
1315              
1316             =item
1317              
1318             DependedOnBy
1319              
1320             =item
1321              
1322             RefersTo
1323              
1324             =item
1325              
1326             ReferredToBy
1327              
1328             =item
1329              
1330             HasMember
1331              
1332             =item
1333              
1334             MemberOf
1335              
1336             =back
1337              
1338             =item unlink_tickets (src => $id1, dst => $id2, link_type => $type)
1339              
1340             Remove a link between two tickets (see B<link_tickets()>)
1341              
1342             =item take (id => $id)
1343              
1344             Take ticket C<$id>.
1345             This will throw C<RT::Client::REST::AlreadyTicketOwnerException> if you are
1346             already the ticket owner.
1347              
1348             =for stopwords Untake untake
1349              
1350             =item untake (id => $id)
1351              
1352             Untake ticket C<$id>.
1353             This will throw C<RT::Client::REST::AlreadyTicketOwnerException> if Nobody
1354             is already the ticket owner.
1355              
1356             =item steal (id => $id)
1357              
1358             Steal ticket C<$id>.
1359             This will throw C<RT::Client::REST::AlreadyTicketOwnerException> if you are
1360             already the ticket owner.
1361              
1362             =back
1363              
1364             =head1 EXCEPTIONS
1365              
1366             When an error occurs, this module will throw exceptions. I recommend
1367             using L<Try::Tiny> or L<Syntax::Keyword::Try> B<try{}> mechanism to catch them,
1368             but you may also use simple B<eval{}>.
1369              
1370             Please see L<RT::Client::REST::Exception> for the full listing and
1371             description of all the exceptions.
1372              
1373             =head1 LIMITATIONS
1374              
1375             Beginning with version 0.14, methods C<edit()> and C<show()> only support
1376             operating on a single object. This is a conscious departure from semantics
1377             offered by the original tool, as I would like to have a precise behavior
1378             for exceptions. If you want to operate on a whole bunch of objects, please
1379             use a loop.
1380              
1381             =head1 DEPENDENCIES
1382              
1383             The following modules are required:
1384              
1385             =over 2
1386              
1387             =item
1388              
1389             Exception::Class
1390              
1391             =item
1392              
1393             LWP
1394              
1395             =item
1396              
1397             HTTP::Cookies
1398              
1399             =item
1400              
1401             HTTP::Request::Common
1402              
1403             =back
1404              
1405             =head1 SEE ALSO
1406              
1407             L<LWP::UserAgent>,
1408             L<RT::Client::REST::Exception>
1409              
1410             =head1 BUGS
1411              
1412             Most likely. Please report.
1413              
1414             =head1 VARIOUS NOTES
1415              
1416             =for stopwords TODO
1417              
1418             B<RT::Client::REST> does not (at the moment, see TODO file) retrieve forms from
1419             RT server, which is either good or bad, depending how you look at it.
1420              
1421             =head1 AUTHOR
1422              
1423             Dean Hamstead <dean@fragfest.com.au>
1424              
1425             =head1 COPYRIGHT AND LICENSE
1426              
1427             This software is copyright (c) 2022, 2020 by Dmitri Tikhonov.
1428              
1429             This is free software; you can redistribute it and/or modify it under
1430             the same terms as the Perl 5 programming language system itself.
1431              
1432             =head1 CONTRIBUTORS
1433              
1434             =for stopwords Abhijit Menon-Sen belg4mit bobtfish Byron Ellacott Dean Hamstead DJ Stauffer dkrotkine Dmitri Tikhonov Marco Pessotto pplusdomain Sarvesh D Søren Lund Tom Harrison
1435              
1436             =over 4
1437              
1438             =item *
1439              
1440             Abhijit Menon-Sen <ams@wiw.org>
1441              
1442             =item *
1443              
1444             belg4mit <belg4mit>
1445              
1446             =item *
1447              
1448             bobtfish <bobtfish@bobtfish.net>
1449              
1450             =item *
1451              
1452             Byron Ellacott <code@bje.id.au>
1453              
1454             =item *
1455              
1456             Dean Hamstead <djzort@cpan.org>
1457              
1458             =item *
1459              
1460             DJ Stauffer <dj@djstauffer.com>
1461              
1462             =item *
1463              
1464             dkrotkine <dkrotkine@gmail.com>
1465              
1466             =item *
1467              
1468             Dmitri Tikhonov <dmitri@cpan.org>
1469              
1470             =item *
1471              
1472             Marco Pessotto <melmothx@gmail.com>
1473              
1474             =item *
1475              
1476             pplusdomain <pplusdomain@gmail.com>
1477              
1478             =item *
1479              
1480             Sarvesh D <sarveshd@openmailbox.org>
1481              
1482             =item *
1483              
1484             Søren Lund <soren@lund.org>
1485              
1486             =item *
1487              
1488             Tom Harrison <tomh@apnic.net>
1489              
1490             =back
1491              
1492             =cut