File Coverage

blib/lib/API/Docker/Role/HTTP.pm
Criterion Covered Total %
statement 28 154 18.1
branch 2 66 3.0
condition 0 19 0.0
subroutine 10 17 58.8
pod 4 4 100.0
total 44 260 16.9


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.001';
4 7     7   68004 use Moo::Role;
  7         14  
  7         43  
5 7     7   6306 use IO::Socket::UNIX;
  7         141044  
  7         45  
6 7     7   3520 use IO::Socket::INET;
  7         22  
  7         52  
7 7     7   6011 use JSON::MaybeXS qw( encode_json decode_json );
  7         6999  
  7         513  
8 7     7   42 use Carp qw( croak );
  7         16  
  7         356  
9 7     7   44 use Log::Any qw( $log );
  7         13  
  7         151  
10 7     7   2011 use namespace::clean;
  7         15  
  7         63  
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 $request .= "\r\n";
99 0 0       0 $request .= $body_content if $body_content;
100              
101 0         0 my $sock = $self->_reconnect;
102 0         0 print $sock $request;
103              
104 0         0 my $response = $self->_read_response($sock);
105 0         0 close $sock;
106 0         0 $self->_clear_socket;
107              
108 0         0 my ($status_code, $status_text, $headers, $body) = @$response;
109              
110 0         0 $log->debugf("Response: %s %s", $status_code, $status_text);
111              
112 0 0       0 if ($status_code >= 400) {
113 0         0 my $error_msg = $body;
114 0 0 0     0 if ($body && $body =~ /^\s*[\{\[]/) {
115 0         0 eval {
116 0         0 my $data = decode_json($body);
117 0   0     0 $error_msg = $data->{message} // $body;
118             };
119             }
120 0         0 croak "Docker API error ($status_code): $error_msg";
121             }
122              
123 0 0 0     0 if ($status_code == 204 || !defined($body) || $body eq '') {
      0        
124 0         0 return undef;
125             }
126              
127 0 0       0 if ($body =~ /^\s*[\{\[]/) {
128 0         0 my $result = eval { decode_json($body) };
  0         0  
129 0 0       0 return $result if defined $result;
130              
131             # Streaming endpoints (e.g. /build, /images/create) return
132             # newline-delimited JSON objects. Parse each line separately.
133 0         0 my @objects;
134 0         0 for my $line (split /\r?\n/, $body) {
135 0 0       0 next unless $line =~ /\S/;
136 0         0 my $obj = eval { decode_json($line) };
  0         0  
137 0 0       0 push @objects, $obj if defined $obj;
138             }
139 0 0       0 return \@objects if @objects;
140             }
141              
142 0         0 return $body;
143             }
144              
145             sub _read_response {
146 0     0   0 my ($self, $sock) = @_;
147              
148 0         0 my $status_line = <$sock>;
149 0 0       0 croak "No response from Docker daemon" unless defined $status_line;
150 0         0 $status_line =~ s/\r?\n$//;
151              
152 0         0 my ($proto, $status_code, $status_text) = split /\s+/, $status_line, 3;
153              
154 0         0 my %headers;
155 0         0 while (my $line = <$sock>) {
156 0         0 $line =~ s/\r?\n$//;
157 0 0       0 last if $line eq '';
158 0 0       0 if ($line =~ /^([^:]+):\s*(.*)$/) {
159 0         0 $headers{lc $1} = $2;
160             }
161             }
162              
163 0         0 my $body = '';
164 0 0 0     0 if ($headers{'transfer-encoding'} && $headers{'transfer-encoding'} eq 'chunked') {
    0          
165 0         0 $body = $self->_read_chunked($sock);
166             }
167             elsif (defined $headers{'content-length'}) {
168 0         0 my $len = $headers{'content-length'};
169 0 0       0 if ($len > 0) {
170 0         0 my $read = 0;
171 0         0 while ($read < $len) {
172 0         0 my $buf;
173 0         0 my $n = read($sock, $buf, $len - $read);
174 0 0       0 last unless $n;
175 0         0 $body .= $buf;
176 0         0 $read += $n;
177             }
178             }
179             }
180             else {
181 0         0 local $/;
182 0   0     0 $body = <$sock> // '';
183             }
184              
185 0         0 return [$status_code, $status_text, \%headers, $body];
186             }
187              
188             sub _read_chunked {
189 0     0   0 my ($self, $sock) = @_;
190 0         0 my $body = '';
191              
192 0         0 while (1) {
193 0         0 my $chunk_header = <$sock>;
194 0 0       0 last unless defined $chunk_header;
195 0         0 $chunk_header =~ s/\r?\n$//;
196 0         0 my $chunk_size = hex($chunk_header);
197 0 0       0 last if $chunk_size == 0;
198              
199 0         0 my $chunk = '';
200 0         0 my $read = 0;
201 0         0 while ($read < $chunk_size) {
202 0         0 my $buf;
203 0         0 my $n = read($sock, $buf, $chunk_size - $read);
204 0 0       0 last unless $n;
205 0         0 $chunk .= $buf;
206 0         0 $read += $n;
207             }
208 0         0 $body .= $chunk;
209              
210             # Read trailing \r\n after chunk data
211 0         0 <$sock>;
212             }
213              
214 0         0 return $body;
215             }
216              
217             sub _uri_encode {
218 0     0   0 my ($str) = @_;
219 0         0 $str =~ s/([^A-Za-z0-9\-_.~:\/])/sprintf("%%%02X", ord($1))/ge;
  0         0  
220 0         0 return $str;
221             }
222              
223             sub get {
224 18     18 1 66 my ($self, $path, %opts) = @_;
225 18         87 return $self->_request('GET', $path, %opts);
226             }
227              
228              
229             sub post {
230 11     11 1 37 my ($self, $path, $body, %opts) = @_;
231 11 100       37 $opts{body} = $body if defined $body;
232 11         46 return $self->_request('POST', $path, %opts);
233             }
234              
235              
236             sub put {
237 0     0 1 0 my ($self, $path, $body, %opts) = @_;
238 0 0       0 $opts{body} = $body if defined $body;
239 0         0 return $self->_request('PUT', $path, %opts);
240             }
241              
242              
243             sub delete_request {
244 4     4 1 17 my ($self, $path, %opts) = @_;
245 4         22 return $self->_request('DELETE', $path, %opts);
246             }
247              
248              
249              
250             1;
251              
252             __END__