File Coverage

blib/lib/WWW/Docker/Role/HTTP.pm
Criterion Covered Total %
statement 28 223 12.5
branch 2 102 1.9
condition 0 23 0.0
subroutine 10 18 55.5
pod 0 5 0.0
total 40 371 10.7


line stmt bran cond sub pod time code
1             package WWW::Docker::Role::HTTP;
2             # ABSTRACT: HTTP transport role for Docker Engine API
3              
4 10     10   119857 use Moo::Role;
  10         25  
  10         72  
5 10     10   11739 use IO::Socket::UNIX;
  10         236410  
  10         68  
6 10     10   5414 use IO::Socket::INET;
  10         28  
  10         70  
7 10     10   10508 use JSON::MaybeXS qw( encode_json decode_json );
  10         43448  
  10         786  
8 10     10   98 use Carp qw( croak );
  10         33  
  10         585  
9 10     10   82 use Log::Any qw( $log );
  10         18  
  10         108  
10 10     10   2870 use namespace::clean;
  10         21  
  10         96  
11              
12             our $VERSION = '0.101';
13              
14             =head1 SYNOPSIS
15              
16             package MyDockerClient;
17             use Moo;
18              
19             has host => (is => 'ro', required => 1);
20             has api_version => (is => 'ro');
21              
22             with 'WWW::Docker::Role::HTTP';
23              
24             # Now use get, post, put, delete_request methods
25             my $data = $self->get('/containers/json');
26              
27             =head1 DESCRIPTION
28              
29             This role provides HTTP transport for the Docker Engine API. It implements
30             HTTP/1.1 communication over Unix sockets and TCP sockets without depending on
31             heavy HTTP client libraries like LWP.
32              
33             Features:
34              
35             =over
36              
37             =item * Unix socket transport (C)
38              
39             =item * TCP socket transport (C)
40              
41             =item * HTTP/1.1 chunked transfer encoding
42              
43             =item * Automatic JSON encoding/decoding
44              
45             =item * Request/response logging via L
46              
47             =item * Automatic connection management
48              
49             =back
50              
51             Consuming classes must provide C and C attributes.
52              
53             =cut
54              
55             requires 'host';
56             requires 'api_version';
57              
58             has _socket => (
59             is => 'lazy',
60             clearer => '_clear_socket',
61             );
62              
63             sub _build__socket {
64 0     0   0 my ($self) = @_;
65 0         0 my $host = $self->host;
66              
67 0 0       0 if ($host =~ m{^unix://(.+)$}) {
    0          
68 0         0 my $path = $1;
69 0         0 $log->debugf("Connecting to Unix socket: %s", $path);
70 0         0 my $sock = IO::Socket::UNIX->new(
71             Peer => $path,
72             Type => SOCK_STREAM,
73             );
74 0 0       0 croak "Cannot connect to Unix socket $path: $!" unless $sock;
75 0         0 return $sock;
76             }
77             elsif ($host =~ m{^tcp://([^:]+):(\d+)$}) {
78 0         0 my ($addr, $port) = ($1, $2);
79 0         0 $log->debugf("Connecting to TCP %s:%s", $addr, $port);
80 0         0 my $sock = IO::Socket::INET->new(
81             PeerAddr => $addr,
82             PeerPort => $port,
83             Proto => 'tcp',
84             );
85 0 0       0 croak "Cannot connect to $addr:$port: $!" unless $sock;
86 0         0 return $sock;
87             }
88             else {
89 0         0 croak "Unsupported host format: $host (expected unix:// or tcp://)";
90             }
91             }
92              
93             sub _reconnect {
94 0     0   0 my ($self) = @_;
95 0         0 $self->_clear_socket;
96 0         0 return $self->_socket;
97             }
98              
99             sub _request {
100 0     0   0 my ($self, $method, $path, %opts) = @_;
101              
102 0         0 my $version = $self->api_version;
103 0 0       0 my $url_path = defined $version ? "/v$version$path" : $path;
104              
105 0         0 my $body_content = '';
106 0         0 my $content_type = 'application/json';
107 0 0       0 if ($opts{raw_body}) {
    0          
108 0         0 $body_content = $opts{raw_body};
109 0   0     0 $content_type = $opts{content_type} // 'application/x-tar';
110             }
111             elsif ($opts{body}) {
112 0         0 $body_content = encode_json($opts{body});
113             }
114              
115 0 0       0 if ($opts{params}) {
116 0         0 my @pairs;
117 0         0 for my $k (sort keys %{$opts{params}}) {
  0         0  
118 0         0 my $v = $opts{params}{$k};
119 0 0       0 next unless defined $v;
120 0 0       0 if (ref $v eq 'HASH') {
121 0         0 $v = encode_json($v);
122             }
123 0         0 push @pairs, _uri_encode($k) . '=' . _uri_encode($v);
124             }
125 0 0       0 $url_path .= '?' . join('&', @pairs) if @pairs;
126             }
127              
128 0         0 $log->debugf("%s %s", $method, $url_path);
129              
130 0         0 my $request = "$method $url_path HTTP/1.1\r\n";
131 0         0 $request .= "Host: localhost\r\n";
132 0         0 $request .= "Connection: close\r\n";
133 0         0 $request .= "User-Agent: WWW-Docker/$VERSION\r\n";
134              
135 0 0       0 if ($body_content) {
136 0         0 $request .= "Content-Type: $content_type\r\n";
137 0         0 $request .= "Content-Length: " . length($body_content) . "\r\n";
138             }
139              
140 0         0 $request .= "\r\n";
141 0 0       0 $request .= $body_content if $body_content;
142              
143 0         0 my $sock = $self->_reconnect;
144 0         0 print $sock $request;
145              
146 0         0 my $response = $self->_read_response($sock);
147 0         0 close $sock;
148 0         0 $self->_clear_socket;
149              
150 0         0 my ($status_code, $status_text, $headers, $body) = @$response;
151              
152 0         0 $log->debugf("Response: %s %s", $status_code, $status_text);
153              
154 0 0       0 if ($status_code >= 400) {
155 0         0 my $error_msg = $body;
156 0 0 0     0 if ($body && $body =~ /^\s*[\{\[]/) {
157 0         0 eval {
158 0         0 my $data = decode_json($body);
159 0   0     0 $error_msg = $data->{message} // $body;
160             };
161             }
162 0         0 croak "Docker API error ($status_code): $error_msg";
163             }
164              
165 0 0 0     0 if ($status_code == 204 || !defined($body) || $body eq '') {
      0        
166 0         0 return undef;
167             }
168              
169 0 0       0 if ($body =~ /^\s*[\{\[]/) {
170 0         0 my $result = eval { decode_json($body) };
  0         0  
171 0 0       0 return $result if defined $result;
172              
173             # Streaming endpoints (e.g. /build, /images/create) return
174             # newline-delimited JSON objects. Parse each line separately.
175 0         0 my @objects;
176 0         0 for my $line (split /\r?\n/, $body) {
177 0 0       0 next unless $line =~ /\S/;
178 0         0 my $obj = eval { decode_json($line) };
  0         0  
179 0 0       0 push @objects, $obj if defined $obj;
180             }
181 0 0       0 return \@objects if @objects;
182             }
183              
184 0         0 return $body;
185             }
186              
187             sub _read_response {
188 0     0   0 my ($self, $sock) = @_;
189              
190             # Read status line
191 0         0 my $status_line = <$sock>;
192 0 0       0 croak "No response from Docker daemon" unless defined $status_line;
193 0         0 $status_line =~ s/\r?\n$//;
194              
195 0         0 my ($proto, $status_code, $status_text) = split /\s+/, $status_line, 3;
196              
197             # Read headers
198 0         0 my %headers;
199 0         0 while (my $line = <$sock>) {
200 0         0 $line =~ s/\r?\n$//;
201 0 0       0 last if $line eq '';
202 0 0       0 if ($line =~ /^([^:]+):\s*(.*)$/) {
203 0         0 $headers{lc $1} = $2;
204             }
205             }
206              
207             # Read body
208 0         0 my $body = '';
209 0 0 0     0 if ($headers{'transfer-encoding'} && $headers{'transfer-encoding'} eq 'chunked') {
    0          
210 0         0 $body = $self->_read_chunked($sock);
211             }
212             elsif (defined $headers{'content-length'}) {
213 0         0 my $len = $headers{'content-length'};
214 0 0       0 if ($len > 0) {
215 0         0 my $read = 0;
216 0         0 while ($read < $len) {
217 0         0 my $buf;
218 0         0 my $n = read($sock, $buf, $len - $read);
219 0 0       0 last unless $n;
220 0         0 $body .= $buf;
221 0         0 $read += $n;
222             }
223             }
224             }
225             else {
226             # Read until EOF
227 0         0 local $/;
228 0   0     0 $body = <$sock> // '';
229             }
230              
231 0         0 return [$status_code, $status_text, \%headers, $body];
232             }
233              
234             sub _read_chunked {
235 0     0   0 my ($self, $sock) = @_;
236 0         0 my $body = '';
237              
238 0         0 while (1) {
239 0         0 my $chunk_header = <$sock>;
240 0 0       0 last unless defined $chunk_header;
241 0         0 $chunk_header =~ s/\r?\n$//;
242 0         0 my $chunk_size = hex($chunk_header);
243 0 0       0 last if $chunk_size == 0;
244              
245 0         0 my $chunk = '';
246 0         0 my $read = 0;
247 0         0 while ($read < $chunk_size) {
248 0         0 my $buf;
249 0         0 my $n = read($sock, $buf, $chunk_size - $read);
250 0 0       0 last unless $n;
251 0         0 $chunk .= $buf;
252 0         0 $read += $n;
253             }
254 0         0 $body .= $chunk;
255              
256             # Read trailing \r\n after chunk data
257 0         0 <$sock>;
258             }
259              
260 0         0 return $body;
261             }
262              
263             sub _uri_encode {
264 0     0   0 my ($str) = @_;
265 0         0 $str =~ s/([^A-Za-z0-9\-_.~:\/])/sprintf("%%%02X", ord($1))/ge;
  0         0  
266 0         0 return $str;
267             }
268              
269             sub get {
270 18     18 0 78 my ($self, $path, %opts) = @_;
271 18         97 return $self->_request('GET', $path, %opts);
272             }
273              
274             =method get
275              
276             my $data = $client->get($path, %opts);
277              
278             Perform HTTP GET request. Returns decoded JSON or raw response body.
279              
280             Options: C (hashref of query parameters).
281              
282             =cut
283              
284             sub stream_get {
285 0     0 0 0 my ($self, $path, %opts) = @_;
286             my $callback = delete $opts{callback}
287 0 0       0 or croak 'stream_get requires a callback option';
288              
289 0         0 my $version = $self->api_version;
290 0 0       0 my $url_path = defined $version ? "/v$version$path" : $path;
291              
292 0 0       0 if ($opts{params}) {
293 0         0 my @pairs;
294 0         0 for my $k (sort keys %{$opts{params}}) {
  0         0  
295 0         0 my $v = $opts{params}{$k};
296 0 0       0 next unless defined $v;
297 0 0       0 if (ref $v eq 'HASH') { $v = encode_json($v) }
  0         0  
298 0         0 push @pairs, _uri_encode($k) . '=' . _uri_encode($v);
299             }
300 0 0       0 $url_path .= '?' . join('&', @pairs) if @pairs;
301             }
302              
303 0         0 $log->debugf("GET (stream) %s", $url_path);
304              
305 0         0 my $request = "GET $url_path HTTP/1.1\r\n";
306 0         0 $request .= "Host: localhost\r\n";
307 0         0 $request .= "Connection: close\r\n";
308 0         0 $request .= "User-Agent: WWW-Docker/$VERSION\r\n";
309 0         0 $request .= "\r\n";
310              
311 0         0 my $sock = $self->_reconnect;
312 0         0 print $sock $request;
313              
314             # Read status line
315 0         0 my $status_line = <$sock>;
316 0 0       0 croak "No response from Docker daemon" unless defined $status_line;
317 0         0 $status_line =~ s/\r?\n$//;
318 0         0 my (undef, $status_code) = split /\s+/, $status_line, 3;
319              
320             # Read headers
321 0         0 my %headers;
322 0         0 while (my $line = <$sock>) {
323 0         0 $line =~ s/\r?\n$//;
324 0 0       0 last if $line eq '';
325 0 0       0 if ($line =~ /^([^:]+):\s*(.*)$/) {
326 0         0 $headers{lc $1} = $2;
327             }
328             }
329              
330 0 0       0 if ($status_code >= 400) {
331 0         0 local $/;
332 0   0     0 my $body = <$sock> // '';
333 0         0 close $sock;
334 0         0 $self->_clear_socket;
335 0         0 croak "Docker API error ($status_code): $body";
336             }
337              
338             # Stream NDJSON body line by line, calling $callback for each parsed object.
339 0   0     0 my $chunked = (($headers{'transfer-encoding'} // '') eq 'chunked');
340 0 0       0 if ($chunked) {
341 0         0 while (1) {
342 0         0 my $chunk_header = <$sock>;
343 0 0       0 last unless defined $chunk_header;
344 0         0 $chunk_header =~ s/\r?\n$//;
345 0         0 my $chunk_size = hex($chunk_header);
346 0 0       0 last if $chunk_size == 0;
347              
348 0         0 my $chunk = '';
349 0         0 my $read = 0;
350 0         0 while ($read < $chunk_size) {
351 0         0 my $buf;
352 0         0 my $n = read($sock, $buf, $chunk_size - $read);
353 0 0       0 last unless $n;
354 0         0 $chunk .= $buf;
355 0         0 $read += $n;
356             }
357 0         0 <$sock>; # trailing CRLF after chunk data
358              
359 0         0 for my $line (split /\r?\n/, $chunk) {
360 0 0       0 next unless $line =~ /\S/;
361 0         0 my $obj = eval { decode_json($line) };
  0         0  
362 0 0       0 $callback->($obj) if defined $obj;
363             }
364             }
365             }
366             else {
367 0         0 while (my $line = <$sock>) {
368 0         0 $line =~ s/\r?\n$//;
369 0 0       0 next unless $line =~ /\S/;
370 0         0 my $obj = eval { decode_json($line) };
  0         0  
371 0 0       0 $callback->($obj) if defined $obj;
372             }
373             }
374              
375 0         0 close $sock;
376 0         0 $self->_clear_socket;
377 0         0 return;
378             }
379              
380             =method stream_get
381              
382             $client->stream_get($path, params => \%params, callback => sub {
383             my ($event) = @_;
384             # $event is a decoded hashref for each NDJSON line
385             });
386              
387             Perform a streaming HTTP GET request, reading the response body incrementally.
388             The C option is required and is invoked once per decoded JSON object
389             as they arrive from the socket. This is suitable for long-lived Docker
390             endpoints such as C that send an unbounded NDJSON stream.
391              
392             Options: C (hashref of query parameters), C (required CodeRef).
393              
394             =cut
395              
396             sub post {
397 11     11 0 40 my ($self, $path, $body, %opts) = @_;
398 11 100       37 $opts{body} = $body if defined $body;
399 11         48 return $self->_request('POST', $path, %opts);
400             }
401              
402             =method post
403              
404             my $data = $client->post($path, $body, %opts);
405              
406             Perform HTTP POST request. C<$body> is automatically JSON-encoded if provided.
407              
408             Options: C (hashref of query parameters).
409              
410             =cut
411              
412             sub put {
413 0     0 0 0 my ($self, $path, $body, %opts) = @_;
414 0 0       0 $opts{body} = $body if defined $body;
415 0         0 return $self->_request('PUT', $path, %opts);
416             }
417              
418             =method put
419              
420             my $data = $client->put($path, $body, %opts);
421              
422             Perform HTTP PUT request. C<$body> is automatically JSON-encoded if provided.
423              
424             Options: C (hashref of query parameters).
425              
426             =cut
427              
428             sub delete_request {
429 4     4 0 18 my ($self, $path, %opts) = @_;
430 4         28 return $self->_request('DELETE', $path, %opts);
431             }
432              
433             =method delete_request
434              
435             my $data = $client->delete_request($path, %opts);
436              
437             Perform HTTP DELETE request.
438              
439             Options: C (hashref of query parameters).
440              
441             =cut
442              
443             =seealso
444              
445             =over
446              
447             =item * L - Main client using this role
448              
449             =back
450              
451             =cut
452              
453             1;