File Coverage

blib/lib/IO/Iron/Connector.pm
Criterion Covered Total %
statement 68 219 31.0
branch 0 54 0.0
condition 0 24 0.0
subroutine 19 24 79.1
pod 3 3 100.0
total 90 324 27.7


line stmt bran cond sub pod time code
1             package IO::Iron::Connector;
2              
3             ## no critic (Documentation::RequirePodAtEnd)
4             ## no critic (Documentation::RequirePodSections)
5             ## no critic (RegularExpressions::RequireExtendedFormatting)
6             ## no critic (RegularExpressions::RequireLineBoundaryMatching)
7             ## no critic (RegularExpressions::ProhibitEscapedMetacharacters)
8              
9 3     3   55 use 5.010_000;
  3         11  
10 3     3   25 use strict;
  3         6  
  3         72  
11 3     3   15 use warnings;
  3         6  
  3         111  
12              
13             # Global creator
14 3         247 BEGIN {
15 3     3   18 use parent qw( IO::Iron::ConnectorBase ); # Inheritance
  3         6  
  3         119  
16             }
17              
18             # Global destructor
19       3     END {
20             }
21              
22             # ABSTRACT: REST API Connector, HTTP interface class.
23              
24             our $VERSION = '0.14'; # VERSION: generated by DZP::OurPkgVersion
25              
26 3     3   10 use Log::Any qw{$log};
  3         20  
  3         1171  
27             require JSON::MaybeXS;
28 3     3   746 use Data::UUID ();
  3         81  
  3         16  
29 3     3   48 use Hash::Util 0.06 qw{lock_keys lock_keys_plus unlock_keys legal_keys};
  3         16  
  3         201  
30 3     3   6 use Carp::Assert;
  3         18  
  3         341  
31 3     3   16 use Carp::Assert::More;
  3         557  
  3         22  
32 3     3   8 use Carp;
  3         188  
  3         23  
33 3     3   7 use English '-no_match_vars';
  3         31  
  3         3152  
34 3     3   164301 use REST::Client ();
  3         122  
  3         25  
35 3     3   5 use URI::Escape qw{uri_escape_utf8};
  3         159  
  3         22  
36 3     3   9 use Try::Tiny;
  3         147  
  3         19  
37 3     3   7 use Scalar::Util qw{blessed looks_like_number};
  3         156  
  3         609  
38             use Exception::Class (
39 3         1334 'IronHTTPCallException' => {
40             fields => [ 'status_code', 'response_message' ],
41             }
42 3     3   9270 );
  3         76  
43              
44             # CONSTANTS
45              
46 3     3   8 use Const::Fast;
  3         40  
  3         9  
