File Coverage

blib/lib/API/Docker/Role/HTTP.pm
Criterion Covered Total %
statement 28 161 17.3
branch 2 70 2.8
condition 0 19 0.0
subroutine 10 17 58.8
pod 4 4 100.0
total 44 271 16.2


line stmt bran cond sub pod time code
1             package API::Docker::Role::HTTP;
2             # ABSTRACT: HTTP transport role for Docker Engine API
3             our $VERSION = '0.002';
4 8     8   88739 use Moo::Role;
  8         18  
  8         54  
5 8     8   8017 use IO::Socket::UNIX;
  8         174077  
  8         49  
6 8     8   3872 use IO::Socket::INET;
  8         31  
  8         62  
7 8     8   6305 use JSON::MaybeXS qw( encode_json decode_json );
  8         6949  
  8         645  
8 8     8   53 use Carp qw( croak );
  8         13  
  8         378  
9 8     8   76 use Log::Any qw( $log );
  8         23  
  8         80  
10 8     8   2161 use namespace::clean;
  8         28  
  8         65  
11              
12              
13             requires 'host';
14             requires 'api_version';
15              
16             has _socket => (
17             is => 'lazy',
18             clearer => '_clear_socket',
19             );
20              
21             sub _build__socket {
22 0     0   0 my ($self) = @_;
23 0         0 my $host = $self->host;
24              
25 0 0       0 if ($host =~ m{^unix://(.+)$}) {
    0          
26 0         0 my $path = $1;
27 0         0 $log->debugf("Connecting to Unix socket: %s", $path);
28 0         0 my $sock = IO::Socket::UNIX->new(
29             Peer => $path,
30             Type => SOCK_STREAM,
31             );
32 0 0       0 croak "Cannot connect to Unix socket $path: $!" unless $sock;
33 0         0 return $sock;
34             }
35             elsif ($host =~ m{^tcp://([^:]+):(\d+)$}) {
36 0         0 my ($addr, $port) = ($1, $2);
37 0         0 $log->debugf("Connecting to TCP %s:%s", $addr, $port);
38 0         0 my $sock = IO::Socket::INET->new(
39             PeerAddr => $addr,
40             PeerPort => $port,
41             Proto => 'tcp',
42             );
43 0 0       0 croak "Cannot connect to $addr:$port: $!" unless $sock;
44 0         0 return $sock;
45             }
46             else {
47 0         0 croak "Unsupported host format: $host (expected unix:// or tcp://)";
48             }
49             }
50              
51             sub _reconnect {
52 0     0   0 my ($self) = @_;
53 0         0 $self->_clear_socket;
54 0         0 return $self->_socket;
55             }
56              
57             sub _request {
58 0     0   0 my ($self, $method, $path, %opts) = @_;
59              
60 0         0 my $version = $self->api_version;
61 0 0       0 my $url_path = defined $version ? "/v$version$path" : $path;
62              
63 0         0 my $body_content = '';
64 0         0 my $content_type = 'application/json';
65 0 0       0 if ($opts{raw_body}) {
    0          
66 0         0 $body_content = $opts{raw_body};
67 0   0     0 $content_type = $opts{content_type} // 'application/x-tar';
68             }
69             elsif ($opts{body}) {
70 0         0 $body_content = encode_json($opts{body});
71             }
72              
73 0 0       0 if ($opts{params}) {
74 0         0 my @pairs;
75 0         0 for my $k (sort keys %{$opts{params}}) {
  0         0  
76 0         0 my $v = $opts{params}{$k};
77 0 0       0 next unless defined $v;
78 0 0       0 if (ref $v eq 'HASH') {
79 0         0 $v = encode_json($v);
80             }
81 0         0 push @pairs, _uri_encode($k) . '=' . _uri_encode($v);
82             }
83 0 0       0 $url_path .= '?' . join('&', @pairs) if @pairs;
84             }
85              
86 0         0 $log->debugf("%s %s", $method, $url_path);
87              
88 0         0 my $request = "$method $url_path HTTP/1.1\r\n";
89 0         0 $request .= "Host: localhost\r\n";
90 0         0 $request .= "Connection: close\r\n";
91 0         0 $request .= "User-Agent: API-Docker\r\n";
92              
93 0 0       0 if ($body_content) {
94 0         0 $request .= "Content-Type: $content_type\r\n";
95 0         0 $request .= "Content-Length: " . length($body_content) . "\r\n";
96             }
97              
98 0 0       0 if ($opts{headers}) {
99 0         0 for my $h (sort keys %{$opts{headers}}) {
  0         0  
100 0         0 my $v = $opts{headers}{$h};
101 0 0       0 next unless defined $v;
102 0         0 $v =~ s/[\r\n]//g;
103 0         0 $request .= "$h: $v\r\n";
104             }
105             }
106              
107 0         0 $request .= "\r\n";
108 0 0       0 $request .= $body_content if $body_content;
109              
110 0         0 my $sock = $self->_reconnect;
111 0         0 print $sock $request;
112              
113 0         0 my $response = $self->_read_response($sock);
114 0         0 close $sock;
115 0         0 $self->_clear_socket;
116              
117 0         0 my ($status_code, $status_text, $headers, $body) = @$response;
118              
119 0         0 $log->debugf("Response: %s %s", $status_code, $status_text);
120              
121 0 0       0 if ($status_code >= 400) {
122 0         0 my $error_msg = $body;
123 0 0 0     0 if ($body && $body =~ /^\s*[\{\[]/) {
124 0         0 eval {
125 0         0 my $data = decode_json($body);
126 0   0     0 $error_msg = $data->{message} // $body;
127             };
128             }
129 0         0 croak "Docker API error ($status_code): $error_msg";
130             }
131              
132 0 0 0     0 if ($status_code == 204 || !defined($body) || $body eq '') {
      0        
133 0         0 return undef;
134             }
135              
136 0 0       0 if ($body =~ /^\s*[\{\[]/) {
137 0         0 my $result = eval { decode_json($body) };
  0         0  
138 0 0       0 return $result if defined $result;
139              
140             # Streaming endpoints (e.g. /build, /images/create) return
141             # newline-delimited JSON objects. Parse each line separately.
142 0         0 my @objects;
143 0         0 for my $line (split /\r?\n/, $body) {
144 0 0       0 next unless $line =~ /\S/;
145 0         0 my $obj = eval { decode_json($line) };
  0         0  
146 0 0       0 push @objects, $obj if defined $obj;
147             }
148 0 0       0 return \@objects if @objects;
149             }
150              
151 0         0 return $body;
152             }
153              
154             sub _read_response {
155 0     0   0 my ($self, $sock) = @_;
156              
157 0         0 my $status_line = <$sock>;
158 0 0       0 croak "No response from Docker daemon" unless defined $status_line;
159 0         0 $status_line =~ s/\r?\n$//;
160              
161 0         0 my ($proto, $status_code, $status_text) = split /\s+/, $status_line, 3;
162              
163 0         0 my %headers;
164 0         0 while (my $line = <$sock>) {
165 0         0 $line =~ s/\r?\n$//;
166 0 0       0 last if $line eq '';
167 0 0       0 if ($line =~ /^([^:]+):\s*(.*)$/) {
168 0         0 $headers{lc $1} = $2;
169             }
170             }
171              
172 0         0 my $body = '';
173 0 0 0     0 if ($headers{'transfer-encoding'} && $headers{'transfer-encoding'} eq 'chunked') {
    0          
174 0         0 $body = $self->_read_chunked($sock);
175             }
176             elsif (defined $headers{'content-length'}) {
177 0         0 my $len = $headers{'content-length'};
178 0 0       0 if ($len > 0) {
179 0         0 my $read = 0;
180 0         0 while ($read < $len) {
181 0         0 my $buf;
182 0         0 my $n = read($sock, $buf, $len - $read);
183 0 0       0 last unless $n;
184 0         0 $body .= $buf;
185 0         0 $read += $n;
186             }
187             }
188             }
189             else {
190 0         0 local $/;
191 0   0     0 $body = <$sock> // '';
192             }
193              
194 0         0 return [$status_code, $status_text, \%headers, $body];
195             }
196              
197             sub _read_chunked {
198 0     0   0 my ($self, $sock) = @_;
199 0         0 my $body = '';
200              
201 0         0 while (1) {
202 0         0 my $chunk_header = <$sock>;
203 0 0       0 last unless defined $chunk_header;
204 0         0 $chunk_header =~ s/\r?\n$//;
205 0         0 my $chunk_size = hex($chunk_header);
206 0 0       0 last if $chunk_size == 0;
207              
208 0         0 my $chunk = '';
209 0         0 my $read = 0;
210 0         0 while ($read < $chunk_size) {
211 0         0 my $buf;
212 0         0 my $n = read($sock, $buf, $chunk_size - $read);
213 0 0       0 last unless $n;
214 0         0 $chunk .= $buf;
215 0         0 $read += $n;
216             }
217 0         0 $body .= $chunk;
218              
219             # Read trailing \r\n after chunk data
220 0         0 <$sock>;
221             }
222              
223 0         0 return $body;
224             }
225              
226             sub _uri_encode {
227 0     0   0 my ($str) = @_;
228 0         0 $str =~ s/([^A-Za-z0-9\-_.~:\/])/sprintf("%%%02X", ord($1))/ge;
  0         0  
229 0         0 return $str;
230             }
231              
232             sub get {
233 18     18 1 72 my ($self, $path, %opts) = @_;
234 18         90 return $self->_request('GET', $path, %opts);
235             }
236              
237              
238             sub post {
239 12     12 1 41 my ($self, $path, $body, %opts) = @_;
240 12 100       39 $opts{body} = $body if defined $body;
241 12         48 return $self->_request('POST', $path, %opts);
242             }
243              
244              
245             sub put {
246 0     0 1 0 my ($self, $path, $body, %opts) = @_;
247 0 0       0 $opts{body} = $body if defined $body;
248 0         0 return $self->_request('PUT', $path, %opts);
249             }
250              
251              
252             sub delete_request {
253 4     4 1 18 my ($self, $path, %opts) = @_;
254 4         21 return $self->_request('DELETE', $path, %opts);
255             }
256              
257              
258              
259             1;
260              
261             __END__