Skip to content

Commit 3fcb907

Browse files
committed
:octocat: add PSR-18 utility clients
1 parent f6f546a commit 3fcb907

File tree

11 files changed

+529
-1
lines changed

11 files changed

+529
-1
lines changed

composer.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
},
2020
"minimum-stability": "stable",
2121
"prefer-stable": true,
22+
"provide": {
23+
"psr/http-client-implementation": "1.0"
24+
},
2225
"require": {
2326
"php": "^8.1",
2427
"ext-fileinfo": "*",
@@ -27,7 +30,9 @@
2730
"ext-mbstring": "*",
2831
"ext-simplexml": "*",
2932
"ext-zlib": "*",
30-
"psr/http-factory":"^1.0"
33+
"psr/http-client": "^1.0",
34+
"psr/http-factory":"^1.0",
35+
"psr/http-message": "^1.1 || ^2.0"
3136
},
3237
"require-dev": {
3338
"ext-curl": "*",

src/Client/EchoClient.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
/**
3+
* Class EchoClient
4+
*
5+
* @created 15.03.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
11+
namespace chillerlan\HTTP\Utils\Client;
12+
13+
use chillerlan\HTTP\Utils\MessageUtil;
14+
use Psr\Http\Client\ClientInterface;
15+
use Psr\Http\Message\{RequestInterface, ResponseFactoryInterface, ResponseInterface};
16+
use Throwable;
17+
use function json_encode, strlen;
18+
19+
/**
20+
* Echoes the http request back (as a JSON object)
21+
*/
22+
class EchoClient implements ClientInterface{
23+
24+
/**
25+
* EchoClient constructor.
26+
*/
27+
public function __construct(
28+
protected ResponseFactoryInterface $responseFactory
29+
){
30+
31+
}
32+
33+
public function sendRequest(RequestInterface $request):ResponseInterface{
34+
$response = $this->responseFactory->createResponse();
35+
36+
try{
37+
$content = MessageUtil::toJSON($request);
38+
}
39+
catch(Throwable $e){
40+
$response = $response->withStatus(500);
41+
$content = json_encode(['error' => $e->getMessage()]);
42+
}
43+
44+
$response = $response
45+
->withHeader('Content-Type', 'application/json')
46+
->withHeader('Content-Length', strlen($content))
47+
;
48+
49+
$response->getBody()->write($content);
50+
51+
return $response;
52+
}
53+
54+
}

src/Client/LoggingClient.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
/**
3+
* Class LoggingClient
4+
*
5+
* @created 07.08.2019
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2019 smiley
8+
* @license MIT
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace chillerlan\HTTP\Utils\Client;
14+
15+
use chillerlan\HTTP\Utils\MessageUtil;
16+
use Psr\Http\Client\{ClientExceptionInterface, ClientInterface};
17+
use Psr\Http\Message\{RequestInterface, ResponseInterface};
18+
use Psr\Log\{LoggerInterface, NullLogger};
19+
use RuntimeException, Throwable;
20+
use function get_class, sprintf;
21+
22+
/**
23+
* a silly logging wrapper (do not use in production!)
24+
*
25+
* @codeCoverageIgnore
26+
*/
27+
class LoggingClient implements ClientInterface{
28+
29+
/**
30+
* LoggingClient constructor.
31+
*/
32+
public function __construct(
33+
protected ClientInterface $http,
34+
protected LoggerInterface $logger = new NullLogger,
35+
){
36+
37+
}
38+
39+
/**
40+
* @codeCoverageIgnore
41+
*/
42+
public function setLogger(LoggerInterface $logger):static{
43+
$this->logger = $logger;
44+
45+
return $this;
46+
}
47+
48+
/**
49+
* @inheritDoc
50+
*/
51+
public function sendRequest(RequestInterface $request):ResponseInterface{
52+
53+
try{
54+
$this->logger->debug(sprintf("\n----HTTP-REQUEST----\n%s", MessageUtil::toString($request)));
55+
56+
$response = $this->http->sendRequest($request);
57+
58+
$this->logger->debug(sprintf("\n----HTTP-RESPONSE---\n%s", MessageUtil::toString($response)));
59+
}
60+
catch(Throwable $e){
61+
$this->logger->error($e->getMessage());
62+
$this->logger->error($e->getTraceAsString());
63+
64+
if(!$e instanceof ClientExceptionInterface){
65+
$msg = 'unexpected exception, does not implement "ClientExceptionInterface": "%s"';
66+
67+
throw new RuntimeException(sprintf($msg, get_class($e)));
68+
}
69+
70+
throw $e;
71+
}
72+
73+
return $response;
74+
}
75+
76+
}

src/Client/URLExtractor.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
/**
3+
* Class URLExtractor
4+
*
5+
* @created 15.08.2019
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2019 smiley
8+
* @license MIT
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace chillerlan\HTTP\Utils\Client;
14+
15+
use Psr\Http\Client\ClientInterface;
16+
use Psr\Http\Message\{RequestFactoryInterface, RequestInterface, ResponseInterface, UriInterface};
17+
use function array_reverse, in_array;
18+
19+
/**
20+
* A client that follows redirects until it reaches a non-30x response, e.g. to extract shortened URLs
21+
*
22+
* The given HTTP client needs to be set up accordingly:
23+
*
24+
* - CURLOPT_FOLLOWLOCATION must be set to false so that we can intercept the 30x responses
25+
* - CURLOPT_MAXREDIRS should be set to a value > 1
26+
*/
27+
class URLExtractor implements ClientInterface{
28+
29+
/** @var \Psr\Http\Message\ResponseInterface[] */
30+
protected array $responses = [];
31+
32+
/**
33+
* URLExtractor constructor.
34+
*/
35+
public function __construct(
36+
protected ClientInterface $http,
37+
protected RequestFactoryInterface $requestFactory,
38+
){
39+
40+
}
41+
42+
/**
43+
* @inheritDoc
44+
*/
45+
public function sendRequest(RequestInterface $request):ResponseInterface{
46+
47+
do{
48+
// fetch the response for the current request
49+
$response = $this->http->sendRequest($request);
50+
$location = $response->getHeaderLine('location');
51+
$this->responses[] = $response;
52+
53+
if($location === ''){
54+
break;
55+
}
56+
57+
// set up a new request to the location header of the last response
58+
$request = $this->requestFactory->createRequest($request->getMethod(), $location);
59+
}
60+
while(in_array($response->getStatusCode(), [301, 302, 303, 307, 308], true));
61+
62+
return $response;
63+
}
64+
65+
/**
66+
* extract the given URL and return the last valid location header
67+
*/
68+
public function extract(UriInterface|string $shortURL):string|null{
69+
$request = $this->requestFactory->createRequest('GET', $shortURL);
70+
$response = $this->sendRequest($request);
71+
72+
if($response->getStatusCode() !== 200 || empty($this->responses)){
73+
return null;
74+
}
75+
76+
foreach(array_reverse($this->responses) as $r){
77+
$url = $r->getHeaderLine('location');
78+
79+
if(!empty($url)){
80+
return $url;
81+
}
82+
}
83+
84+
return null;
85+
}
86+
87+
/**
88+
* @return \Psr\Http\Message\ResponseInterface[]
89+
*/
90+
public function getResponses():array{
91+
return $this->responses;
92+
}
93+
94+
}

tests/Client/EchoClientTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
/**
3+
* Class EchoClientTest
4+
*
5+
* @created 15.03.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
11+
namespace chillerlan\HTTPTest\Utils\Client;
12+
13+
use chillerlan\HTTP\Utils\MessageUtil;
14+
use chillerlan\HTTPTest\Utils\Client\Factories\EchoClientFactory;
15+
16+
/**
17+
*
18+
*/
19+
class EchoClientTest extends HTTPClientTestAbstract{
20+
21+
protected string $HTTP_CLIENT_FACTORY = EchoClientFactory::class;
22+
23+
public function testSendRequest():void{
24+
$url = 'https://httpbin.org/get';
25+
$response = $this->httpClient->sendRequest($this->requestFactory->createRequest('GET', $url));
26+
$json = MessageUtil::decodeJSON($response);
27+
28+
$this::assertSame($url, $json->request->url);
29+
$this::assertSame('GET', $json->request->method);
30+
$this::assertSame('httpbin.org', $json->headers->{'Host'});
31+
}
32+
33+
public function testNetworkError():void{
34+
$this::markTestSkipped('N/A');
35+
}
36+
37+
public function testRequestError():void{
38+
$this::markTestSkipped('N/A');
39+
}
40+
41+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
/**
3+
* Class EchoClientFactory
4+
*
5+
* @created 15.03.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
11+
namespace chillerlan\HTTPTest\Utils\Client\Factories;
12+
13+
use chillerlan\HTTP\Utils\Client\EchoClient;
14+
use chillerlan\PHPUnitHttp\HttpClientFactoryInterface;
15+
use Psr\Http\Client\ClientInterface;
16+
use Psr\Http\Message\ResponseFactoryInterface;
17+
18+
final class EchoClientFactory implements HttpClientFactoryInterface{
19+
20+
public function getClient(string $cacert, ResponseFactoryInterface $responseFactory):ClientInterface{
21+
return new EchoClient($responseFactory);
22+
}
23+
24+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
/**
3+
* Class ChillerlanHttpClientFactory
4+
*
5+
* @created 14.03.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace chillerlan\HTTPTest\Utils\Client\Factories;
14+
15+
use chillerlan\HTTP\Utils\Client\LoggingClient;
16+
use chillerlan\PHPUnitHttp\{GuzzleHttpClientFactory, HttpClientFactoryInterface};
17+
use Psr\Http\Client\ClientInterface;
18+
use Psr\Http\Message\ResponseFactoryInterface;
19+
use Psr\Log\AbstractLogger;
20+
use Stringable;
21+
use function date;
22+
use function printf;
23+
24+
final class LoggingClientFactory implements HttpClientFactoryInterface{
25+
26+
public function getClient(string $cacert, ResponseFactoryInterface $responseFactory):ClientInterface{
27+
$http = (new GuzzleHttpClientFactory)->getClient($cacert, $responseFactory);
28+
$logger = new class () extends AbstractLogger{
29+
public function log($level, string|Stringable $message, array $context = []):void{
30+
printf("\n[%s][%s] LoggingClientTest: %s", date('Y-m-d H:i:s'), $level, $message);
31+
}
32+
};
33+
34+
return new LoggingClient($http, $logger);
35+
}
36+
37+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
/**
3+
* Class URLExtractorClientFactory
4+
*
5+
* @created 15.03.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
11+
namespace chillerlan\HTTPTest\Utils\Client\Factories;
12+
13+
use chillerlan\HTTP\Utils\Client\URLExtractor;
14+
use chillerlan\PHPUnitHttp\HttpClientFactoryInterface;
15+
use GuzzleHttp\Client;
16+
use GuzzleHttp\Psr7\HttpFactory;
17+
use Psr\Http\Client\ClientInterface;
18+
use Psr\Http\Message\ResponseFactoryInterface;
19+
use const CURLOPT_FOLLOWLOCATION;
20+
use const CURLOPT_MAXREDIRS;
21+
22+
/**
23+
*
24+
*/
25+
final class URLExtractorClientFactory implements HttpClientFactoryInterface{
26+
27+
public function getClient(string $cacert, ResponseFactoryInterface $responseFactory):ClientInterface{
28+
29+
$http = new Client([
30+
'verify' => false,
31+
'headers' => [
32+
'User-Agent' => self::USER_AGENT,
33+
],
34+
'curl' => [
35+
CURLOPT_FOLLOWLOCATION => false,
36+
CURLOPT_MAXREDIRS => 25,
37+
],
38+
]);
39+
40+
// note: this client requires a request factory
41+
return new URLExtractor($http, new HttpFactory);
42+
}
43+
44+
}

0 commit comments

Comments
 (0)