File Coverage

blib/lib/App/PAIA/Command.pm
Criterion Covered Total %
statement 139 160 86.8
branch 49 68 72.0
condition 33 50 66.0
subroutine 25 26 96.1
pod 2 14 14.2
total 248 318 77.9


line stmt bran cond sub pod time code
1             package App::PAIA::Command;
2 4     4   2362 use strict;
  4         3  
  4         103  
3 4     4   31 use v5.10;
  4         9  
  4         200  
4              
5             our $VERSION = '0.30';
6              
7 4     4   16 use App::Cmd::Setup -command;
  4         4  
  4         24  
8 4     4   1643 use App::PAIA::Agent;
  4         9  
  4         98  
9 4     4   24 use App::PAIA::JSON;
  4         7  
  4         126  
10 4     4   1356 use App::PAIA::File;
  4         6  
  4         71  
11 4     4   18 use URI::Escape;
  4         56  
  4         160  
12 4     4   15 use URI;
  4         3  
  4         208  
13              
14             # TODO: move option handling to App::PAIA
15              
16             # Implements lazy accessors just like Mo, Moo, Moose...
17             sub has {
18 52     52 0 80 my ($name, %options) = @_;
19 52   100 112   182 my $coerce = $options{coerce} || sub { $_[0] };
  112         1511  
20 52         45 my $default = $options{default};
21 4     4   13 no strict 'refs'; ## no critic
  4         3  
  4         6018  
22 52         181 *{__PACKAGE__."::$name"} = sub {
23 245 100 66 245   881 if (@_ > 1) {
    100          
24 6         10 $_[0]->{$name} = $coerce->($_[1]);
25             } elsif (!exists $_[0]->{$name} && $default) {
26 106         214 $_[0]->{$name} = $coerce->($default->($_[0]));
27             } else {
28 133         394 $_[0]->{$name}
29             }
30             }
31 52         105 }
32              
33             sub option {
34 28     28 0 35 my ($self, $name) = @_;
35 28   66     49 $self->app->global_options->{$name} # command line
      100        
36             // $self->config->get($name) # config file
37             // $self->session->get($name); # session file
38             }
39              
40             has config => (
41             default => sub {
42             App::PAIA::File->new(
43             logger => $_[0]->logger,
44             type => 'config',
45             file => $_[0]->app->global_options->config,
46             )
47             }
48             );
49              
50             has session => (
51             default => sub {
52             App::PAIA::File->new(
53             logger => $_[0]->logger,
54             type => 'session',
55             file => $_[0]->app->global_options->session,
56             )
57             }
58             );
59              
60             has agent => (
61             default => sub {
62             App::PAIA::Agent->new(
63             insecure => $_[0]->option('insecure'),
64             logger => $_[0]->logger,
65             dumper => $_[0]->dumper,
66             );
67             }
68             );
69              
70             has logger => (
71             default => sub {
72             ($_[0]->app->global_options->verbose || $_[0]->app->global_options->debug)
73             ? sub { say "# $_" for split "\n", $_[0]; }
74             : sub { };
75             }
76             );
77              
78             has dumper => (
79             default => sub {
80             $_[0]->app->global_options->debug
81             ? sub { say "> $_" for split "\n", $_[0]; }
82             : sub { };
83             }
84             );
85              
86             sub base_url {
87 12     12 0 16 my ($self, $name) = @_;
88            
89             # command line
90 12 50       29 if ( defined $self->app->global_options->{$name} ) {
91 0         0 return $self->app->global_options->{$name};
92             }
93              
94 12         89 my $base = $self->app->global_options->{base};
95 12 100       64 if (defined $base) {
96 7         25 $base =~ s{/$}{};
97 7         21 return "$base/$name";
98             }
99              
100             # session file or config file
101 5         10 foreach ( $self->session, $self->config ) {
102 6 100       13 if ( defined $_->get($name) ) {
103 4         6 return $_->get($name);
104             }
105             }
106            
107             # config file with base
108 1 50       3 if ( defined $self->config->get('base') ) {
109 1         1 my $base = $self->config->get('base');
110 1         2 $base =~ s{/$}{};
111 1         3 return $base . "/$name";
112             }
113              
114 0         0 return;
115             }
116              
117             has auth => (
118             default => sub {
119             $_[0]->base_url('auth');
120             }
121             );
122              
123             has core => (
124             default => sub {
125             $_[0]->base_url('core');
126             }
127             );
128              
129             has base => (
130             default => sub { $_[0]->option('base') },
131             coerce => sub { my ($b) = @_; $b =~ s!/$!!; $b; }
132             );
133              
134             has patron => (
135             default => sub { $_[0]->option('patron') }
136             );
137              
138             has scope => (
139             default => sub { $_[0]->option('scope') }
140             );
141              
142             has token => (
143             default => sub { $_[0]->option('access_token') }
144             );
145              
146             has username => (
147             default => sub {
148             $_[0]->option('username') // $_[0]->usage_error("missing username")
149             }
150             );
151              
152             has password => (
153             default => sub {
154             $_[0]->option('password') // $_[0]->usage_error("missing password")
155             }
156             );
157              
158             sub expired {
159 5     5 0 5 my ($self) = @_;
160              
161 5         12 my $expires = $self->session->get('expires_at');
162 5 100       17 return $expires ? $expires <= time : 0;
163             }
164              
165             sub not_authentificated {
166 6     6 0 8 my ($self, $scope) = @_;
167              
168 6   100     20 my $token = $self->token // return "missing access token";
169              
170 5 50       71 return "access token expired" if $self->expired;
171              
172 5 50 100     23 if ($scope and $self->scope and !$self->has_scope($scope)) {
      66        
173 0         0 return "current scope '{$self->scope}' does not include $scope!\n";
174             }
175              
176 5         13 return;
177             }
178              
179             sub has_scope {
180 2     2 0 3 my ($self, $scope) = @_;
181 2   50     4 my $has_scope = $self->scope // '';
182 2         13 return index($has_scope, $scope) != -1;
183             }
184              
185             sub request {
186 8     8 0 17 my ($self, $method, $url, $param) = @_;
187              
188 8         9 my %headers;
189 8 100       30 if ($url !~ /login$/) {
190 4   50     801 my $token = $self->token // die "missing access_token - login required\n";
191 4         13 $headers{Authorization} = "Bearer $token";
192             }
193              
194 8         36 my ($response, $json) = $self->agent->request( $method, $url, $param, %headers );
195              
196             # handle request errors
197 8 100 100     60 if (ref $json and defined $json->{error}) {
198 1         1 my $msg = $json->{error};
199 1 50       2 if (defined $json->{error_description}) {
200 1         2 $msg .= ': '.$json->{error_description};
201             }
202 1         33 die "$msg\n";
203             }
204              
205 7 100       20 if ($response->{status} ne '200') {
206 2   33     7 my $msg = $response->{content} // 'HTTP request failed: '.$response->{status};
207 2         92 die "$msg\n";
208             }
209              
210 5 50       11 if (my $scopes = $response->{headers}->{'x-oauth-scopes'}) {
211 0         0 $self->session->set( scope => $scopes );
212             }
213              
214 5         15 return $json;
215             }
216              
217             sub login {
218 4     4 0 5 my ($self, $scope) = @_;
219              
220 4 50       18 if ($self->session->purge) {
221 0         0 $self->session->file(undef);
222 0         0 $self->logger->("deleted session file");
223             }
224              
225 4 50       25 my $auth = $self->auth or $self->usage_error("missing PAIA auth server URL");
226              
227             # take credentials from command line or config file only
228 4         19 my %params = (
229             username => $self->username,
230             password => $self->password,
231             grant_type => 'password',
232             );
233              
234 4 100       14 if (defined $scope) {
235 1         2 $scope =~ s/,/ /g;
236 1         2 $params{scope} = $scope;
237             }
238              
239 4         28 my $response = $self->request( "POST", "$auth/login", \%params );
240              
241 1         8 $self->{$_} = $response->{$_} for qw(expires_in access_token token_type patron scope);
242              
243 1         6 $self->session->set( $_, $response->{$_} ) for qw(access_token patron scope);
244 1         2 $self->session->set( expires_at => time + $response->{expires_in} );
245 1         3 $self->session->set( auth => $auth );
246 1 50       92 $self->session->set( core => $self->core ) if defined $self->core;
247              
248 1         9 $self->store_session;
249            
250 1         3 return $response;
251             }
252              
253              
254             our %required_scopes = (
255             patron => 'read_patron',
256             items => 'read_items',
257             request => 'write_items',
258             renew => 'write_items',
259             cancel => 'write_items',
260             fees => 'read_fees',
261             change => 'change_password',
262             );
263              
264             sub auto_login_for {
265 4     4 0 5 my ($self, $command) = @_;
266              
267 4         8 my $scope = $required_scopes{$command};
268              
269 4 100       11 if ( $self->not_authentificated($scope) ) {
270             # add to existing scopes (TODO: only if wanted)
271 1   50     4 my $new_scope = join ' ', split(' ',$self->scope // ''), $scope;
272 1         3 $self->logger->("auto-login with scope '$new_scope'");
273 1         20 $self->login( $new_scope );
274 1 50 33     2 if ( $self->scope and !$self->has_scope($scope) ) {
275 0         0 die "current scope '{$self->scope}' does not include $scope!\n";
276             }
277             }
278             }
279              
280             sub store_session {
281 3     3 0 4 my ($self) = @_;
282              
283 3         652 $self->session->store;
284              
285 3 100       28 $self->token($self->session->get('access_token'))
286             if defined $self->session->get('access_token');
287 3 100       6 $self->scope($self->session->get('scope'))
288             if defined $self->session->get('scope');
289 3 100       6 $self->patron($self->session->get('patron'))
290             if defined $self->session->get('patron');
291             # TODO: expires_at?
292             }
293              
294             sub core_request {
295 4     4 0 7 my ($self, $method, $command, $params) = @_;
296              
297 4   33     13 my $core = $self->core // $self->usage_error("missing PAIA core server URL");
298              
299 4         16 $self->auto_login_for($command);
300              
301 4   33     13 my $patron = $self->patron // $self->usage_error("missing patron identifier");
302              
303 4         17 my $url = "$core/".uri_escape($patron);
304 4 100       73 $url .= "/$command" if $command ne 'patron';
305              
306             # save PAIA core URL in session
307 4 100 100     9 if ( ($self->session->get('core') // '') ne $core ) {
308 2         5 $self->session->set( core => $core );
309 2         7 $self->store_session;
310             # TODO: could we save new expiry as well?
311             }
312              
313 4         16 my $json = $self->request( $method => $url, $params );
314              
315 4 100       8 if ($json->{doc}) {
316             # TODO: more details about failed documents
317 2         1 my @errors = grep { defined $_ } map { $_->{error} } @{$json->{doc}};
  2         4  
  2         4  
  2         3  
318 2 100       3 if (@errors) {
319 1         21 die join("\n", @errors)."\n";;
320             }
321             }
322              
323 3         10 return $json;
324             }
325              
326             # used in command::renew and ::cancel
327             sub uri_list {
328 1     1 0 2 my $self = shift;
329 1         3 map {
330 1         2 /^((edition|item)=)?(.+)/;
331 1         5 my $uri = URI->new($3);
332 1 50 33     1707 $self->usage_error("not an URI: $3") unless $uri and $uri->scheme;
333 1   50     24 my $d = { ($2 // "item") => "$uri" };
334 1         10 $d;
335             } @_;
336             }
337              
338             # TODO:
339              
340             sub description {
341 0     0 1 0 my ($class) = @_;
342 0 0       0 $class = ref $class if ref $class;
343              
344             # classname to filename
345 0         0 (my $pm_file = $class) =~ s!::!/!g;
346 0         0 $pm_file .= '.pm';
347 0 0       0 $pm_file = $INC{$pm_file} or return '';
348            
349 0 0       0 open my $input, "<", $pm_file or return '';
350              
351 0         0 my $descr = "";
352 0         0 open my $output, ">", \$descr;
353              
354 4     4   1950 use Pod::Usage;
  4         145123  
  4         1184  
355 0         0 pod2usage( -input => $input,
356             -output => $output,
357             -exit => "NOEXIT", -verbose => 99,
358             -sections => "DESCRIPTION",
359             indent => 0,
360             );
361 0         0 $descr =~ s/Description:\n//m;
362 0         0 chomp $descr;
363              
364 0         0 return $descr;
365             }
366              
367             # TODO: Think about making this part of App::Cmd
368             # see https://github.com/rjbs/App-Cmd/issues/30
369             sub execute {
370 24     24 1 56575 my $self = shift;
371              
372 24 50       98 if ($self->app->global_options->version) {
    50          
373 0         0 $self->app->execute_command( $self->app->prepare_command('version') );
374 0         0 exit;
375             } elsif ($self->app->global_options->help) {
376             # $self->app->execute_command( $self->app->prepare_command('help', @ARGV) );
377             # exit;
378             }
379              
380 24         607 my $response = $self->_execute(@_);
381 18 100 100     188 if (defined $response and !$self->app->global_options->quiet) {
382 4         48 print encode_json($response);
383             }
384             }
385              
386             1;
387             __END__