File Coverage

blib/lib/GitLab/API/v4/RESTClient.pm
Criterion Covered Total %
statement 93 126 73.8
branch 17 36 47.2
condition 7 22 31.8
subroutine 19 24 79.1
pod 0 3 0.0
total 136 211 64.4


line stmt bran cond sub pod time code
1             package GitLab::API::v4::RESTClient;
2             our $VERSION = '0.25';
3              
4             =encoding utf8
5              
6             =head1 NAME
7              
8             GitLab::API::v4::RESTClient - The HTTP client that does the heavy lifting.
9              
10             =head1 DESCRIPTION
11              
12             Currently this class uses L and L to do its job. This may
13             change, and the interface may change, so documentation is lacking in order
14             to not mislead people.
15              
16             If you do want to customize how this class works then take a look at the
17             source.
18              
19             =head1 ATTRIBUTES
20              
21             =head1 http_tiny_request
22              
23             my $req = $api->rest_client->http_tiny_request();
24              
25             The most recent request arrayref as passed to L.
26              
27             If this is C then no request has been made.
28              
29             =head1 http_tiny_response
30              
31             my $res = $api->rest_client->http_tiny_response();
32              
33             The most recent response hashref as passed back from L.
34              
35             If this is C and L is defined then no response was received
36             and you will have encountered an error when making the request
37              
38             =cut
39              
40 1     1   596 use Carp qw();
  1         3  
  1         28  
41 1     1   453 use HTTP::Tiny::Multipart;
  1         45570  
  1         46  
42 1     1   9 use HTTP::Tiny;
  1         2  
  1         18  
43 1     1   5 use JSON;
  1         2  
  1         9  
44 1     1   598 use Log::Any qw( $log );
  1         8563  
  1         4  
45 1     1   2937 use Path::Tiny;
  1         10919  
  1         56  
46 1     1   12 use Try::Tiny;
  1         2  
  1         58  
47 1     1   503 use Types::Common::Numeric -types;
  1         93761  
  1         8  
48 1     1   1870 use Types::Common::String -types;
  1         23275  
  1         15  
49 1     1   1648 use Types::Standard -types;
  1         3  
  1         7  
50 1     1   4638 use URI::Escape;
  1         12  
  1         72  
51 1     1   7 use URI;
  1         3  
  1         20  
52              
53 1     1   5 use Moo;
  1         3  
  1         10  
54 1     1   527 use strictures 2;
  1         9  
  1         45  
55 1     1   267 use namespace::clean;
  1         2  
  1         9  
