File Coverage

blib/lib/WebService/Redmine.pm
Criterion Covered Total %
statement 109 161 67.7
branch 34 62 54.8
condition 26 56 46.4
subroutine 16 21 76.1
pod 3 3 100.0
total 188 303 62.0


line stmt bran cond sub pod time code
1             package WebService::Redmine;
2              
3 3     3   40086 use 5.010;
  3         11  
  3         119  
4 3     3   17 use strict;
  3         7  
  3         108  
5 3     3   26 use warnings;
  3         10  
  3         176  
6              
7             our $VERSION = '0.06';
8              
9 3     3   3030 use URI;
  3         44487  
  3         93  
10 3     3   2763 use URI::QueryParam;
  3         2486  
  3         80  
11 3     3   27775 use LWP::UserAgent;
  3         144738  
  3         119  
12 3     3   3890 use JSON::XS qw/encode_json decode_json/;
  3         21908  
  3         305  
13 3     3   11056 use Encode qw/decode/;
  3         42563  
  3         5866  
14              
15             =pod
16              
17             =encoding UTF-8
18              
19             =head1 NAME
20              
21             WebService::Redmine - Wrapper for RedMine REST API (http://www.redmine.org/projects/redmine/wiki/Rest_api).
22              
23             =head1 SYNOPSIS
24              
25             use WebService::Redmine;
26             my $redminer = WebService::Redmine->new(
27             host => 'example.com/redmine',
28             key => 'xxx',
29             );
30             # password-based auth is also supported:
31             #my $redminer = WebService::Redmine->new(
32             # host => 'example.com/redmine',
33             # user => 'redminer',
34             # pass => 'p@s$w0rD',
35             #);
36              
37             my $project = $redminer->createProject({ project => {
38             identifier => 'my-project',
39             name => 'My Project',
40             description => 'My project, created with *WebService::Redmine*',
41             }});
42             if (!$project) {
43             say STDERR 'Error(s) creating project: ', join("\n", map { $_ } @{ $redminer->errorDetails->{errors} });
44             exit 1;
45             }
46             my $project_id = $project->{project}{id};
47              
48             $redminer->updateProject($project_id, { project => {
49             parent_id => 42, # Make a project with numeric ID 42 parent for $project_id
50             inherit_members => 1, # Inherit all members and their permissions from the parent
51             }});
52            
53             my $issue = $redminer->createIssue({ issue => {
54             project_id => $project_id,
55             subject => 'Test issue for WebService::Redmine',
56             description => 'Issue description',
57             }});
58              
59             $redminer->deleteProject($project_id);
60              
61             =head1 DESCRIPTION
62              
63             This module is a client for RedMine REST API. Please note that although
64             RedMine API is designed to support both JSON and XML, this module is B.
65              
66             =head1 METHODS NAMING AND OTHER CALL CONVENTIONS
67              
68             All methods are dynamically converted to actual HTTP requests using following conventions.
69              
70             =head2 Getting a Collection of Objects
71              
72             $redminer->projects; # ->users, ->issues, ->timeEntries ...
73             $redminer->getProjects; # ->getUsers, ->getIssues, ->getTimeEntries ...
74             $redminer->readProjects; # ->readUsers, ->readIssues, ->readTimeEntries ...
75            
76             # Second page when displaying 10 items per page:
77             $redminer->projects({ offset => 9, limit => 10 });
78              
79             # Filtering issues:
80             $redminer->issues({ project_id => 42, assigned_to_id => 'me' });
81              
82             =head2 Getting an Object
83              
84             $redminer->project(1); # ->user(1), ->issue(1), ->timeEntry(1) ...
85             $redminer->getProject(1); # ->getUser(1), ->getIssue(1), ->getTimeEntry(1) ...
86             $redminer->readProject(1); # ->readUsers(1), ->readIssue(1), ->readTimeEntry(1) ...
87            
88             # Showing an object with additional metadata:
89             $redminer->issue(1, { include => 'relations,changesets' });
90              
91             =head2 Creating an Object
92              
93             $redminer->createProject({
94             # ...
95             }); # ->createUser, ->createIssue, ->createTimeEntry ...
96              
97             =head2 Updating an Object
98              
99             $redminer->updateProject(1, {
100             # ...
101             }); # ->updateUser(...), ->updateIssue(...), ->updateTimeEntry(...) ...
102              
103             =head2 Deleting an Object
104              
105             $redminer->deleteProject(1); # ->deleteUser(1), ->deleteIssue(1), ->deleteTimeEntry(1) ...
106              
107             =head2 Objects Belonging to Other Objects
108              
109             #
110             # Example for project membership(s)
111             #
112             my $project_id = 42;
113             my $membership_id = 42;
114              
115             # Listing *project* memberships and creating a membership within a *project*
116             # require identifying a project and thus have to be spelled like this:
117             $redminer->projectMemberships($project_id, { limit => 50 });
118             $redminer->createProjectMembership($project_id, { ... });
119              
120             # Viewing/Updating/Deleting a membership is performed directly by its ID, thus:
121             my $membership = $redminer->membership($membership_id);
122             $redminer->updateMembership($membership_id, { ... });
123             $redminer->deleteMembership($membership_id);
124              
125             =head2 Complex Object Names
126              
127             Such complex names as C which should be dispatched to C
128             are recognized and thus can be spelled in CamelCase (see examples above).
129             If this is not the case, please report bugs.
130              
131             =head2 Return Values
132              
133             All successfull calls return hash references. For C and C calls
134             hash references are empty.
135              
136             If a call fails, C is returned. In this case detailed error information can
137             be retrieved using C method:
138            
139             if (!$redminer->deleteIssue(42)) {
140             my $details = $redminer->errorDetails;
141             # Process $details here...
142             }
143              
144             =head1 METHODS
145              
146             =head2 new
147              
148             my $redminer = WebService::Redmine->new(%options);
149              
150             Following options are recognized:
151              
152             =over
153              
154             =item *
155              
156             B: RedMine host. Beside host name, may include port, path and/or URL scheme (C is used by default).
157              
158             =item *
159              
160             B: API key. For details, please refer to http://www.redmine.org/projects/redmine/wiki/Rest_api#Authentication
161              
162             =item *
163              
164             B, B: User name and password for password-based authentication
165              
166             =item *
167              
168             B: User login for impersonation. For details, please refer to http://www.redmine.org/projects/redmine/wiki/Rest_api#User-Impersonation.
169              
170             =item *
171              
172             B: Automatically add/remove wrapper object for data. See below.
173              
174             =back
175              
176             =head3 no_wrapper_object
177              
178             By default RedMine API requires you to wrap you object data:
179              
180             my $project = $redminer->createProject({
181             project => {
182             identifier => 'some-id',
183             name => 'Some Name',
184             }
185             });
186             # $project contains something like
187             # { project => { id => 42, identifier => 'some-id', name => 'Some Name' ... } }
188              
189             By default this module follows this convention. However, if you turn on
190             the C flag
191              
192             my $redminer = WebService::Redmine->new(
193             host => 'example.com/redmine',
194             key => 'xxx',
195             no_wrapper_object => 1,
196             );
197              
198             you can skip "wrapping" object data, which results in simpler data structures:
199              
200             my $project = $redminer->createProject({
201             identifier => 'some-id',
202             name => 'Some Name',
203             });
204             # $project contains something like
205             # { id => 42, identifier => 'some-id', name => 'Some Name' ... }
206              
207             Please note that wrapping can be skipped only while operating on single objects,
208             i.e. this flag is honored for C and C requests as well as for
209             Cting individual objects. This flag is ignored for C calls and calls
210             like C.
211              
212             =cut
213              
214             sub new
215             {
216 1     1 1 26 my $class = shift;
217 1         4 my %arg = @_;
218            
219 1   50     16 my $self = {
220             error => '',
221             protocol => $arg{protocol} // 'http',
222             ua => LWP::UserAgent->new,
223             };
224              
225 1         4245 foreach my $param (qw/host user pass key work_as no_wrapper_object/) {
226 6   100     27 $self->{$param} = $arg{$param} // '';
227             }
228              
229 1 50 33     9 if (length $self->{host} && $self->{host} =~ m|^(https?)://|i) {
230 0         0 $self->{protocol} = lc $1;
231 0         0 $self->{host} =~ s/^https?://i;
232             } else {
233 1 50       9 $self->{protocol} = 'http' if $self->{protocol} !~ /^https?$/i;
234             }
235              
236 1         2 my $auth = '';
237 1 50 33     9 if (!length $self->{key} && length $self->{user}) {
238 0         0 $auth = $self->{user};
239 0 0       0 if (length $self->{pass}) {
240 0         0 $auth .= ':' . $self->{pass};
241             }
242 0         0 $auth .= '@';
243             }
244 1         4 $self->{uri} = "$self->{protocol}://$auth$self->{host}";
245              
246 1         4 $self->{ua}->default_header('Content-Type' => 'application/json');
247            
248 1 50       46 if (length $self->{key}) {
249 0         0 $self->{ua}->default_header('X-Redmine-API-Key' => $self->{key});
250             }
251 1 50       4 if (length $self->{work_as}) {
252 0         0 $self->{ua}->default_header('X-Redmine-Switch-User' => $self->{work_as});
253             }
254              
255 1         2 bless $self, $class;
256              
257 1         5 return $self;
258             }
259              
260             =head2 error
261              
262             Error during the last call. This is an empty string for successfull calls, otherwise
263             it contains an HTTP status line.
264              
265             If the call failed before sending an actual request (e.g. method name could not
266             be dispatched into an HTTP request), contains description of the client error.
267              
268             =cut
269              
270 0     0 1 0 sub error { return $_[0]->{error} }
271              
272             =head2 errorDetails
273              
274             Contains detailed error messages from the last call. This is an empty hash reference
275             for successfull calls, otherwise please see http://www.redmine.org/projects/redmine/wiki/Rest_api#Validation-errors.
276              
277             If the call failed before sending an actual request (e.g. method name could not
278             be dispatched into an HTTP request), return value is
279              
280             {
281             client_error => 1
282             }
283              
284             =cut
285              
286 0     0 1 0 sub errorDetails { return $_[0]->{error_details} }
287              
288 8   50 8   19 sub _set_error { $_[0]->{error} = $_[1] // ''; return; }
  8         38  
289              
290             sub _set_client_error
291             {
292 8     8   10 my $self = shift;
293 8         9 my $error = shift;
294              
295 8         22 $self->{error_details} = {
296             client_error => 1
297             };
298              
299 8         19 return $self->_set_error($error);
300             }
301              
302             sub AUTOLOAD
303             {
304 0     0   0 our $AUTOLOAD;
305 0         0 my $self = shift;
306 0         0 my $method = substr($AUTOLOAD, length(__PACKAGE__) + 2);
307 0 0       0 return if $method eq 'DESTROY';
308 0         0 return $self->_response($self->_request($method, @_));
309             }
310              
311             sub _request
312             {
313 0     0   0 my $self = shift;
314 0   0     0 my $r = $self->_dispatch_name(@_) // return;
315              
316 0         0 $self->_set_error;
317              
318 0         0 my $uri = URI->new(sprintf('%s/%s.json', $self->{uri}, $r->{path}));
319 0 0 0     0 if ($r->{method} eq 'GET' && ref $r->{query} eq 'HASH') {
320 0         0 foreach my $param (keys %{ $r->{query} }) {
  0         0  
321             # 2DO: implement passing arrays as foo=1&foo=2&foo=3 if needed
322 0         0 $uri->query_param($param => $r->{query}{$param});
323             }
324             }
325              
326 0         0 my $request = HTTP::Request->new($r->{method}, $uri);
327              
328 0 0 0     0 if ($r->{method} ne 'GET' && defined $r->{content}) {
329 0         0 my $json = eval { Encode::decode('UTF-8', JSON::XS::encode_json($r->{content})) };
  0         0  
330 0 0       0 if ($@) {
331 0         0 return $self->_set_client_error('Malformed input data:' . $@);
332             }
333 0         0 $request->header('Content-Length' => length $json);
334 0         0 $request->content($json);
335             }
336              
337 0         0 return $request;
338             }
339              
340             sub _response
341             {
342 0     0   0 my $self = shift;
343 0   0     0 my $request = shift // return;
344 0         0 my $response = $self->{ua}->request($request);
345              
346 0 0       0 if (!$response->is_success) {
347 0   0     0 $self->{error_details} = eval {
348 0         0 JSON::XS::decode_json($response->decoded_content)
349             } // {};
350 0         0 return $self->_set_error($response->status_line);
351             }
352              
353 0         0 $self->{error_details} = {};
354              
355 0 0 0     0 if ($request->method eq 'PUT' || $request->method eq 'DELETE') {
356 0         0 return {};
357             }
358              
359 0         0 my $content = eval { JSON::XS::decode_json($response->decoded_content) };
  0         0  
360 0 0       0 if ($@) {
361 0         0 return $self->_set_error($@);
362             }
363              
364 0 0 0     0 if ($self->{expect_single_object} && $self->{no_wrapper_object}) {
365 0         0 $content = delete $content->{$self->{expect_single_object}};
366             }
367              
368 0         0 return $content;
369             }
370              
371             sub _dispatch_name
372             {
373 29     29   15625 my $self = shift;
374 29   100     84 my $name = shift // return $self->_set_client_error('Undefined method name');
375 28         50 my @args = @_;
376              
377 28         145 my ($action, $objects) = ($name =~ /^(get|read|create|update|delete)?([A-Za-z]+?)$/x);
378            
379 28 100 100     138 if (!$action || $action eq 'read') {
380 12         16 $action = 'get';
381             }
382 28 100       49 if (!$objects) {
383 1         5 return $self->_set_client_error("Malformed method name '$name'");
384             }
385              
386 27         95 my %METHOD = (
387             get => 'GET' ,
388             create => 'POST' ,
389             update => 'PUT' ,
390             delete => 'DELETE',
391             );
392              
393 27         139 my $data = {
394             method => $METHOD{$action},
395             path => '',
396             content => undef,
397             query => undef,
398             };
399              
400 27 100 100     90 if ($action eq 'get') {
    100          
401 13 100       31 if (ref $args[-1] eq 'HASH') {
402             # If last argument is a hash reference, treat it as a filtering clause:
403 10         19 $data->{query} = pop @args;
404             }
405             } elsif ($action eq 'create' || $action eq 'update') {
406             # If last argument is an array/hash reference, treat it as a request body:
407 11 100 66     51 if (ref $args[-1] ne 'ARRAY' && ref $args[-1] ne 'HASH') {
408 4         7 return $self->_set_client_error(
409             'No data provided for a create/update method'
410             );
411             }
412 7         14 $data->{content} = pop @args;
413             }
414              
415 23         68 $objects = $self->_normalize_objects($objects);
416 23         32 my $i = 0;
417 23         19 my @objects;
418 23         94 while ($objects =~ /([A-Z][a-z]+)/g) {
419 29         58 my $object = $self->_object($1);
420 29         58 my $category = $self->_category($object);
421            
422 29         45 push @objects, $category;
423              
424 29 100       70 next if $object eq $category;
425              
426 20         29 my $is_last_object = pos($objects) == length($objects);
427              
428             # We need to attach an object ID to the path if an object is singular and
429             # we either perform anything but creation or we create a new object inside
430             # another object (e.g. createProjectMembership)
431 20 100 100     72 if ($action ne 'create' || !$is_last_object) {
432 15         22 my $object_id = $args[$i++];
433              
434 15 100 66     75 return $self->_set_client_error(
435             sprintf 'Incorrect object ID for %s in query %s', $object, $name
436             ) if !defined $object_id || ref \$object_id ne 'SCALAR';
437              
438 13         20 push @objects, $object_id;
439             }
440              
441 18 100       62 $self->_dispatch_last_object($action, $object, $data) if $is_last_object;
442             }
443            
444 21         51 $data->{path} = join '/', @objects;
445              
446 21         101 return $data;
447             }
448              
449             sub _dispatch_last_object
450             {
451 12     12   12 my $self = shift;
452 12         15 my $action = shift;
453 12         14 my $object = shift;
454 12         16 my $data = shift;
455              
456 12         32 delete $self->{expect_single_object};
457              
458 12 50       23 if (length $object) {
459 12 100 100     60 if ($action eq 'get' || $action eq 'create') {
460 7         21 $self->{expect_single_object} = $object;
461             }
462 12 50       26 if ($self->{no_wrapper_object}) {
463 0 0 0     0 if ($action eq 'create' || $action eq 'update') {
464             # Wrap object data unless we pass everything as is:
465 0         0 $data->{content} = { $object => $data->{content} };
466             }
467             }
468             }
469              
470 12         37 return 1;
471             }
472              
473             sub _normalize_objects
474             {
475 23     23   25 my $self = shift;
476 23         28 my $objects = shift;
477              
478 23         35 $objects = ucfirst $objects;
479             # These are tokens that form a *single* entry in the resulting request path,
480             # e.g.: PUT /time_entries/1.json
481             # But it is natural to spell them like this:
482             # $api->updateTimeEntry(1, { ... });
483 23         36 $objects =~ s/TimeEntr/Timeentr/g;
484 23         31 $objects =~ s/IssueCategor/Issuecategor/g;
485 23         24 $objects =~ s/IssueStatus/Issuestatus/g;
486 23         20 $objects =~ s/CustomField/Customfield/g;
487              
488 23         45 return $objects;
489             }
490              
491             sub _object
492             {
493 29     29   33 my $self = shift;
494 29         51 my $object = lc(shift);
495            
496             # Process compound words:
497 29         46 $object =~ s/timeentr/time_entr/igx;
498 29         46 $object =~ s/issue(categor|status)/issue_$1/igx;
499 29         31 $object =~ s/customfield/custom_field/igx;
500            
501 29         55 return $object;
502             }
503              
504             # If an object is singular, pluralize it to make its category name: user -> users
505             sub _category
506             {
507 29     29   32 my $self = shift;
508 29         33 my $object = shift;
509              
510 29         31 my $category = $object;
511              
512 29 100 66     109 if ($category !~ /s$/ || $category =~ /us$/) {
513 20 100       50 if ($object =~ /y$/) {
    50          
514 5         15 $category =~ s/y$/ies/;
515             } elsif ($category =~ /us$/) {
516 0         0 $category .= 'es';
517             } else {
518 15         20 $category .= 's';
519             }
520             }
521              
522 29         54 return $category;
523             }
524              
525             =head1 SEE ALSO
526              
527             Redmine::API (https://metacpan.org/pod/Redmine::API). Major differences
528             between this module and Redmine::API are:
529              
530             =over
531              
532             =item *
533              
534             B. Redmine::API depends on Moo and REST::Client which in turn depends on
535             LWP::UserAgent, URI and possibly others. WebService::Redmine uses pure Perl OOP and
536             depends directly on LWP::UserAgent and URI.
537              
538             =item *
539              
540             B. Although both modules use dynamic dispatching for building actual HTTP
541             requests, they do it in a different manner. In particular, WebService::Redmine tries to
542             dispatch a single method name without using chains of interrim objects as Redmine::API does.
543              
544             =back
545              
546             Fork this project on GitHub: https://github.com/igelhaus/redminer
547              
548             =head1 AUTHOR
549              
550             Anton Soldatov, Eigelhaus@gmail.comE
551              
552             =head1 COPYRIGHT AND LICENSE
553              
554             Copyright (C) 2014 by Anton Soldatov
555              
556             This library is free software; you can redistribute it and/or modify
557             it under the same terms as Perl itself, either Perl version 5.10.0 or,
558             at your option, any later version of Perl 5 you may have available.
559              
560             =cut
561              
562             1;
563              
564             __END__