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