File Coverage

blib/lib/Confluence/REST.pm
Criterion Covered Total %
statement 35 151 23.1
branch 0 94 0.0
condition 0 17 0.0
subroutine 12 22 54.5
pod 6 6 100.0
total 53 290 18.2


line stmt bran cond sub pod time code
1             package Confluence::REST;
2              
3             # ABSTRACT: Thin wrapper around Confluence's REST API
4 1     1   47552 use 5.010;
  1         3  
5 1     1   329 use utf8;
  1         11  
  1         4  
6 1     1   25 use strict;
  1         2  
  1         15  
7 1     1   4 use warnings;
  1         1  
  1         19  
8 1     1   3 use Carp;
  1         2  
  1         41  
9 1     1   300 use URI;
  1         4619  
  1         25  
10 1     1   240 use MIME::Base64;
  1         498  
  1         48  
11 1     1   6 use URI::Escape;
  1         2  
  1         91  
12 1     1   385 use JSON;
  1         6647  
  1         4  
13 1     1   355 use Data::Util qw/:check/;
  1         495  
  1         272  
14 1     1   360 use REST::Client;
  1         38926  
  1         29  
15 1     1   509 use Data::Dumper;
  1         5067  
  1         1489  
16              
17             $CONFLUENCE::REST::VERSION = '0.011';
18              
19             our $DEBUG_REQUESTS_P = 0;
20             our $DEBUG_JSON_P = 0;
21             our $DEBUG_ITERATORS = 0;
22              
23             =pod
24              
25             =encoding UTF-8
26              
27             =head1 NAME
28              
29             Confluence::REST - Thin wrapper around Confluence's REST API
30              
31             =head1 VERSION
32              
33             version 0.01
34              
35             =head1 SYNOPSIS
36              
37             use Confluence::REST;
38             use Data::Dumper;
39              
40             my $confluence = Confluence::REST->new('https://confluence.example.net');
41              
42             # Set up an iterator
43             $confluence->set_search_iterator(
44             {
45             cql => 'type = "page" and space = "home"',
46             expand => 'metadata.labels',
47             }
48             );
49              
50             # Keep bumping the iterator for the next page of results
51             while ( my $result = $confluence->next_result ) {
52              
53             # Print the hashref representing the JSON response
54             print Dumper $result;
55             }
56              
57              
58             =head1 DESCRIPTION
59              
60             Confluence::REST - Thin wrapper around Confluence's REST API
61              
62             L is a proprietary
63             wiki from L.
64              
65             This module is a thin wrapper around L
66             API|https://developer.atlassian.com/confcloud/confluence-rest-api-39985291.html>,
67             which is superseding its old SOAP API. (If you want to interact with
68             the SOAP API, there's another Perl module called
69             L.
70              
71             This code is basically L with
72             some tweaks to get it to work with the Confluence REST API.
73              
74             Copyright (c) 2013 by CPqD (http://www.cpqd.com.br/).
75              
76             Copyright (c) 2016 (Changes to adapt to Confluence REST APIs) by Rich Loveland.
77              
78             This is free software; you can redistribute it and/or modify it under
79             the same terms as the Perl 5 programming language system itself.
80              
81             =head1 METHODS
82              
83             =over
84              
85             =item new(URL, USERNAME, PASSWORD [, REST_CLIENT_CONFIG])
86              
87             The constructor needs up to four arguments:
88              
89             The URL is a string or a URI object denoting the base URL of the Confluence
90             server. This is a required argument.
91              
92             You may choose a specific API version by appending the
93             C string to the URL's path. It's more common to
94             leave it unspecified, in which case the C string is
95             appended automatically to the URL.
96              
97             The USERNAME of a Confluence user can be undefined if PASSWORD is also
98             undefined. In such a case the user credentials are looked up in the
99             C<.netrc> file.
100              
101             The HTTP PASSWORD of the user can be undefined, in which case the user
102             credentials are looked up in the C<.netrc> file. (This is the password
103             the user uses to log in to Confluence's web interface.)
104              
105             The REST_CLIENT_CONFIG is a REST::Client object used to make the REST
106             invocations. This optional argument must be a hashref that can be fed
107             to the REST::Client constructor. Note that the C argument
108             overwrites any value associated with the C key in this hash.
109              
110             To use a network proxy please set the 'proxy' argument to the string
111             or URI object describing the fully qualified (including port) URL to
112             your network proxy. This is an extension to the REST::Client
113             configuration and will be removed from the hash before passing it on
114             to the REST::Client constructor.
115              
116             =cut
117              
118             sub new {
119 0     0 1   my ($class, $URL, $username, $password, $rest_client_config) = @_;
120              
121 0 0         $URL = URI->new($URL) if is_string($URL);
122 0 0         is_instance($URL, 'URI')
123             or croak __PACKAGE__
124             . "::new: URL argument must be a string or a URI object.\n";
125              
126             # Choose the latest REST API unless already specified
127 0 0         unless ($URL->path =~ m@/rest/api/(?:\d+|content)/?$@) {
128 0           $URL->path($URL->path . '/rest/api/content');
129             }
130              
131             # If no password is set we try to lookup the credentials in the .netrc file
132 0 0         if (!defined $password) {
133 0 0         eval { require Net::Netrc }
  0            
134             or croak
135             "Can't require Net::Netrc module. Please, specify the USERNAME and PASSWORD.\n";
136 0 0         if (my $machine = Net::Netrc->lookup($URL->host, $username))
137             { # $username may be undef
138 0           $username = $machine->login;
139 0           $password = $machine->password;
140             }
141             else {
142 0           croak "No credentials found in the .netrc file.\n";
143             }
144             }
145              
146 0 0         is_string($username)
147             or croak __PACKAGE__ . "::new: USERNAME argument must be a string.\n";
148              
149 0 0         is_string($password)
150             or croak __PACKAGE__ . "::new: PASSWORD argument must be a string.\n";
151              
152 0 0         $rest_client_config = {} unless defined $rest_client_config;
153 0 0         is_hash_ref($rest_client_config)
154             or croak __PACKAGE__
155             . "::new: REST_CLIENT_CONFIG argument must be a hash-ref.\n";
156              
157             # remove the REST::Client faux config value 'proxy' if set and use it
158             # ourselves.
159 0           my $proxy = delete $rest_client_config->{proxy};
160              
161 0 0         if ($proxy) {
162 0 0 0       is_string($proxy) || is_instance($proxy, 'URI')
163             or croak __PACKAGE__
164             . "::new: 'proxy' rest client attribute must be a string or a URI object.\n";
165             }
166              
167 0           my $rest = REST::Client->new($rest_client_config);
168              
169             # Set proxy to be used
170 0 0         if ($proxy) {
171 0           $rest->getUseragent->proxy(['http', 'https'] => $proxy);
172             }
173              
174             # Set default base URL
175 0           $rest->setHost($URL);
176              
177             # Follow redirects/authentication by default
178 0           $rest->setFollow(1);
179              
180             # Since Confluence doesn't send an authentication challenge, we may
181             # simply force the sending of the authentication header.
182 0           $rest->addHeader(
183             Authorization => 'Basic ' . encode_base64("$username:$password"));
184              
185             # Configure UserAgent name
186 0           $rest->getUseragent->agent(__PACKAGE__);
187              
188             return
189 0           bless {rest => $rest, json => JSON->new->utf8->allow_nonref,} => $class;
190             }
191              
192             sub _error {
193 0     0     my ($self, $content, $type, $code) = @_;
194              
195 0 0         $type = 'text/plain' unless $type;
196 0 0         $code = 500 unless $code;
197              
198 0           my $msg = __PACKAGE__ . " Error[$code";
199              
200 0 0         if (eval { require HTTP::Status }) {
  0            
201 0 0         if (my $status = HTTP::Status::status_message($code)) {
202 0           $msg .= " - $status";
203             }
204             }
205              
206 0           $msg .= "]:\n";
207              
208 0 0 0       if ($type =~ m:text/plain:i) {
    0          
    0          
    0          
209 0           $msg .= $content;
210             }
211             elsif ($type =~ m:application/json:) {
212 0           my $error = $self->{json}->decode($content);
213 0 0         if (ref $error eq 'HASH') {
214              
215             # Confluence errors may be laid out in all sorts of ways. You have to
216             # look them up from the scant documentation at
217             # https://docs.atlassian.com/confluence/REST/latest/.
218              
219             # /issue/bulk tucks the errors one level down, inside the
220             # 'elementErrors' hash.
221             $error = $error->{elementErrors}
222 0 0         if exists $error->{elementErrors};
223              
224             # Some methods tuck the errors in the 'errorMessages' array.
225 0 0         if (my $errorMessages = $error->{errorMessages}) {
226 0           $msg .= "- $_\n" foreach @$errorMessages;
227             }
228              
229             # And some tuck them in the 'errors' hash.
230 0 0         if (my $errors = $error->{errors}) {
231 0           $msg .= "- [$_] $errors->{$_}\n" foreach sort keys %$errors;
232             }
233             }
234             else {
235 0           $msg .= $content;
236             }
237             }
238 0           elsif ($type =~ m:text/html:i && eval { require HTML::TreeBuilder }) {
239 0           $msg .= HTML::TreeBuilder->new_from_content($content)->as_text;
240             }
241             elsif ($type =~ m:^(text/|application|xml):i) {
242 0           $msg .= "$content";
243             }
244             else {
245 0           $msg
246             .= "(binary content not shown)";
247             }
248 0           $msg =~ s/\n*$/\n/s; # end message with a single newline
249 0           return $msg;
250             }
251              
252             sub _content {
253 0     0     my ($self) = @_;
254              
255 0           my $rest = $self->{rest};
256 0           my $code = $rest->responseCode();
257 0           my $type = $rest->responseHeader('Content-Type');
258 0           my $content = $rest->responseContent();
259              
260 0 0         $code =~ /^2/ or croak $self->_error($content, $type, $code);
261              
262 0 0         return unless $content;
263              
264 0 0         if (!defined $type) {
    0          
    0          
265 0           croak $self->_error(
266             "Cannot convert response content with no Content-Type specified."
267             );
268             }
269             elsif ($type =~ m:^application/json:i) {
270 0           my $decoded = $self->{json}->decode($content);
271 0 0         print Dumper $decoded if $DEBUG_JSON_P;
272 0           return $decoded;
273             }
274             elsif ($type =~ m:^text/plain:i) {
275 0           return $content;
276             }
277             else {
278 0           croak $self->_error(
279             "I don't understand content with Content-Type '$type'.");
280             }
281             }
282              
283             sub _build_query {
284 0     0     my ($self, $query) = @_;
285              
286 0 0         is_hash_ref($query)
287             or croak $self->_error("The QUERY argument must be a hash-ref.");
288              
289             return '?'
290 0           . join('&', map { $_ . '=' . uri_escape($query->{$_}) } keys %$query);
  0            
291             }
292              
293             =item GET(PATH [, QUERY])
294              
295             Thin wrapper around the underlying REST::Client method.
296              
297             Takes a required PATH and an optional QUERY string as arguments.
298              
299             =cut
300              
301             sub GET {
302 0     0 1   my ($self, $path, $query) = @_;
303              
304 0 0         $path .= $self->_build_query($query) if $query;
305              
306 0 0         do {
307 0           print "GET: $path\n";
308             } if $DEBUG_REQUESTS_P;
309              
310 0           $self->{rest}->GET($path);
311              
312 0           return $self->_content();
313             }
314              
315             =item DELETE(PATH [, QUERY])
316              
317             Thin wrapper around the underlying REST::Client method.
318              
319             Takes a required PATH and an optional QUERY string as arguments.
320              
321             =cut
322              
323             sub DELETE {
324 0     0     my ($self, $path, $query) = @_;
325              
326 0 0         $path .= $self->_build_query($query) if $query;
327              
328 0 0         do {
329 0           print "DELETE: $path\n";
330             } if $DEBUG_REQUESTS_P;
331              
332 0           $self->{rest}->DELETE($path);
333              
334 0           return $self->_content();
335             }
336              
337             =item PUT(PATH, [QUERY], VALUE, [HEADERS])
338              
339             Thin wrapper around the underlying REST::Client method.
340              
341             Takes as arguments: a required PATH, an optional QUERY string, an
342             required hashref VALUE which is encoded as JSON, and an optional
343             hashref of HEADERS.
344              
345             =cut
346              
347             sub PUT {
348 0     0 1   my ($self, $path, $query, $value, $headers) = @_;
349              
350 0 0         defined $value
351             or croak $self->_error("PUT method's 'value' argument is undefined.");
352              
353 0 0         $path .= $self->_build_query($query) if $query;
354              
355 0   0       $headers //= {};
356 0   0       $headers->{'Content-Type'} //= 'application/json;charset=UTF-8';
357              
358 0           $self->{rest}->PUT($path, $self->{json}->encode($value), $headers);
359              
360 0           return $self->_content();
361             }
362              
363             =item POST(PATH, [QUERY], VALUE, [HEADERS])
364              
365             Thin wrapper around the underlying REST::Client method.
366              
367             Takes as arguments: a required PATH, an optional QUERY string, a
368             required hashref VALUE which is encoded as JSON, and an optional
369             hashref of HEADERS.
370              
371             =cut
372              
373             sub POST {
374 0     0 1   my ($self, $path, $query, $value, $headers) = @_;
375              
376 0 0         defined $value
377             or croak $self->_error("POST method's 'value' argument is undefined.");
378              
379 0 0         $path .= $self->_build_query($query) if $query;
380              
381 0   0       $headers //= {};
382 0   0       $headers->{'Content-Type'} //= 'application/json;charset=UTF-8';
383              
384 0           $self->{rest}->POST($path, $self->{json}->encode($value), $headers);
385              
386 0           return $self->_content();
387             }
388              
389             =item set_search_iterator(PARAMS)
390              
391             Used to create an "iterator" against which you will later "kick" for
392             results (in HOP parlance), using the C method. PARAMS
393             must conform to the query parameters supported by the Confluence API.
394              
395             $confluence->set_search_iterator(
396             {
397             cql => 'label = test and type = page',
398             expand => 'metadata.labels',
399             }
400             );
401              
402             =cut
403              
404             sub set_search_iterator {
405 0     0 1   my ($self, $params) = @_;
406              
407 0           my %params = (%$params); # rebuild the hash to own it
408              
409 0           $params{start} = 0;
410 0           $params{limit} = 25;
411              
412             $self->{iter} = {
413 0           params => \%params, # params hash to be used in the next call
414             offset => 0, # offset of the next issue to be fetched
415             results => { # results of the last call
416             start => 0,
417             limit => 25,
418             json => {},
419             },
420             };
421              
422 0           return;
423             }
424              
425             =item next_result()
426              
427             Call this method to get the next page of results from your Confluence
428             API call. Requires that you have already called
429             C.
430              
431             while ( my $item = $confluence->next_result ) {
432             # ... do things with the result
433             }
434              
435             =back
436              
437             =cut
438              
439             sub next_result {
440 0     0 1   my ($self) = @_;
441 0           state $calls = 0;
442              
443             my $iter = $self->{iter}
444 0 0         or croak $self->_error(
445             "You must call set_search_iterator before calling next_result");
446              
447 0           my $has_next_page = $iter->{results}{json}{_links}{next};
448              
449 0 0 0       if (!$has_next_page && $calls >= 1) {
    0          
    0          
450              
451             # If there is no next page, we've reached the end of the search results
452 0           $self->{iter} = undef;
453 0           return;
454             }
455             elsif ($iter->{offset} % $iter->{results}{limit} == 0) {
456              
457             # If the number of calls to the API so far is 0,
458             # OR,
459             # if the offset is divisible by the page limit (meaning that we've
460             # worked through the current page of responses), we need to:
461             #
462             # 1. bump the start pointer by LIMIT (unless no calls have been made)
463             #
464             # 2. fetch the next page of results
465             #
466              
467 0 0         $iter->{params}{start} += $iter->{results}{limit} if $calls > 0;
468 0           $iter->{results}{json} = $self->GET('/search', $iter->{params});
469 0           $calls++;
470              
471 0 0         print Dumper $iter if $DEBUG_ITERATORS;
472             }
473             elsif ($calls == 0) {
474 0           $iter->{params}{start} += $iter->{results}{limit};
475             }
476              
477             # If neither of the above conditions are true (meaning that we DO have a
478             # next page of results but we HAVE NOT yet reached the page offset limit,
479             # we need to:
480             #
481             # + return the next item in the search result ...
482             #
483             # + the index of which will be the sum of: the offset minus the start, e.g.,
484             # if the offset is 78, the start should be 75, meaning the index should be 3
485             #
486              
487             my $actual_start
488             = ($calls == 0)
489             ? $iter->{results}{start}
490 0 0         : $iter->{results}{json}{start};
491 0           return $iter->{results}{json}{results}[$iter->{offset}++ - $actual_start];
492             }
493              
494             1;
495              
496             __END__