Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.00% |
228 / 240 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
Command | |
95.00% |
228 / 240 |
|
71.43% |
5 / 7 |
141 | |
0.00% |
0 / 1 |
clientToCommand | |
89.04% |
65 / 73 |
|
0.00% |
0 / 1 |
54.42 | |||
commandToClient | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
6 | |||
parseCommandOptions | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
extractCommandOptionValues | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
15 | |||
convertCommandOptions | |
96.04% |
97 / 101 |
|
0.00% |
0 / 1 |
54 | |||
trimQuotes | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
5 | |||
addQuotes | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
5 |
1 | <?php |
2 | /** |
3 | * Pop PHP Framework (http://www.popphp.org/) |
4 | * |
5 | * @link https://github.com/popphp/popphp-framework |
6 | * @author Nick Sagona, III <dev@nolainteractive.com> |
7 | * @copyright Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com) |
8 | * @license http://www.popphp.org/license New BSD License |
9 | */ |
10 | |
11 | /** |
12 | * @namespace |
13 | */ |
14 | namespace Pop\Http\Client\Handler\Curl; |
15 | |
16 | use Pop\Http\Auth; |
17 | use Pop\Http\Client; |
18 | use Pop\Http\Client\Request; |
19 | use Pop\Http\Client\Handler\Curl; |
20 | use Pop\Http\Promise; |
21 | |
22 | /** |
23 | * HTTP client curl command class |
24 | * |
25 | * @category Pop |
26 | * @package Pop\Http |
27 | * @author Nick Sagona, III <dev@nolainteractive.com> |
28 | * @copyright Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com) |
29 | * @license http://www.popphp.org/license New BSD License |
30 | * @version 5.2.0 |
31 | */ |
32 | class Command |
33 | { |
34 | |
35 | /** |
36 | * Create a compatible command string to execute with the curl CLI application |
37 | * |
38 | * @param Client $client |
39 | * @return string |
40 | */ |
41 | public static function clientToCommand(Client $client): string |
42 | { |
43 | $command = 'curl'; |
44 | $currentOptions = []; |
45 | |
46 | // If client has a Curl handler, get current options before reset |
47 | if (($client->hasHandler()) && ($client->getHandler() instanceof Curl) && ($client->getHandler()->hasOptions())) { |
48 | $currentOptions = $client->getHandler()->getOptions(); |
49 | } |
50 | |
51 | $client->prepare(); |
52 | |
53 | if (!($client->getHandler() instanceof Curl)) { |
54 | throw new Exception('Error: The client object must use a Curl handler.'); |
55 | } |
56 | |
57 | // Set return header |
58 | if ($client->getHandler()->isReturnHeader()) { |
59 | $command .= ' -i'; |
60 | } |
61 | |
62 | // Set method |
63 | $method = $client->getRequest()->getMethod(); |
64 | $command .= ' -X ' . $method; |
65 | |
66 | // Handle insecure settings |
67 | if (($client->hasOption('verify_peer') && ($client->getOption('verify_peer'))) || |
68 | ($client->hasOption('allow_self_signed') && ($client->getOption('allow_self_signed'))) || |
69 | ($client->getHandler()->hasOption(CURLOPT_SSL_VERIFYHOST) && (!$client->getHandler()->getOption(CURLOPT_SSL_VERIFYHOST))) || |
70 | ($client->getHandler()->hasOption(CURLOPT_SSL_VERIFYPEER) && (!$client->getHandler()->getOption(CURLOPT_SSL_VERIFYPEER)))) { |
71 | $command .= ' --insecure'; |
72 | } |
73 | |
74 | // Handle basic auth |
75 | if (($client->hasAuth()) && ($client->getAuth()->isBasic())) { |
76 | $command .= ' --basic -u "' . $client->getAuth()->getUsername() . ':' . $client->getAuth()->getPassword() . '"'; |
77 | if ($client->getRequest()->hasHeader('Authorization')) { |
78 | $client->getRequest()->removeHeader('Authorization'); |
79 | } |
80 | } |
81 | |
82 | // Handle headers |
83 | if ($client->getRequest()->hasHeaders()) { |
84 | foreach ($client->getRequest()->getHeaders() as $header) { |
85 | if ((!str_contains($header->getValueAsString(), 'multipart/form-data')) && |
86 | (!str_contains($header->getValueAsString(), 'x-www-form-urlencoded')) && ($header->getName() != 'Content-Length')) { |
87 | $command .= ' --header "' . $header . '"'; |
88 | } |
89 | } |
90 | } |
91 | |
92 | // Handle data |
93 | if ($client->getRequest()->hasData()) { |
94 | // Multipart form data |
95 | if ($client->getRequest()->isMultipart()) { |
96 | $data = $client->getRequest()->getData()->toArray(); |
97 | foreach ($data as $key => $value) { |
98 | $command .= (isset($value['filename']) && file_exists($value['filename'])) ? |
99 | ' -F "' . $key . '=@' . $value['filename'] . '"' : |
100 | ' -F "' . http_build_query([$key => $value]) . '"'; |
101 | } |
102 | // JSON data |
103 | } else if ($client->getRequest()->isJson()) { |
104 | $data = $client->getRequest()->getData()->toArray(); |
105 | foreach ($data as $key => $datum) { |
106 | if (isset($datum['filename']) && file_exists($datum['filename'])) { |
107 | $command .= ' --data @' . $datum['filename']; |
108 | unset($data[$key]); |
109 | } |
110 | } |
111 | if (!empty($data)) { |
112 | $json = json_encode($data); |
113 | if (str_contains($json, "'")) { |
114 | $json = str_replace("'", "\\'", $json); |
115 | } |
116 | $command .= " --data '" . $json . "'"; |
117 | } |
118 | // XML data |
119 | } else if ($client->getRequest()->isXml()) { |
120 | $data = $client->getRequest()->getData()->toArray(); |
121 | foreach ($data as $key => $datum) { |
122 | if (isset($datum['filename']) && file_exists($datum['filename'])) { |
123 | $command .= ' --data @' . $datum['filename']; |
124 | unset($data[$key]); |
125 | } |
126 | } |
127 | |
128 | if (!empty($data)) { |
129 | foreach ($data as $datum) { |
130 | if (str_contains($datum, "'")) { |
131 | $datum = str_replace("'", "\\'", $datum); |
132 | } |
133 | $command .= " --data '" . $datum . "'"; |
134 | } |
135 | } |
136 | // URL-encoded data |
137 | } else if (($client->getRequest()->getMethod() == 'GET') || ($client->getRequest()->isUrlEncoded()) || |
138 | !($client->getRequest()->hasRequestType())) { |
139 | $command .= ' --data "' . $client->getRequest()->getData()->prepare()->getDataContent() . '"'; |
140 | } |
141 | } |
142 | |
143 | // Add body content as data |
144 | if ($client->getRequest()->hasBody()) { |
145 | $body = $client->getRequest()->getBodyContent(); |
146 | if (str_contains($body, "'")) { |
147 | $body = str_replace("'", "\\'", $body); |
148 | } |
149 | $command .= " --data '" . $body . "'"; |
150 | } |
151 | |
152 | // Handle all other options |
153 | $curlOptions = $client->getHandler()->getOptions() + $currentOptions; |
154 | foreach ($curlOptions as $curlOption => $curlOptionValue) { |
155 | $curlOptionName = Options::getOptionNameByValue($curlOption); |
156 | if (!Options::isOmitOption($curlOptionName)) { |
157 | $commandOption = Options::getPhpOption($curlOptionName); |
158 | $command .= (is_array($commandOption) && isset($commandOption[0])) ? |
159 | ' ' . $commandOption[0] : ' ' . $commandOption; |
160 | if (Options::isValueOption($curlOptionName) && !empty($curlOptionValue)) { |
161 | $command .= ' ' . self::addQuotes($curlOptionValue); |
162 | } |
163 | } |
164 | } |
165 | |
166 | $command .= ' ' . self::addQuotes($client->getRequest()->getUriAsString()); |
167 | |
168 | return $command; |
169 | } |
170 | |
171 | /** |
172 | * Create a client object from a command string from the Curl CLI application |
173 | * |
174 | * @param string $command |
175 | * @return Client |
176 | */ |
177 | public static function commandToClient(string $command): Client |
178 | { |
179 | $command = trim($command); |
180 | |
181 | if (!str_starts_with($command, 'curl')) { |
182 | throw new Exception("Error: The command isn't a valid cURL command."); |
183 | } |
184 | |
185 | $command = substr($command, 4); |
186 | $options = []; |
187 | |
188 | // No options |
189 | if (!str_contains($command, '-')) { |
190 | $requestUri = trim($command); |
191 | // Else, parse options |
192 | } else { |
193 | $optionString = substr($command, 0, strrpos($command, ' ')); |
194 | $requestUri = substr($command, (strrpos($command, ' ') + 1)); |
195 | $options = self::parseCommandOptions($optionString); |
196 | } |
197 | |
198 | $request = new Request(self::trimQuotes($requestUri)); |
199 | $curl = new Curl(); |
200 | $files = null; |
201 | |
202 | if (!empty($options)) { |
203 | [$auth, $files] = self::convertCommandOptions($options, $curl, $request); |
204 | } |
205 | |
206 | $client = new Client($request, $curl); |
207 | |
208 | if (!empty($auth)) { |
209 | $client->setAuth($auth); |
210 | } |
211 | if (!empty($files)) { |
212 | $client->setFiles($files, false); |
213 | } |
214 | |
215 | return $client; |
216 | } |
217 | |
218 | /** |
219 | * Parse the CLI command options string |
220 | * |
221 | * @param string $optionString |
222 | * @return array |
223 | */ |
224 | public static function parseCommandOptions(string $optionString): array |
225 | { |
226 | $options = []; |
227 | $matches = []; |
228 | |
229 | preg_match_all('/\s[\-]{1,2}/', $optionString, $matches, PREG_OFFSET_CAPTURE); |
230 | |
231 | if (isset($matches[0]) && isset($matches[0][0])) { |
232 | foreach ($matches[0] as $i => $match) { |
233 | if (isset($matches[0][$i + 1])) { |
234 | $length = ($matches[0][$i + 1][1]) - $match[1] - 1; |
235 | $options[] = substr($optionString, $match[1] + 1, $length); |
236 | } else { |
237 | $options[] = substr($optionString, $match[1] + 1); |
238 | } |
239 | } |
240 | } |
241 | |
242 | return $options; |
243 | } |
244 | |
245 | /** |
246 | * Extract command option values |
247 | * |
248 | * @param array $options |
249 | * @return array |
250 | */ |
251 | public static function extractCommandOptionValues(array $options): array |
252 | { |
253 | $optionValues = []; |
254 | |
255 | foreach ($options as $option) { |
256 | $opt = null; |
257 | $val = null; |
258 | if (str_starts_with($option, '--')) { |
259 | if (str_contains($option, ' ')) { |
260 | $opt = substr($option, 0, strpos($option, ' ')); |
261 | $val = substr($option, (strpos($option, ' ') + 1)); |
262 | } else { |
263 | $opt = $option; |
264 | } |
265 | } else { |
266 | if (strlen($option) > 2) { |
267 | if (substr($option, 2, 1) == ' ') { |
268 | $opt = substr($option, 0, 2); |
269 | $val = substr($option, 3); |
270 | } else { |
271 | $opt = substr($option, 0, 2); |
272 | $val = substr($option, 2); |
273 | } |
274 | } else { |
275 | $opt = $option; |
276 | } |
277 | } |
278 | |
279 | if (($opt == '-d') || ($opt == '--data') || ($opt == '-F') || ($opt == '--form')) { |
280 | $val = self::trimQuotes($val); |
281 | if (str_contains($val, '=') && !str_contains($val, '<?xml')) { |
282 | parse_str(self::trimQuotes($val), $val); |
283 | } |
284 | } |
285 | |
286 | if (isset($optionValues[$opt])) { |
287 | if (!is_array($optionValues[$opt])) { |
288 | $optionValues[$opt] = [$optionValues[$opt]]; |
289 | } |
290 | if (is_array($val)) { |
291 | $optionValues[$opt] = array_merge($optionValues[$opt], $val); |
292 | } else { |
293 | $optionValues[$opt][] = $val; |
294 | } |
295 | } else { |
296 | $optionValues[$opt] = $val; |
297 | } |
298 | } |
299 | |
300 | return $optionValues; |
301 | } |
302 | |
303 | /** |
304 | * Convert CLI options to usable values for the Curl handler and request |
305 | * |
306 | * @param array $options |
307 | * @param Curl $curl |
308 | * @param Request $request |
309 | * @return array |
310 | */ |
311 | public static function convertCommandOptions(array $options, Curl $curl, Request $request): array |
312 | { |
313 | $optionValues = self::extractCommandOptionValues($options); |
314 | $auth = null; |
315 | $files = []; |
316 | |
317 | // Handle method |
318 | // If forced GET method |
319 | if (array_key_exists('-G', $optionValues) || array_key_exists('--get', $optionValues)) { |
320 | $request->setMethod('GET'); |
321 | if (array_key_exists('-G', $optionValues)) { |
322 | unset($optionValues['-G']); |
323 | } else { |
324 | unset($optionValues['--get']); |
325 | } |
326 | // If HEAD method |
327 | } else if (array_key_exists('-I', $optionValues) || array_key_exists('--head', $optionValues)) { |
328 | $request->setMethod('HEAD'); |
329 | if (array_key_exists('-I', $optionValues)) { |
330 | unset($optionValues['-I']); |
331 | } else { |
332 | unset($optionValues['--head']); |
333 | } |
334 | // All other methods |
335 | } else if (isset($optionValues['-X']) || isset($optionValues['--request'])) { |
336 | $request->setMethod(($optionValues['-X'] ?? $optionValues['--request'])); |
337 | if (isset($optionValues['-X'])) { |
338 | unset($optionValues['-X']); |
339 | } else { |
340 | unset($optionValues['--request']); |
341 | } |
342 | } |
343 | |
344 | // Handle insecure settings |
345 | if (array_key_exists('-k', $optionValues) || array_key_exists('--insecure', $optionValues)) { |
346 | $curl->setOption(CURLOPT_SSL_VERIFYHOST, 0); |
347 | $curl->setOption(CURLOPT_SSL_VERIFYPEER, 0); |
348 | } |
349 | |
350 | // Handle headers |
351 | if (isset($optionValues['-H']) || isset($optionValues['--header'])) { |
352 | $headerOpts = ($optionValues['-H'] ?? $optionValues['--header']); |
353 | if (is_array($headerOpts)) { |
354 | $headers = array_map(function ($value) { |
355 | return Command::trimQuotes($value); |
356 | }, $headerOpts); |
357 | } else { |
358 | $headers = [Command::trimQuotes($headerOpts)]; |
359 | } |
360 | |
361 | $request->addHeaders($headers); |
362 | |
363 | if (isset($optionValues['-H'])) { |
364 | unset($optionValues['-H']); |
365 | } else { |
366 | unset($optionValues['--header']); |
367 | } |
368 | } |
369 | |
370 | // Handle basic auth |
371 | if ((!array_key_exists('--digest', $optionValues) || array_key_exists('--basic', $optionValues) || array_key_exists('--anyauth', $optionValues)) && |
372 | (isset($optionValues['-u']) || isset($optionValues['--user']))) { |
373 | $userData = ($optionValues['-u'] ?? $optionValues['--user']); |
374 | if (str_contains($userData, ':')) { |
375 | [$username, $password] = explode(':', self::trimQuotes($userData), 2); |
376 | $auth = Auth::createBasic($username, $password); |
377 | if (isset($optionValues['-u'])) { |
378 | unset($optionValues['-u']); |
379 | } else { |
380 | unset($optionValues['--user']); |
381 | } |
382 | if (array_key_exists('--basic', $optionValues)) { |
383 | unset($optionValues['--basic']); |
384 | } |
385 | } |
386 | } |
387 | |
388 | // Handle JSON request |
389 | if (array_key_exists('--json', $optionValues)) { |
390 | $request->addHeaders([ |
391 | 'Content-Type: application/json', |
392 | 'Accept: application/json' |
393 | ]); |
394 | unset($optionValues['--json']); |
395 | } |
396 | |
397 | // Handle data |
398 | if (isset($optionValues['-d']) || isset($optionValues['--data'])) { |
399 | $data = ($optionValues['-d'] ?? $optionValues['--data']); |
400 | |
401 | if ($request->hasHeader('Content-Type') && is_string($data)) { |
402 | if (str_starts_with($data, '@')) { |
403 | //&& file_exists(getcwd() . DIRECTORY_SEPARATOR . substr($data, 1))) { |
404 | $file = substr($data, 1); |
405 | if (!str_starts_with($file, '/')) { |
406 | $file = getcwd() . DIRECTORY_SEPARATOR . substr($data, 1); |
407 | } |
408 | if (file_exists($file)) { |
409 | $files[] = $file; |
410 | } |
411 | } else { |
412 | $contentType = $request->getHeaderValueAsString('Content-Type'); |
413 | if ($contentType == Request::JSON) { |
414 | $data = json_decode($data, true); |
415 | } else if ($contentType == Request::URLENCODED) { |
416 | parse_str($data, $data); |
417 | } |
418 | } |
419 | } |
420 | $request->setData($data); |
421 | if (isset($optionValues['-d'])) { |
422 | unset($optionValues['-d']); |
423 | } else { |
424 | unset($optionValues['--data']); |
425 | } |
426 | } |
427 | |
428 | // Handle form data |
429 | if (isset($optionValues['-F']) || isset($optionValues['--form'])) { |
430 | $data = []; |
431 | $formData = ($optionValues['-F'] ?? $optionValues['--form']); |
432 | if (is_array($formData)) { |
433 | foreach ($formData as $key => $formDatum) { |
434 | if (str_starts_with($formDatum, '@')) { |
435 | $data[$key] = [ |
436 | 'filename' => getcwd() . DIRECTORY_SEPARATOR . substr($formDatum, 1), |
437 | 'contentType' => Client\Data::getMimeTypeFromFilename(substr($formDatum, 1)) |
438 | ]; |
439 | } else { |
440 | $data[$key] = $formDatum; |
441 | } |
442 | } |
443 | } |
444 | |
445 | $request->setData($data) |
446 | ->setRequestType(Request::MULTIPART); |
447 | |
448 | if (isset($optionValues['-F'])) { |
449 | unset($optionValues['-F']); |
450 | } else { |
451 | unset($optionValues['--form']); |
452 | } |
453 | } |
454 | |
455 | // Handle all other options |
456 | foreach ($optionValues as $option => $value) { |
457 | if (!Options::isOmitOption($option)) { |
458 | foreach (Options::getPhpOptions() as $phpOption => $curlOption) { |
459 | if (is_array($curlOption)) { |
460 | foreach ($curlOption as $cOpt) { |
461 | if ((str_starts_with($option, '--') && str_contains($cOpt, $option)) || str_starts_with($cOpt, $option)) { |
462 | if (Options::isValueOption($option)) { |
463 | $optionValue = Options::getValueOption($option) ?? self::trimQuotes($value); |
464 | } else { |
465 | $optionValue = true; |
466 | } |
467 | $curl->setOption(constant($phpOption), $optionValue); |
468 | break; |
469 | } |
470 | } |
471 | } else if ((str_starts_with($option, '--') && str_contains($curlOption, $option)) || str_starts_with($curlOption, $option)) { |
472 | if (Options::isValueOption($option)) { |
473 | $optionValue = Options::getValueOption($option) ?? self::trimQuotes($value); |
474 | } else { |
475 | $optionValue = true; |
476 | } |
477 | $curl->setOption(constant($phpOption), $optionValue); |
478 | break; |
479 | } |
480 | } |
481 | } |
482 | } |
483 | |
484 | return [$auth, $files]; |
485 | } |
486 | |
487 | /** |
488 | * Trim quotes from value |
489 | * |
490 | * @param string $value |
491 | * @return string |
492 | */ |
493 | public static function trimQuotes(string $value): string |
494 | { |
495 | if ((str_starts_with($value, '"') && str_ends_with($value, '"')) || (str_starts_with($value, "'") && str_ends_with($value, "'"))) { |
496 | $value = substr($value, 1); |
497 | $value = substr($value, 0, -1); |
498 | } |
499 | |
500 | return $value; |
501 | } |
502 | |
503 | /** |
504 | * Trim quotes from value |
505 | * |
506 | * @param string $value |
507 | * @param string $quote |
508 | * @return string |
509 | */ |
510 | public static function addQuotes(string $value, string $quote = '"'): string |
511 | { |
512 | if (!str_starts_with($value, '"') && !str_ends_with($value, '"') && !str_starts_with($value, "'") && !str_ends_with($value, "'")) { |
513 | $value = $quote . $value . $quote; |
514 | } |
515 | |
516 | return $value; |
517 | } |
518 | |
519 | } |