File Coverage

blib/lib/JIRA/REST.pm
Criterion Covered Total %
statement 32 210 15.2
branch 0 108 0.0
condition 0 60 0.0
subroutine 11 29 37.9
pod 9 9 100.0
total 52 416 12.5


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