56              
57             sub croak {
58 0     0 0 0 local $Carp::Internal{ 'GitLab::API::v4' } = 1;
59 0         0 local $Carp::Internal{ 'GitLab::API::v4::RESTClient' } = 1;
60              
61 0         0 return Carp::croak( @_ );
62             }
63              
64             sub croakf {
65 0     0 0 0 my $msg = shift;
66 0         0 $msg = sprintf( $msg, @_ );
67 0         0 return croak( $msg );
68             }
69              
70             has _clean_base_url => (
71             is => 'lazy',
72             init_arg => undef,
73             builder => '_build_clean_base_url',
74             );
75             sub _build_clean_base_url {
76 2     2   27 my ($self) = @_;
77 2         12 my $url = $self->base_url();
78              
79             # Remove any leading slash so that request() does not build URLs
80             # with double slashes when joining the base_url with the path.
81             # If double slashes were allowed then extra unecessary redirects
82             # could happen.
83 2         8 $url =~ s{/+$}{};
84              
85 2         15 return URI->new( $url )->canonical();
86             }
87              
88             has base_url => (
89             is => 'ro',
90             isa => NonEmptySimpleStr,
91             required => 1,
92             );
93              
94             has retries => (
95             is => 'ro',
96             isa => PositiveOrZeroInt,
97             default => 0,
98             );
99              
100             has http_tiny => (
101             is => 'lazy',
102             isa => InstanceOf[ 'HTTP::Tiny' ],
103             );
104             sub _build_http_tiny {
105 0     0   0 return HTTP::Tiny->new();
106             }
107              
108             has json => (
109             is => 'lazy',
110             isa => HasMethods[ 'encode', 'decode' ],
111             );
112             sub _build_json {
113 2     2   109 return JSON->new->utf8->allow_nonref();
114             }
115              
116             has http_tiny_request => (
117             is => 'ro',
118             writer => '_set_request',
119             clearer => '_clear_request',
120             init_arg => undef,
121             );
122              
123             has http_tiny_response => (
124             is => 'ro',
125             writer => '_set_response',
126             clearer => '_clear_response',
127             init_arg => undef,
128             );
129              
130             # The purpose of this method is for tests to have a place to inject themselves.
131             sub _http_tiny_request {
132 0     0   0 my ($self, $req_method, $req) = @_;
133              
134 0         0 return $self->http_tiny->$req_method( @$req );
135             }
136              
137             sub request {
138 11     11 0 3074 my ($self, $verb, $raw_path, $path_vars, $options) = @_;
139              
140 11         175 $self->_clear_request();
141 11         229 $self->_clear_response();
142              
143 11 50       59 $options = { %{ $options || {} } };
  11         50  
144 11         28 my $query = delete $options->{query};
145 11         18 my $content = delete $options->{content};
146 11 50       16 my $headers = $options->{headers} = { %{ $options->{headers} || {} } };
  11         37  
147              
148             # Convert foo/:bar/baz into foo/%s/baz.
149 11         21 my $path = $raw_path;
150 11         28 $path =~ s{:[^/]+}{%s}g;
151             # sprintf will throw if the number of %s doesn't match the size of @$path_vars.
152             # Might be nice to catch that and provide a better error message, but that should
153             # never happen as the API methods verify the argument size before we get here.
154 11 100       29 $path = sprintf($path, (map { uri_escape($_) } @$path_vars)) if @$path_vars;
  2         9  
155              
156 11         104 $log->tracef( 'Making %s request against %s', $verb, $path );
157              
158 11         212 my $url = $self->_clean_base_url->clone();
159 11         9427 $url->path( $url->path() . '/' . $path );
160 11 50       461 $url->query_form( $query ) if defined $query;
161 11         38 $url = "$url"; # No more changes to the url from this point forward.
162              
163 11         76 my $req_method = 'request';
164 11         26 my $req = [ $verb, $url, $options ];
165              
166 11 50 66     46 if ($verb eq 'POST' and ref($content) eq 'HASH' and $content->{file}) {
      66        
167 0         0 $content = { %$content };
168 0         0 my $file = path( delete $content->{file} );
169              
170 0 0 0     0 unless (-f $file and -r $file) {
171 0         0 local $Carp::Internal{ 'GitLab::API::v4' } = 1;
172 0         0 local $Carp::Internal{ 'GitLab::API::v4::RESTClient' } = 1;
173 0         0 croak "File $file is not readable";
174             }
175              
176             # Might as well mask the filename, but leave the extension.
177 0         0 my $filename = $file->basename(); # foo/bar.txt => bar.txt
178 0         0 $filename =~ s{^.*?(\.[^.]+|)}{upload$1}; # bar.txt => upload.txt
179              
180 0         0 my $data = {
181             file => {
182             filename => $filename,
183             content => $file->slurp(),
184             },
185             };
186              
187 0         0 $req->[0] = $req->[1]; # Replace method with url.
188 0         0 $req->[1] = $data; # Put data where url was.
189             # So, req went from [$verb,$url,$options] to [$url,$data,$options],
190             # per the post_multipart interface.
191              
192 0         0 $req_method = 'post_multipart';
193 0 0       0 $content = undef if ! %$content;
194             }
195              
196 11 100       25 if (ref $content) {
197 4         71 $content = $self->json->encode( $content );
198 4         49 $headers->{'content-type'} = 'application/json';
199 4         8 $headers->{'content-length'} = length( $content );
200             }
201              
202 11 100       28 $options->{content} = $content if defined $content;
203              
204 11         38 $self->_set_request( $req );
205              
206 11         13 my $res;
207 11         29 my $tries_left = $self->retries();
208 11         18 do {
209 11         33 $res = $self->_http_tiny_request( $req_method, $req );
210 11 50       35 if ($res->{status} =~ m{^5}) {
211 0         0 $tries_left--;
212 0 0       0 $log->warn('Request failed; retrying...') if $tries_left > 0;
213             }
214             else {
215 11         29 $tries_left = 0
216             }
217             } while $tries_left > 0;
218              
219 11         33 $self->_set_response( $res );
220              
221 11 50 33     47 if ($res->{status} eq '404' and $verb eq 'GET') {
222 0         0 return undef;
223             }
224              
225             # Special case for:
226             # https://github.com/bluefeet/GitLab-API-v4/issues/35#issuecomment-515533017
227 11 0 33     35 if ($res->{status} eq '403' and $verb eq 'GET' and $raw_path eq 'projects/:project_id/releases/:tag_name') {
      33        
228 0         0 return undef;
229             }
230              
231 11 50       23 if ($res->{success}) {
232 11 100       34 return undef if $res->{status} eq '204';
233              
234 6         9 my $decode = $options->{decode};
235 6 50       14 $decode = 1 if !defined $decode;
236 6 50       13 return $res->{content} if !$decode;
237              
238             return try{
239 6     6   405 $self->json->decode( $res->{content} );
240             }
241             catch {
242             croakf(
243             'Error decoding JSON (%s %s %s): ',
244 0     0     $verb, $url, $res->{status}, $_,
245             );
246 6         51 };
247             }
248              
249 0   0       my $glimpse = $res->{content} || '';
250 0           $glimpse =~ s{\s+}{ }g;
251 0 0         if ( length($glimpse) > 50 ) {
252 0           $glimpse = substr( $glimpse, 0, 50 );
253 0           $glimpse .= '...';
254             }
255              
256             croakf(
257             'Error %sing %s (HTTP %s): %s %s',
258             $verb, $url,
259 0   0       $res->{status}, ($res->{reason} || 'Unknown'),
260             $glimpse,
261             );
262             }
263              
264             1;
265             __END__