File Coverage

blib/lib/App/PAIA/Command.pm
Criterion Covered Total %
statement 125 134 93.2
branch 43 54 79.6
condition 34 50 68.0
subroutine 24 24 100.0
pod 1 13 7.6
total 227 275 82.5


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