47             const my $HTTP_CODE_OK_MIN => 200;
48             const my $HTTP_CODE_OK_MAX => 299;
49             const my $HTTP_CODE_SERVICE_UNAVAILABLE => 503;
50             const my $HTTP_CONTENT_TYPE_JSON => q{application/json; charset=utf-8};
51              
52             sub new {
53 3     3 1 19 my ($class) = @_;
54 3         268 $log->tracef( 'Entering new(%s)', $class );
55 3         7 my $self = IO::Iron::ConnectorBase->new();
56              
57             # Add more keys to the self hash.
58             my @self_keys = (
59             'client', # REST client timeout (for REST calls accessing Iron services).
60             'mime_boundary', # The boundary string separating parts in multipart REST messages.
61 3         16 legal_keys( %{$self} ),
  3         24  
62             );
63 3         14 unlock_keys( %{$self} );
  3         29  
64 3         5 bless $self, $class;
65 3         17 lock_keys( %{$self}, @self_keys );
  3         132  
66              
67             # Set up REST client
68 3         16572 my $client = REST::Client->new();
69 3         984 $self->{'client'} = $client;
70              
71             # Create MIME multipart message boundary string
72 3         681 my $ug = Data::UUID->new();
73 3         50 my $uuid1 = $ug->create();
74 3         41 $self->{'mime_boundary'} = 'MIME_BOUNDARY_' . ( substr $ug->to_string($uuid1), 1, 20 ); ## no critic (ValuesAndExpressions::ProhibitMagicNumbers)
75              
76 3         192 $log->infof('Iron Connector created with REST::Client as HTTP user agent.');
77 3         44559 $log->tracef( 'Exiting new: %s', $self );
78 0           return $self;
79             }
80              
81             # TODO check why previous message (coded content) is in the next message!
82             sub perform_iron_action { ## no critic (Subroutines::ProhibitExcessComplexity)
83 0     0 1   my ( $self, $iron_action, $params ) = @_;
84 0 0         if ( !defined $params ) {
85 0           $params = {};
86             }
87 0           $log->tracef( 'Entering Connector:perform_iron_action(%s, %s)', $iron_action, $params );
88              
89 0           my $href = $iron_action->{'href'};
90 0           my $action_verb = $iron_action->{'action'};
91 0           my $return_type = $iron_action->{'return'};
92 0           my $retry = $iron_action->{'retry'};
93 0           my $require_body = $iron_action->{'require_body'};
94 0 0         my $paged = $iron_action->{'paged'} ? $iron_action->{'paged'} : 0;
95 0 0         my $per_page = $iron_action->{'per_page'} ? $iron_action->{'per_page'} : 0;
96 0           my $url_params = q{};
97              
98 0 0 0       if ( exists $iron_action->{'url_params'} && ref $iron_action->{'url_params'} eq 'HASH' ) {
99 0           foreach ( keys %{ $iron_action->{'url_params'} } ) {
  0            
100 0           $log->tracef( 'perform_iron_action(): url_param:%s', $_ );
101 0 0         if ( $params->{ '{' . $_ . '}' } ) {
102 0           $url_params .= "$_={$_}&";
103             }
104             }
105 0           $url_params = substr $url_params, 0, ( length $url_params ) - 1;
106             }
107 0 0         if ($url_params) {
108 0           $href .= ( q{?} . $url_params );
109             }
110 0           my $content_type = $iron_action->{'content_type'};
111 0           $params->{'content_type'} = $content_type;
112 0           $params->{'return_type'} = $return_type;
113 0           $log->tracef( 'href before value substitution:\'%s\'.', $href );
114 0           foreach my $value_key ( sort keys %{$params} ) {
  0            
115 0           my $value = $params->{$value_key};
116 0           $log->tracef( 'Param key:%s; value=%s;', $value_key, $value );
117 0           $href =~ s/$value_key/$value/gs;
118             }
119 0           $log->tracef( 'href after value substitution:\'%s\'.', $href );
120              
121 0           my ( $http_status_code, $returned_msg );
122 0           my $keep_on_trying = 1;
123 0           while ($keep_on_trying) {
124 0           $keep_on_trying = 0;
125             try {
126             assert( ( $require_body == 1 && defined $params->{'body'} && ref $params->{'body'} eq 'HASH' )
127 0   0 0     || ( $require_body == 0 && !defined $params->{'body'} ) );
128 0           assert_in(
129             $action_verb,
130             [ 'GET', 'PATCH', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD' ],
131             'action_verb is a valid HTTP verb.'
132             );
133 0           assert_nonblank( $params->{'{Protocol}'}, 'params->{Protocol} is defined and not blank.' );
134 0           assert_nonblank( $params->{'{Port}'}, 'params->{Port} is defined and not blank.' );
135 0           assert_nonblank( $params->{'{Host}'}, 'params->{Host} is defined and not blank.' );
136 0           assert_nonblank( $params->{'{Project ID}'}, 'params->{Project ID} is defined and not blank.' );
137 0           assert_nonblank( $params->{'{API Version}'}, 'params->{API Version} is defined and not blank.' );
138 0           assert_nonblank( $params->{'authorization_token'}, 'params->{authorization_token} is defined and not blank.' );
139 0           assert_nonblank( $params->{'http_client_timeout'}, 'params->{http_client_timeout} is defined and not blank.' );
140 0 0         my $url_escape_these_fields = defined $iron_action->{'url_escape'} ? $iron_action->{'url_escape'} : {};
141              
142 0           foreach my $field_name ( keys %{$url_escape_these_fields} ) {
  0            
143 0 0         if ( defined $params->{$field_name} ) {
144 0           $params->{$field_name} = uri_escape_utf8( $params->{$field_name} );
145             }
146             }
147             #
148 0 0         if ($paged) {
149 0           $log->debugf('A paged query.');
150 0           my @returned_msgs;
151 0           my ( $http_status_code_temp, $returned_msg_temp );
152 0           my $page_number = 0;
153 0           while (1) {
154 0           my $page_href = $href;
155 0           $log->debugf( 'A paged query. Href:\'%s\'', $page_href );
156 0 0         $page_href .= ( $href =~ /\?/gsx ? q{&} : q{?} ) . 'per_page=' . $per_page . '&page=' . $page_number;
157 0           ( $http_status_code_temp, $returned_msg_temp ) =
158             $self->perform_http_action( $action_verb, $page_href, $params );
159 0           my $return_list = $returned_msg_temp;
160 0           my ( $return_type_def, $list_hash_key ) = ( split m/:/s, $return_type );
161 0 0 0       $return_list = $returned_msg_temp->{$list_hash_key}
162             if $return_type_def eq 'LIST' && defined $list_hash_key; ## no critic (ControlStructures::ProhibitPostfixControls)
163 0           push @returned_msgs, @{$return_list};
  0            
164              
165 0 0 0       if ( scalar @{$return_list} == 0 || @{$return_list} < $per_page ) {
  0            
  0            
166 0           last;
167             }
168 0           $page_number++;
169             }
170 0           $http_status_code = $http_status_code_temp;
171 0           $returned_msg = \@returned_msgs;
172             }
173             else {
174 0           ( $http_status_code, $returned_msg ) = $self->perform_http_action( $action_verb, $href, $params );
175             }
176             }
177             catch {
178 0     0     $log->debugf( 'perform_iron_action(): Caught exception:\'%s\'.', $_ );
179 0 0 0       croak $_ unless blessed $_ && $_->can('rethrow'); ## no critic (ControlStructures::ProhibitPostfixControls)
180 0 0         if ( $_->isa('IronHTTPCallException') ) {
181 0 0         if ( $_->status_code == $HTTP_CODE_SERVICE_UNAVAILABLE ) {
182              
183             # 503 Service Unavailable. Clients should implement exponential backoff to retry the request.
184 0 0         $keep_on_trying = 1 if ( $retry == 1 ); ## no critic (ControlStructures::ProhibitPostfixControls)
185             # TODO Fix this temporary solution for backoff to retry the request.
186             }
187             else {
188 0           $log->debugf( 'perform_iron_action(): rethrow the exception.', $_ );
189 0           $_->rethrow;
190             }
191             }
192             else {
193 0           $_->rethrow;
194             }
195 0           };
196              
197             # Module::Pluggable here?
198             }
199 0           $log->tracef( 'Exiting Connector:perform_iron_action(): %s', $returned_msg );
200 0           return $http_status_code, $returned_msg;
201             }
202              
203             sub perform_http_action {
204 0     0 1   my ( $self, $action_verb, $href, $params ) = @_;
205 0           my $client = $self->{'client'};
206 0           my $json = JSON::MaybeXS->new( utf8 => 1, pretty => 1 );
207              
208             # TODO assert href is URL
209 0           assert_in( $action_verb, [ 'GET', 'PATCH', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD' ], 'action_verb is a valid HTTP verb.' );
210 0           assert_exists(
211             $params,
212             [ 'http_client_timeout', 'authorization_token' ],
213             'params contains items http_client_timeout and authorization_token.'
214             );
215 0           assert_integer( $params->{'http_client_timeout'}, 'params->{\'http_client_timeout\'} is integer.' );
216 0           assert_nonblank( $params->{'authorization_token'}, 'params->{\'authorization_token\'} is a non-blank string.' );
217 0           $log->tracef( 'Entering Connector:perform_http_action(%s, %s, %s)', $action_verb, $href, $params );
218             #
219             # HTTP request attributes
220 0           my $timeout = $params->{'http_client_timeout'};
221 0           my $request_body;
222              
223             # Headers
224 0 0         my $content_type = defined( $params->{'content_type'} ) ? $params->{'content_type'} : $HTTP_CONTENT_TYPE_JSON;
225 0           my $authorization = 'OAuth ' . $params->{'authorization_token'};
226             #
227 0 0         if ( $content_type =~ /multipart/is ) {
228 0 0         my $body_content = $params->{'body'} ? $params->{'body'} : {}; # Else use an empty hash for body.
229 0           my $file_as_zip = $params->{'body'}->{'file'};
230 0           delete $params->{'body'}->{'file'};
231 0           my $encoded_body_content = $json->encode($body_content);
232 0           my $boundary = $self->{'mime_boundary'};
233 0           $content_type = "multipart/form-data; boundary=$boundary";
234 0           my $file_name = $params->{'body'}->{'file_name'} . '.zip';
235              
236             #$request_body = 'MIME-Version: 1.0' . "\n";
237             #$request_body .= 'Content-Length: ' . $req_content_length . "\n";
238             #$request_body .= 'Content-Type: ' . $req_content_type . "\n";
239 0           $request_body = q{--} . $boundary . "\n";
240 0           $request_body .= 'Content-Disposition: ' . 'form-data; name="data"' . "\n";
241 0           $request_body .= 'Content-Type: ' . 'text/plain; charset=utf-8' . "\n";
242 0           $request_body .= "\n";
243 0           $request_body .= $encoded_body_content . "\n";
244 0           $request_body .= "\n";
245 0           $request_body .= q{--} . $boundary . "\n";
246 0           $request_body .= 'Content-Disposition: ' . 'form-data; name="file"; filename="' . $file_name . q{"} . "\n";
247 0           $request_body .= 'Content-Type: ' . 'application/zip' . "\n";
248 0           $request_body .= 'Content-Transfer-Encoding: base64' . "\n";
249 0           $request_body .= "\n";
250 0           $request_body .= $file_as_zip . "\n";
251 0           $request_body .= q{--} . $boundary . q{--} . "\n";
252             }
253             else {
254 0 0         my $body_content = $params->{'body'} ? $params->{'body'} : {}; # Else use an empty hash for body.
255 0           $log->debugf( 'About to jsonize the body:\'%s\'', $body_content );
256 0           foreach ( keys %{$body_content} ) {
  0            
257              
258             # Gimmick to ensure the proper jsonization of numbers
259             # Otherwise numbers might end up as strings.
260 0 0         $body_content->{$_} += 0 if looks_like_number $body_content->{$_}; ## no critic (ControlStructures::ProhibitPostfixControls)
261             }
262 0           my $encoded_body_content = $json->encode($body_content);
263 0           $log->debugf( 'Jsonized body:\'%s\'', $encoded_body_content );
264 0           $request_body = $encoded_body_content;
265             }
266 0           $client->setTimeout($timeout);
267 0           $log->tracef( 'client: %s; action=%s; href=%s;', $client, $action_verb, $href );
268 0           $log->debugf( 'REST Request: [verb=%s; href=%s; body=%s; Headers: Content-Type=%s; Authorization=%s]',
269             $action_verb, $href, $request_body, $content_type, $authorization );
270 0           $client->request(
271             $action_verb,
272             $href,
273             $request_body,
274             {
275             'Content-Type' => $content_type,
276             'Authorization' => $authorization,
277             }
278             );
279              
280             # RETURN:
281 0           $log->debugf( 'Returned HTTP response code:%s', $client->responseCode() );
282 0           $log->tracef( 'Returned HTTP response:%s', $client->responseContent() );
283 0 0 0       if ( $client->responseCode() >= $HTTP_CODE_OK_MIN && $client->responseCode() <= $HTTP_CODE_OK_MAX ) {
284              
285             # 200 OK: Successful GET; 201 Created: Successful POST
286 0           $log->tracef( 'HTTP Response code: %d, %s', $client->responseCode(), 'Successful!' );
287 0           my $decoded_body_content;
288 0 0 0       if ( defined $params->{'return_type'} && $params->{'return_type'} eq 'BINARY' ) {
    0 0        
289 0           $log->tracef( 'Returned HTTP response header Content-Disposition:%s', $client->responseHeader('Content-Disposition') );
290 0           my $filename;
291 0 0         if ( $client->responseHeader('Content-Disposition') =~ /filename=(.+)$/s ) {
292 0 0         $filename = $1 ? $1 : '[Unknown filename]';
293             }
294 0           $decoded_body_content = { 'file' => $client->responseContent(), 'file_name' => $filename };
295             }
296             elsif ( defined $params->{'return_type'} && $params->{'return_type'} eq 'PLAIN_TEXT' ) {
297 0           $decoded_body_content = $client->responseContent();
298             }
299             else {
300 0           $decoded_body_content = $json->decode( $client->responseContent() );
301             }
302 0           $log->tracef( 'Exiting Connector:perform_http_action(): %s, %s', $client->responseCode(), $decoded_body_content );
303 0           return $client->responseCode(), $decoded_body_content;
304             }
305             else {
306 0           $log->tracef( 'HTTP Response code: %d, %s', $client->responseCode(), 'Failure!' );
307 0           my $decoded_body_content;
308             try {
309 0     0     $decoded_body_content = $json->decode( $client->responseContent() );
310 0           };
311 0 0         my $response_message = $decoded_body_content ? $decoded_body_content->{'msg'} : $client->responseContent();
312 0           $log->tracef(
313             'Throwing exception in perform_http_action(): status_code=%s, response_message=%s',
314             $client->responseCode(),
315             $response_message
316             );
317 0           IronHTTPCallException->throw(
318             status_code => $client->responseCode(),
319             response_message => $response_message,
320             error => 'IronHTTPCallException: status_code=' . $client->responseCode() . ' response_message=' . $response_message,
321             );
322             }
323             return; # Control does not reach this point.
324             }
325              
326             1;
327              
328             __END__
329              
330             =pod
331              
332             =encoding UTF-8
333              
334             =head1 NAME
335              
336             IO::Iron::Connector - REST API Connector, HTTP interface class.
337              
338             =head1 VERSION
339              
340             version 0.14
341              
342             =head1 SYNOPSIS
343              
344             This package is for internal use of IO::Iron packages.
345              
346             =head1 DESCRIPTION
347              
348             This class object handles the actual http traffic. Parameters are
349             passed from the calling object (partly from API class) via Connection
350             class object. This class can be mocked and replaced when
351             the client objects are created.
352              
353             =for stopwords API http Params params Mikko Koivunalho
354              
355             =head1 SUBROUTINES/METHODS
356              
357             =head2 new
358              
359             Creator function.
360              
361             =head2 perform_iron_action
362              
363             =over 8
364              
365             =item Params: action name, params hash.
366              
367             =item Return: 1/0 (1 if success, 0 in all failures),
368             HTTP return code, hash if success/failed request.
369              
370             =back
371              
372             =head2 perform_http_action
373              
374             Do the actual "dirty work" of Internet connection.
375             This routine is only accessed internally.
376              
377             =head1 AUTHOR
378              
379             Mikko Koivunalho <mikko.koivunalho@iki.fi>
380              
381             =head1 BUGS
382              
383             Please report any bugs or feature requests to bug-io-iron@rt.cpan.org or through the web interface at:
384             http://rt.cpan.org/Public/Dist/Display.html?Name=IO-Iron
385              
386             =head1 COPYRIGHT AND LICENSE
387              
388             This software is copyright (c) 2023 by Mikko Koivunalho.
389              
390             This is free software; you can redistribute it and/or modify it under
391             the same terms as the Perl 5 programming language system itself.
392              
393             The full text of the license can be found in the
394             F<LICENSE> file included with this distribution.
395              
396             =cut