44
55namespace Codin \HttpClient ;
66
7+ use CurlHandle ;
78use Psr \Http \Client \ClientInterface ;
89use Psr \Http \Message \RequestInterface ;
910use Psr \Http \Message \ResponseFactoryInterface ;
1011use Psr \Http \Message \ResponseInterface ;
1112use Psr \Http \Message \StreamFactoryInterface ;
1213use Psr \Http \Message \StreamInterface ;
1314
14- class HttpClient implements ClientInterface
15+ readonly class HttpClient implements ClientInterface
1516{
1617 public const VERSION = '1.0 ' ;
1718
18- protected ResponseFactoryInterface $ responseFactory ;
19-
20- protected StreamFactoryInterface $ streamFactory ;
21-
22- protected array $ options ;
23-
24- protected bool $ debug ;
25-
26- protected array $ metrics = [];
27-
2819 /**
29- * @var \CurlHandle
20+ * @param array<string, mixed> $options
3021 */
31- protected $ session ;
32-
3322 public function __construct (
34- ResponseFactoryInterface $ responseFactory ,
35- StreamFactoryInterface $ streamFactory ,
36- array $ options = [],
37- bool $ debug = false
23+ private ResponseFactoryInterface $ responseFactory ,
24+ private StreamFactoryInterface $ streamFactory ,
25+ private array $ options = [],
3826 ) {
39- $ this ->responseFactory = $ responseFactory ;
40- $ this ->streamFactory = $ streamFactory ;
41- $ this ->options = $ options ;
42- $ this ->debug = $ debug ;
43- $ this ->session = curl_init ();
4427 }
4528
46- public function __destruct ()
29+ private function parseHeaders ( ResponseInterface $ response , StreamInterface $ headers ): ResponseInterface
4730 {
48- if (is_resource ($ this ->session )) {
49- curl_close ($ this ->session );
31+ $ data = rtrim ((string ) $ headers );
32+ $ parts = explode ("\r\n\r\n" , $ data );
33+ $ last = array_pop ($ parts );
34+ $ lines = explode ("\r\n" , $ last );
35+ $ status = array_shift ($ lines );
36+
37+ if (is_string ($ status ) && strpos ($ status , 'HTTP/ ' ) === 0 ) {
38+ [$ version , $ status , $ message ] = explode (' ' , substr ($ status , strlen ('http/ ' )), 3 );
39+ $ response = $ response ->withProtocolVersion ($ version )->withStatus ((int ) $ status , $ message );
5040 }
41+
42+ return array_reduce ($ lines , static function (ResponseInterface $ response , string $ line ): ResponseInterface {
43+ [$ name , $ value ] = explode (': ' , $ line , 2 );
44+ return $ response ->withHeader ($ name , $ value );
45+ }, $ response );
5146 }
5247
53- public function getMetrics ( ): array
48+ private function buildResponse ( StreamInterface $ headers , StreamInterface $ body ): ResponseInterface
5449 {
55- return $ this ->metrics ;
50+ if ($ body ->isSeekable ()) {
51+ $ body ->rewind ();
52+ }
53+ $ response = $ this ->responseFactory ->createResponse (200 )->withBody ($ body );
54+
55+ return $ this ->parseHeaders ($ response , $ headers );
5656 }
5757
58- protected function buildOptions (RequestInterface $ request ): array
58+ /**
59+ * @return array<int, mixed>
60+ */
61+ private function buildOptions (RequestInterface $ request ): array
5962 {
6063 $ options = [
6164 CURLOPT_URL => (string ) $ request ->getUri (),
@@ -71,16 +74,9 @@ protected function buildOptions(RequestInterface $request): array
7174 CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS ,
7275 CURLOPT_COOKIEFILE => '' ,
7376 CURLOPT_FOLLOWLOCATION => true ,
77+ CURLOPT_CUSTOMREQUEST => $ request ->getMethod (),
7478 ];
7579
76- if ('POST ' === $ request ->getMethod ()) {
77- $ options [CURLOPT_POST ] = true ;
78- } elseif ('HEAD ' === $ request ->getMethod ()) {
79- $ options [CURLOPT_NOBODY ] = true ;
80- } else {
81- $ options [CURLOPT_CUSTOMREQUEST ] = $ request ->getMethod ();
82- }
83-
8480 if ($ request ->getProtocolVersion () === '1.1 ' ) {
8581 $ options [CURLOPT_HTTP_VERSION ] = CURL_HTTP_VERSION_1_1 ;
8682 } elseif ($ request ->getProtocolVersion () === '2.0 ' ) {
@@ -102,15 +98,26 @@ protected function buildOptions(RequestInterface $request): array
10298 }
10399 }
104100
105- if (in_array ($ request ->getMethod (), ['PUT ' , 'POST ' , 'PATCH ' ])) {
106- if ('POST ' !== $ request ->getMethod ()) {
107- $ options [CURLOPT_UPLOAD ] = true ;
101+ if ($ request ->getBody ()->getSize () > 0 ) {
102+ $ size = $ request ->hasHeader ('Content-Length ' )
103+ ? (int ) $ request ->getHeaderLine ('Content-Length ' )
104+ : null ;
105+
106+ $ options [CURLOPT_UPLOAD ] = true ;
107+
108+ // If the Expect header is not present, prevent curl from adding it
109+ if (!$ request ->hasHeader ('Expect ' )) {
110+ $ options [CURLOPT_HTTPHEADER ][] = 'Expect: ' ;
108111 }
109112
110- if ($ request ->hasHeader ('Content-Length ' )) {
111- $ options [CURLOPT_INFILESIZE ] = $ request ->getHeader ('Content-Length ' )[0 ];
112- } elseif (!$ request ->hasHeader ('Transfer-Encoding ' )) {
113- $ options [CURLOPT_HTTPHEADER ][] = 'Transfer-Encoding: chunked ' ;
113+ // cURL sometimes adds a content-type by default. Prevent this.
114+ if (!$ request ->hasHeader ('Content-Type ' )) {
115+ $ options [CURLOPT_HTTPHEADER ][] = 'Content-Type: ' ;
116+ }
117+
118+ if ($ size !== null ) {
119+ $ options [CURLOPT_INFILESIZE ] = $ size ;
120+ $ request = $ request ->withoutHeader ('Content-Length ' );
114121 }
115122
116123 if ($ request ->getBody ()->isSeekable () && $ request ->getBody ()->tell () > 0 ) {
@@ -122,46 +129,20 @@ protected function buildOptions(RequestInterface $request): array
122129 };
123130 }
124131
125- return $ this ->options + $ options ;
126- }
127-
128- protected function parseHeaders (ResponseInterface $ response , StreamInterface $ headers ): ResponseInterface
129- {
130- $ data = rtrim ((string ) $ headers );
131- $ parts = explode ("\r\n\r\n" , $ data );
132- $ last = array_pop ($ parts );
133- $ lines = explode ("\r\n" , $ last );
134- $ status = array_shift ($ lines );
135-
136- if (is_string ($ status ) && strpos ($ status , 'HTTP/ ' ) === 0 ) {
137- [$ version , $ status , $ message ] = explode (' ' , substr ($ status , strlen ('http/ ' )), 3 );
138- $ response = $ response ->withProtocolVersion ($ version )->withStatus ((int ) $ status , $ message );
139- }
140-
141- return array_reduce ($ lines , static function (ResponseInterface $ response , string $ line ): ResponseInterface {
142- [$ name , $ value ] = explode (': ' , $ line , 2 );
143- return $ response ->withHeader ($ name , $ value );
144- }, $ response );
145- }
146-
147- protected function buildResponse (StreamInterface $ headers , StreamInterface $ body ): ResponseInterface
148- {
149- if ($ body ->isSeekable ()) {
150- $ body ->rewind ();
151- }
152- $ response = $ this ->responseFactory ->createResponse (200 )->withBody ($ body );
153-
154- return $ this ->parseHeaders ($ response , $ headers );
132+ return $ options ;
155133 }
156134
157- protected function prepareSession (RequestInterface $ request ): array
135+ /**
136+ * @return array{0: StreamInterface, 1: StreamInterface}
137+ */
138+ private function prepareSession (RequestInterface $ request , CurlHandle $ session ): array
158139 {
159- curl_setopt_array ($ this -> session , $ this ->buildOptions ($ request ));
140+ curl_setopt_array ($ session , $ this ->buildOptions ($ request ));
160141
161142 $ headers = $ this ->streamFactory ->createStream ('' );
162143
163144 curl_setopt (
164- $ this -> session ,
145+ $ session ,
165146 CURLOPT_HEADERFUNCTION ,
166147 static function ($ session , string $ data ) use ($ headers ): int {
167148 return $ headers ->write ($ data );
@@ -171,7 +152,7 @@ static function ($session, string $data) use ($headers): int {
171152 $ body = $ this ->streamFactory ->createStream ('' );
172153
173154 curl_setopt (
174- $ this -> session ,
155+ $ session ,
175156 CURLOPT_WRITEFUNCTION ,
176157 static function ($ session , string $ data ) use ($ body ): int {
177158 return $ body ->write ($ data );
@@ -183,16 +164,21 @@ static function ($session, string $data) use ($body): int {
183164
184165 public function sendRequest (RequestInterface $ request ): ResponseInterface
185166 {
186- [$ headers , $ body ] = $ this ->prepareSession ($ request );
167+ $ session = curl_init ();
168+
169+ [$ headers , $ body ] = $ this ->prepareSession ($ request , $ session );
187170
188- $ result = curl_exec ($ this ->session );
189- if ($ this ->debug ) {
190- $ this ->metrics = curl_getinfo ($ this ->session );
171+ $ result = curl_exec ($ session );
172+ if (isset ($ this ->options ['metrics ' ]) && is_callable ($ this ->options ['metrics ' ])) {
173+ $ metrics = curl_getinfo ($ session );
174+ $ this ->options ['metrics ' ]($ metrics );
191175 }
192- curl_reset ($ this ->session );
176+ $ errorMessage = curl_error ($ session );
177+ $ errorCode = curl_errno ($ session );
178+ curl_close ($ session );
193179
194180 if (false === $ result ) {
195- throw new Exceptions \TransportError (curl_error ( $ this -> session ), curl_errno ( $ this -> session ) , $ request );
181+ throw new Exceptions \TransportError ($ errorMessage , $ errorCode , $ request );
196182 }
197183
198184 $ response = $ this ->buildResponse ($ headers , $ body );
0 commit comments