File Coverage

blib/lib/JIRA/REST.pm
Criterion Covered Total %
statement 32 200 16.0
branch 0 100 0.0
condition 0 48 0.0
subroutine 11 27 40.7
pod 8 8 100.0
total 51 383 13.3


line stmt bran cond sub pod time code
1             package JIRA::REST;
2             # ABSTRACT: Thin wrapper around Jira's REST API
3             $JIRA::REST::VERSION = '0.021';
4 2     2   152908 use 5.016;
  2         19  
5 2     2   1359 use utf8;
  2         30  
  2         10  
6 2     2   67 use warnings;
  2         4  
  2         62  
7              
8 2     2   13 use Carp;
  2         3  
  2         131  
9 2     2   1135 use URI;
  2         14987  
  2         75  
10 2     2   1311 use Encode;
  2         33566  
  2         207  
11 2     2   1091 use MIME::Base64;
  2         1349  
  2         118  
12 2     2   16 use URI::Escape;
  2         3  
  2         107  
13 2     2   1395 use JSON 2.23;
  2         23147  
  2         13  
14 2     2   1479 use REST::Client;
  2         89475  
  2         105  
15 2     2   1235 use HTTP::CookieJar::LWP;
  2         59334  
  2         5700  
16              
17             sub new {
18 0     0 1   my $class = shift; # this always has to come first!
19              
20             # Valid option names in the order expected by the old-form constructor
21 0           my @opts = qw/url username password rest_client_config proxy ssl_verify_none anonymous/;
22              
23 0           my %args;
24              
25 0 0 0       if (@_ == 1 && ref $_[0] && ref $_[0] eq 'HASH') {
      0        
26             # The new-form constructor expects a single hash reference.
27 0           @args{@opts} = delete @{$_[0]}{@opts};
  0            
28 0           croak __PACKAGE__ . "::new: unknown arguments: '", join("', '", sort keys %{$_[0]}), "'.\n"
29 0 0         if keys %{$_[0]};
  0            
30             } else {
31             # The old-form constructor expects a list of positional parameters.
32 0           @args{@opts} = @_;
33             }
34              
35             # Turn the url into a URI object
36 0 0         if (! $args{url}) {
    0          
    0          
37 0           croak __PACKAGE__ . "::new: 'url' argument must be defined.\n";
38             } elsif (! ref $args{url}) {
39 0           $args{url} = URI->new($args{url});
40             } elsif (! $args{url}->isa('URI')) {
41 0           croak __PACKAGE__ . "::new: 'url' argument must be a URI object.\n";
42             }
43              
44 0           my ($path, $api) = ($args{url}->path, '/rest/api/latest');
45             # See if the user wants a default REST API:
46 0 0         if ($path =~ s:(/rest/.*)$::) {
47 0           $api = $1;
48 0           $args{url}->path($path);
49             }
50             # Strip trailing slashes from $path. For some reason they cause 404
51             # errors down the road.
52 0 0         if ($path =~ s:/+$::) {
53 0           $args{url}->path($path);
54             }
55              
56 0 0         unless ($args{anonymous}) {
57             # If username and password are not set we try to lookup the credentials
58 0 0 0       if (! defined $args{username} || ! defined $args{password}) {
59             ($args{username}, $args{password}) =
60 0           _search_for_credentials($args{url}, $args{username});
61             }
62              
63 0           foreach (qw/username password/) {
64             croak __PACKAGE__ . "::new: '$_' argument must be a non-empty string.\n"
65 0 0 0       if ! defined $args{$_} || ref $args{$_} || length $args{$_} == 0;
      0        
66             }
67             }
68              
69 0           for ($args{rest_client_config}) {
70 0   0       $_ //= {};
71 0 0 0       croak __PACKAGE__ . "::new: 'rest_client_config' argument must be a hash reference.\n"
      0        
72             unless defined && ref && ref eq 'HASH';
73             }
74              
75             # remove the REST::Client faux config 'proxy' if set and use it later.
76             # This is deprecated since v0.017
77 0 0         if (my $proxy = delete $args{rest_client_config}{proxy}) {
78 0           carp __PACKAGE__ . "::new: passing 'proxy' in the 'rest_client_config' hash is deprecated. Please, use the corresponding argument instead.\n";
79 0   0       $args{proxy} //= $proxy;
80             }
81              
82 0           my $rest = REST::Client->new($args{rest_client_config});
83              
84             # Set default base URL
85 0           $rest->setHost($args{url});
86              
87             # Follow redirects/authentication by default
88 0           $rest->setFollow(1);
89              
90             # Since Jira doesn't send an authentication challenge, we force the
91             # sending of the authentication header.
92             $rest->addHeader(Authorization => 'Basic ' . encode_base64("$args{username}:$args{password}", ''))
93 0 0         unless $args{anonymous};
94              
95 0           for my $ua ($rest->getUseragent) {
96             # Configure UserAgent name
97 0           $ua->agent(__PACKAGE__);
98              
99             # Set proxy to be used
100 0 0         $ua->proxy(['http','https'] => $args{proxy}) if $args{proxy};
101              
102             # Turn off SSL verification if requested
103 0 0         $ua->ssl_opts(SSL_verify_mode => 0, verify_hostname => 0) if $args{ssl_verify_none};
104              
105             # Configure a cookie_jar so that we can send Cookie headers
106 0           $ua->cookie_jar(HTTP::CookieJar::LWP->new());
107             }
108              
109 0           return bless {
110             rest => $rest,
111             json => JSON->new->utf8->allow_nonref,
112             api => $api,
113             args => \%args,
114             } => $class;
115             }
116              
117             sub new_session {
118 0     0 1   my ($class, @args) = @_;
119 0           my $jira = $class->new(@args);
120             $jira->{_session} = $jira->POST('/rest/auth/1/session', undef, {
121             username => $jira->{args}{username},
122             password => $jira->{args}{password},
123 0           });
124 0           return $jira;
125             }
126              
127             sub DESTROY {
128 0     0     my $self = shift;
129 0 0         $self->DELETE('/rest/auth/1/session') if exists $self->{_session};
130 0           return;
131             }
132              
133             sub _search_for_credentials {
134 0     0     my ($URL, $username) = @_;
135 0           my (@errors, $password);
136              
137             # Try .netrc first
138 0           ($username, $password) = eval { _user_pass_from_netrc($URL, $username) };
  0            
139 0 0         push @errors, "Net::Netrc: $@" if $@;
140 0 0 0       return ($username, $password) if defined $username && defined $password;
141              
142             # Fallback to Config::Identity
143 0   0       my $stub = $ENV{JIRA_REST_IDENTITY} || "jira";
144 0           ($username, $password) = eval { _user_pass_from_config_identity($stub) };
  0            
145 0 0         push @errors, "Config::Identity: $@" if $@;
146 0 0 0       return ($username, $password) if defined $username && defined $password;
147              
148             # Still not defined, so we report errors
149 0           for (@errors) {
150 0           chomp;
151 0           s/\n//g;
152 0           s/ at \S+ line \d+.*//;
153             }
154             croak __PACKAGE__ . "::new: Could not locate credentials. Tried these modules:\n"
155 0           . join("", map { "* $_\n" } @errors)
  0            
156             . "Please specify the USERNAME and PASSWORD as arguments to new";
157             }
158              
159             sub _user_pass_from_config_identity {
160 0     0     my ($stub) = @_;
161 0           my ($username, $password);
162 0 0         eval {require Config::Identity; Config::Identity->VERSION(0.0019) }
  0            
  0            
163             or croak "Can't load Config::Identity 0.0019 or later.\n";
164 0           my %id = Config::Identity->load_check( $stub, [qw/username password/] );
165 0           return ($id{username}, $id{password});
166             }
167              
168             sub _user_pass_from_netrc {
169 0     0     my ($URL, $username) = @_;
170 0           my $password;
171 0 0         eval {require Net::Netrc; 1}
  0            
  0            
172             or croak "Can't require Net::Netrc module.";
173 0 0         if (my $machine = Net::Netrc->lookup($URL->host, $username)) { # $username may be undef
174 0           $username = $machine->login;
175 0           $password = $machine->password;
176             } else {
177 0           croak "No credentials found in the .netrc file.\n";
178             }
179 0           return ($username, $password);
180             }
181              
182             sub _error {
183 0     0     my ($self, $content, $type, $code) = @_;
184              
185 0 0         $type = 'text/plain' unless $type;
186 0 0         $code = 500 unless $code;
187              
188 0           my $msg = __PACKAGE__ . " Error[$code";
189              
190 0 0         if (eval {require HTTP::Status}) {
  0            
191 0 0         if (my $status = HTTP::Status::status_message($code)) {
192 0           $msg .= " - $status";
193             }
194             }
195              
196 0           $msg .= "]:\n";
197              
198 0 0 0       if ($type =~ m:text/plain:i) {
    0          
    0          
    0          
199 0           $msg .= $content;
200             } elsif ($type =~ m:application/json:) {
201 0           my $error = $self->{json}->decode($content);
202 0 0         if (ref $error eq 'HASH') {
203             # Jira errors may be laid out in all sorts of ways. You have to
204             # look them up from the scant documentation at
205             # https://docs.atlassian.com/jira/REST/latest/.
206              
207             # /issue/bulk tucks the errors one level down, inside the
208             # 'elementErrors' hash.
209 0 0         $error = $error->{elementErrors} if exists $error->{elementErrors};
210              
211             # Some methods tuck the errors in the 'errorMessages' array.
212 0 0         if (my $errorMessages = $error->{errorMessages}) {
213 0           $msg .= "- $_\n" foreach @$errorMessages;
214             }
215              
216             # And some tuck them in the 'errors' hash.
217 0 0         if (my $errors = $error->{errors}) {
218 0           $msg .= "- [$_] $errors->{$_}\n" foreach sort keys %$errors;
219             }
220              
221             # some give us a single message in 'errorMessage'
222 0 0         $msg .= $error->{errorMessage} . qq{\n} if $error->{errorMessage};
223             } else {
224 0           $msg .= $content;
225             }
226 0           } elsif ($type =~ m:text/html:i && eval {require HTML::TreeBuilder}) {
227 0           $msg .= HTML::TreeBuilder->new_from_content($content)->as_text;
228             } elsif ($type =~ m:^(text/|application|xml):i) {
229 0           $msg .= "$content";
230             } else {
231 0           $msg .= "(binary content not shown)";
232             };
233 0           $msg =~ s/\n*$/\n/s; # end message with a single newline
234 0           return $msg;
235             }
236              
237             sub _content {
238 0     0     my ($self) = @_;
239              
240 0           my $rest = $self->{rest};
241 0           my $code = $rest->responseCode();
242 0           my $type = $rest->responseHeader('Content-Type');
243 0           my $content = $rest->responseContent();
244              
245 0 0         $code =~ /^2/
246             or croak $self->_error($content, $type, $code);
247              
248 0 0         return unless $content;
249              
250 0 0         if (! defined $type) {
    0          
    0          
251 0           croak $self->_error("Cannot convert response content with no Content-Type specified.");
252             } elsif ($type =~ m:^application/json:i) {
253 0           return $self->{json}->decode($content);
254             } elsif ($type =~ m:^text/plain:i) {
255 0           return $content;
256             } else {
257 0           croak $self->_error("I don't understand content with Content-Type '$type'.");
258             }
259             }
260              
261             sub _build_path {
262 0     0     my ($self, $path, $query) = @_;
263              
264             # Prefix $path with the default API prefix unless it already specifies
265             # one or it's an absolute URL.
266 0 0         $path = $self->{api} . $path unless $path =~ m@^(?:/rest/|(?i)https?:)@;
267              
268 0 0         if (defined $query) {
269 0 0 0       croak $self->_error("The QUERY argument must be a hash reference.")
270             unless ref $query && ref $query eq 'HASH';
271 0           return $path . '?'. join('&', map {$_ . '=' . uri_escape($query->{$_})} keys %$query);
  0            
272             } else {
273 0           return $path;
274             }
275             }
276              
277             sub GET {
278 0     0 1   my ($self, $path, $query) = @_;
279              
280 0           $self->{rest}->GET($self->_build_path($path, $query));
281              
282 0           return $self->_content();
283             }
284              
285             sub DELETE {
286 0     0     my ($self, $path, $query) = @_;
287              
288 0           $self->{rest}->DELETE($self->_build_path($path, $query));
289              
290 0           return $self->_content();
291             }
292              
293             sub PUT {
294 0     0 1   my ($self, $path, $query, $value, $headers) = @_;
295              
296 0 0         defined $value
297             or croak $self->_error("PUT method's 'value' argument is undefined.");
298              
299 0           $path = $self->_build_path($path, $query);
300              
301 0   0       $headers ||= {};
302 0   0       $headers->{'Content-Type'} //= 'application/json;charset=UTF-8';
303              
304 0           $self->{rest}->PUT($path, $self->{json}->encode($value), $headers);
305              
306 0           return $self->_content();
307             }
308              
309             sub POST {
310 0     0 1   my ($self, $path, $query, $value, $headers) = @_;
311              
312 0 0         defined $value
313             or croak $self->_error("POST method's 'value' argument is undefined.");
314              
315 0           $path = $self->_build_path($path, $query);
316              
317 0   0       $headers ||= {};
318 0   0       $headers->{'Content-Type'} //= 'application/json;charset=UTF-8';
319              
320 0           $self->{rest}->POST($path, $self->{json}->encode($value), $headers);
321              
322 0           return $self->_content();
323             }
324              
325             sub set_search_iterator {
326 0     0 1   my ($self, $params) = @_;
327              
328 0           my %params = ( %$params ); # rebuild the hash to own it
329              
330 0           $params{startAt} = 0;
331              
332             $self->{iter} = {
333 0           params => \%params, # params hash to be used in the next call
334             offset => 0, # offset of the next issue to be fetched
335             results => { # results of the last call (this one is fake)
336             startAt => 0,
337             total => -1,
338             issues => [],
339             },
340             };
341              
342 0           return;
343             }
344              
345             sub next_issue {
346 0     0 1   my ($self) = @_;
347              
348             my $iter = $self->{iter}
349 0 0         or croak $self->_error("You must call set_search_iterator before calling next_issue");
350              
351 0 0         if ($iter->{offset} == $iter->{results}{total}) {
    0          
352             # This is the end of the search results
353 0           $self->{iter} = undef;
354 0           return;
355 0           } elsif ($iter->{offset} == $iter->{results}{startAt} + @{$iter->{results}{issues}}) {
356             # Time to get the next bunch of issues
357 0           $iter->{params}{startAt} = $iter->{offset};
358 0           $iter->{results} = $self->POST('/search', undef, $iter->{params});
359             }
360              
361 0           return $iter->{results}{issues}[$iter->{offset}++ - $iter->{results}{startAt}];
362             }
363              
364             sub attach_files {
365 0     0 1   my ($self, $issueIdOrKey, @files) = @_;
366              
367             # We need to violate the REST::Client class encapsulation to implement
368             # the HTTP POST method necessary to invoke the /issue/key/attachments
369             # REST endpoint because it has to use the form-data Content-Type.
370              
371 0           my $rest = $self->{rest};
372              
373             # FIXME: How to attach all files at once?
374 0           foreach my $file (@files) {
375             my $response = $rest->getUseragent()->post(
376             $rest->getHost . "/rest/api/latest/issue/$issueIdOrKey/attachments",
377 0           %{$rest->{_headers}},
  0            
378             'X-Atlassian-Token' => 'nocheck',
379             'Content-Type' => 'form-data',
380             'Content' => [ file => [$file, encode_utf8( $file )] ],
381             );
382              
383 0 0         $response->is_success
384             or croak $self->_error("attach_files($file): " . $response->status_line);
385             }
386              
387 0           return;
388             }
389              
390             1;
391              
392             __END__