File Coverage

blib/lib/Testcontainers/Wait/HTTP.pm
Criterion Covered Total %
statement 18 93 19.3
branch 0 42 0.0
condition 0 5 0.0
subroutine 6 9 66.6
pod 0 1 0.0
total 24 150 16.0


line stmt bran cond sub pod time code
1             package Testcontainers::Wait::HTTP;
2             # ABSTRACT: Wait strategy for HTTP endpoints
3              
4 4     4   32 use strict;
  4         10  
  4         168  
5 4     4   22 use warnings;
  4         8  
  4         236  
6 4     4   24 use Moo;
  4         6  
  4         27  
7 4     4   1624 use Carp qw( croak );
  4         8  
  4         276  
8 4     4   27 use IO::Socket::INET;
  4         6  
  4         40  
9 4     4   2472 use Log::Any qw( $log );
  4         8  
  4         31  
10              
11             our $VERSION = '0.001';
12              
13             with 'Testcontainers::Wait::Base';
14              
15             =head1 SYNOPSIS
16              
17             use Testcontainers::Wait;
18              
19             # Wait for HTTP 200 on /
20             my $wait = Testcontainers::Wait::for_http('/');
21              
22             # Wait with custom options
23             my $wait = Testcontainers::Wait::for_http('/health',
24             port => '8080/tcp',
25             status_code => 200,
26             method => 'GET',
27             );
28              
29             =head1 DESCRIPTION
30              
31             Waits for an HTTP endpoint to return a successful response. Equivalent to
32             Go's C.
33              
34             This wait strategy makes a raw HTTP request (without depending on LWP or
35             HTTP::Tiny) to keep dependencies minimal, matching WWW::Docker's approach.
36              
37             =cut
38              
39             has path => (
40             is => 'ro',
41             default => '/',
42             );
43              
44             =attr path
45              
46             HTTP path to request. Default: C.
47              
48             =cut
49              
50             has port => (
51             is => 'ro',
52             default => undef,
53             );
54              
55             =attr port
56              
57             Container port to connect to. If not set, uses the lowest exposed port.
58              
59             =cut
60              
61             has status_code => (
62             is => 'ro',
63             default => 200,
64             );
65              
66             =attr status_code
67              
68             Expected HTTP status code. Default: 200.
69              
70             =cut
71              
72             has method => (
73             is => 'ro',
74             default => 'GET',
75             );
76              
77             =attr method
78              
79             HTTP method. Default: C.
80              
81             =cut
82              
83             has body => (
84             is => 'ro',
85             default => undef,
86             );
87              
88             has headers => (
89             is => 'ro',
90             default => sub { {} },
91             );
92              
93             has tls => (
94             is => 'ro',
95             default => 0,
96             );
97              
98             has response_matcher => (
99             is => 'ro',
100             default => undef,
101             );
102              
103             =attr response_matcher
104              
105             Optional coderef that receives the response body and returns true/false.
106              
107             =cut
108              
109             sub check {
110 0     0 0   my ($self, $container) = @_;
111              
112 0           my $port = $self->_resolve_port($container);
113 0           my $host = $container->host;
114 0           my $mapped_port = eval { $container->mapped_port($port) };
  0            
115              
116 0 0         unless ($mapped_port) {
117 0           $log->tracef("Port %s not yet mapped", $port);
118 0           return 0;
119             }
120              
121 0 0         my $scheme = $self->tls ? 'https' : 'http';
122 0           my $url = sprintf("%s://%s:%s%s", $scheme, $host, $mapped_port, $self->path);
123              
124 0           $log->tracef("HTTP check: %s %s", $self->method, $url);
125              
126             # Use HTTP::Tiny if available, fallback to raw socket
127 0           my ($status, $response_body) = $self->_do_http_request($host, $mapped_port);
128              
129 0 0         unless (defined $status) {
130 0           return 0;
131             }
132              
133 0           $log->tracef("HTTP response: %d", $status);
134              
135 0 0         if ($status != $self->status_code) {
136 0           return 0;
137             }
138              
139 0 0         if ($self->response_matcher) {
140 0 0         return $self->response_matcher->($response_body) ? 1 : 0;
141             }
142              
143 0           return 1;
144             }
145              
146             =method check($container)
147              
148             Make an HTTP request and check the response. Returns true/false.
149              
150             =cut
151              
152             sub _do_http_request {
153 0     0     my ($self, $host, $port) = @_;
154              
155             # Try HTTP::Tiny first (commonly available)
156 0 0         if (eval { require HTTP::Tiny; 1 }) {
  0            
  0            
157 0           my $http = HTTP::Tiny->new(timeout => 3);
158              
159             # Try both IPv6 and IPv4 to handle Docker port mapping on macOS
160 0 0         my @hosts = $host eq 'localhost' ? ('::1', '127.0.0.1') : ($host);
161 0           for my $try_host (@hosts) {
162 0 0         my $url_host = $try_host =~ /:/ ? "[$try_host]" : $try_host;
163 0 0         my $url = sprintf("%s://%s:%s%s",
164             $self->tls ? 'https' : 'http', $url_host, $port, $self->path);
165              
166 0           my %request_opts;
167 0 0         $request_opts{headers} = $self->headers if %{$self->headers};
  0            
168 0 0         $request_opts{content} = $self->body if defined $self->body;
169              
170 0           my $response = eval { $http->request($self->method, $url, \%request_opts) };
  0            
171 0 0 0       next if $@ || !$response;
172 0 0         next if $response->{status} == 599; # connection error, try next host
173              
174 0           return ($response->{status}, $response->{content});
175             }
176 0           return (undef, undef);
177             }
178              
179             # Fallback to raw socket HTTP
180 0           my $sock = IO::Socket::INET->new(
181             PeerAddr => $host,
182             PeerPort => $port,
183             Proto => 'tcp',
184             Timeout => 3,
185             );
186 0 0         return (undef, undef) unless $sock;
187              
188 0           my $path = $self->path;
189 0           my $method = $self->method;
190 0           my $request = "$method $path HTTP/1.0\r\nHost: $host:$port\r\n";
191              
192 0           for my $key (keys %{$self->headers}) {
  0            
193 0           $request .= "$key: $self->{headers}{$key}\r\n";
194             }
195              
196 0 0         if (defined $self->body) {
197 0           my $len = length($self->body);
198 0           $request .= "Content-Length: $len\r\n\r\n$self->{body}";
199             } else {
200 0           $request .= "\r\n";
201             }
202              
203 0           print $sock $request;
204              
205 0           my $response = '';
206 0           while (my $line = <$sock>) {
207 0           $response .= $line;
208             }
209 0           close($sock);
210              
211 0 0         if ($response =~ m{^HTTP/\d\.\d (\d+)}) {
212 0           my $status = $1;
213 0           my ($body) = $response =~ m{\r\n\r\n(.*)$}s;
214 0   0       return ($status, $body // '');
215             }
216              
217 0           return (undef, undef);
218             }
219              
220             sub _resolve_port {
221 0     0     my ($self, $container) = @_;
222              
223 0 0         if ($self->port) {
224 0           my $port = $self->port;
225 0 0         $port = "$port/tcp" unless $port =~ m{/};
226 0           return $port;
227             }
228              
229             # Use lowest exposed port
230 0           my $ports = $container->request->exposed_ports;
231 0 0         croak "No exposed ports configured and no port specified" unless @$ports;
232              
233             my @sorted = sort {
234 0           my ($a_num) = $a =~ /^(\d+)/;
  0            
235 0           my ($b_num) = $b =~ /^(\d+)/;
236 0           $a_num <=> $b_num;
237             } @$ports;
238              
239 0           my $port = $sorted[0];
240 0 0         $port = "$port/tcp" unless $port =~ m{/};
241 0           return $port;
242             }
243              
244             1;