diff --git a/README.md b/README.md index 36487df..02a49f1 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,28 @@ $response = $newRequest->getResponse(); See [Response/Definitions](src/Response/Definitions) for more details about code +### Using your own HTTP client and cache + +By default, requests are made with `file_get_contents()` and the IANA bootstrap files are cached in the system temporary directory. + +The client optionally accepts a [PSR-18](https://www.php-fig.org/psr/psr-18/) HTTP client, a [PSR-17](https://www.php-fig.org/psr/psr-17/) request factory and a [PSR-16](https://www.php-fig.org/psr/psr-16/) cache. The HTTP client is used for both the bootstrap files and the RDAP queries, so timeouts, proxies, retries and logging follow your application's configuration. The cache stores the bootstrap files for `AbstractRdapService::cacheExpirations()` seconds. + +```php +use ArrayAccess\RdapClient\Client; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; + +// Psr18Client is also a request factory, so the second argument can be omitted +$httpClient = new Psr18Client(HttpClient::create(['timeout' => 5])); +$cache = new Psr16Cache(new FilesystemAdapter()); +$client = new Client($httpClient, null, $cache); + +// any PSR-18 client works, e.g. Guzzle with a PSR-17 factory +$client = new Client(new \GuzzleHttp\Client(['timeout' => 5]), new \GuzzleHttp\Psr7\HttpFactory()); +``` + ## Note diff --git a/composer.json b/composer.json index 1c2cdcd..0826747 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,10 @@ "require": { "php": ">= 8.1", "ext-intl": "*", - "ext-json": "*" + "ext-json": "*", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { "squizlabs/php_codesniffer": "^3.11.2", @@ -46,4 +49,4 @@ "ArrayAccess\\RdapClient\\" : "src/" } } -} \ No newline at end of file +} diff --git a/src/Client.php b/src/Client.php index f1f8dcd..5c27fb1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -5,9 +5,12 @@ use ArrayAccess\RdapClient\Exceptions\EmptyArgumentException; use ArrayAccess\RdapClient\Exceptions\InvalidServiceDefinitionException; +use ArrayAccess\RdapClient\Exceptions\RdapRemoteRequestException; use ArrayAccess\RdapClient\Exceptions\UnsupportedProtocolException; use ArrayAccess\RdapClient\Interfaces\RdapClientInterface; +use ArrayAccess\RdapClient\Interfaces\RdapHttpClientAwareInterface; use ArrayAccess\RdapClient\Interfaces\RdapProtocolInterface; +use ArrayAccess\RdapClient\Interfaces\RdapRequestInterface; use ArrayAccess\RdapClient\Protocols\AsnProtocol; use ArrayAccess\RdapClient\Protocols\DomainProtocol; use ArrayAccess\RdapClient\Protocols\IPv4Protocol; @@ -15,20 +18,30 @@ use ArrayAccess\RdapClient\Protocols\NsProtocol; use ArrayAccess\RdapClient\Services\AsnService; use ArrayAccess\RdapClient\Util\CIDR; +use InvalidArgumentException; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\SimpleCache\CacheInterface; use function explode; +use function file_get_contents; use function idn_to_ascii; use function is_a; use function is_array; use function is_int; use function is_object; +use function is_string; use function preg_match; +use function restore_error_handler; +use function set_error_handler; use function sprintf; use function str_contains; +use function stream_context_create; use function strlen; use function strtolower; use function trim; -class Client implements RdapClientInterface +class Client implements RdapClientInterface, RdapHttpClientAwareInterface { /** * @var string Version @@ -51,6 +64,131 @@ class Client implements RdapClientInterface */ protected array $protocols = self::PROTOCOLS; + /** + * @var RequestFactoryInterface|null $requestFactory The PSR-17 request factory + */ + protected ?RequestFactoryInterface $requestFactory; + + /** + * Constructor + * + * Without arguments, requests are made with file_get_contents() and the + * IANA bootstrap files are cached in the system temporary directory. + * + * @param ClientInterface|null $httpClient PSR-18 client used for bootstrap and RDAP requests + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory, + * optional when the HTTP client also implements RequestFactoryInterface + * @param CacheInterface|null $cache PSR-16 cache for the IANA bootstrap files + * @throws InvalidArgumentException if an HTTP client is given without a request factory + */ + public function __construct( + protected ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + protected ?CacheInterface $cache = null + ) { + if ($requestFactory === null && $httpClient instanceof RequestFactoryInterface) { + $requestFactory = $httpClient; + } + if ($httpClient !== null && $requestFactory === null) { + throw new InvalidArgumentException( + 'A PSR-17 request factory is required when using a PSR-18 HTTP client' + ); + } + $this->requestFactory = $requestFactory; + } + + /** + * @inheritDoc + */ + public function getHttpClient() : ?ClientInterface + { + return $this->httpClient; + } + + /** + * Get the PSR-17 request factory + * + * @return RequestFactoryInterface|null + */ + public function getRequestFactory() : ?RequestFactoryInterface + { + return $this->requestFactory; + } + + /** + * @inheritDoc + */ + public function getCache() : ?CacheInterface + { + return $this->cache; + } + + /** + * @inheritDoc + */ + public function fetch(string $url) : string + { + if ($this->httpClient === null || $this->requestFactory === null) { + return $this->fetchWithStream($url); + } + + $request = $this->requestFactory + ->createRequest('GET', $url) + ->withHeader('Accept', 'application/rdap+json,application/json;q=0.9,*/*;q=0.8') + ->withHeader('User-Agent', 'Rdap-Client/' . self::VERSION); + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new RdapRemoteRequestException( + $e->getMessage(), + (int) $e->getCode(), + $e + ); + } + + $content = (string) $response->getBody(); + if ($content === '') { + throw new RdapRemoteRequestException( + sprintf('Could not get RDAP response from %s', $url), + $response->getStatusCode() + ); + } + return $content; + } + + /** + * Fetch the body of the given URL with file_get_contents() + * + * @param string $url + * @return string + * @throws RdapRemoteRequestException + */ + protected function fetchWithStream(string $url) : string + { + $errorCode = 0; + $errorMessage = null; + set_error_handler( + static function (int $code, string $message) use (&$errorCode, &$errorMessage) : bool { + $errorCode = $code; + $errorMessage = $message; + return true; + } + ); + $content = file_get_contents( + $url, + false, + stream_context_create(RdapRequestInterface::DEFAULT_STREAM_CONTEXT) + ); + restore_error_handler(); + if (!is_string($content) || $content === '') { + throw new RdapRemoteRequestException( + $errorMessage ?? sprintf('Could not get RDAP response from %s', $url), + $errorCode + ); + } + return $content; + } + /** * Check if protocol is supported * diff --git a/src/Interfaces/RdapHttpClientAwareInterface.php b/src/Interfaces/RdapHttpClientAwareInterface.php new file mode 100644 index 0000000..ed5b875 --- /dev/null +++ b/src/Interfaces/RdapHttpClientAwareInterface.php @@ -0,0 +1,36 @@ +client; } + /** + * Load the service from an IANA bootstrap URL + * + * Uses the PSR-18 HTTP client and PSR-16 cache of the client when configured, + * otherwise falls back to {@see AbstractRdapService::fromURL()}. + * + * @template T of AbstractRdapService + * @param class-string $serviceClass + * @param string $url + * @return T + * @throws \Exception + * @noinspection PhpFullyQualifiedNameUsageInspection + */ + protected function loadService(string $serviceClass, string $url): AbstractRdapService + { + $client = $this->getClient(); + if (!$client instanceof RdapHttpClientAwareInterface + || ($client->getHttpClient() === null && $client->getCache() === null) + ) { + return $serviceClass::fromURL($url); + } + + $cache = $client->getCache(); + $cacheKey = 'rdap-client.bootstrap.' . md5($url); + $content = $cache?->get($cacheKey); + if (is_string($content)) { + try { + return $serviceClass::fromJson($content, $url); + } catch (InvalidServiceDefinitionException) { + $cache?->delete($cacheKey); + } + } + + $content = $client->fetch($url); + $service = $serviceClass::fromJson($content, $url); + $cache?->set($cacheKey, $content, $serviceClass::cacheExpirations()); + return $service; + } + /** * @inheritDoc */ diff --git a/src/Protocols/AsnProtocol.php b/src/Protocols/AsnProtocol.php index d90a50e..27b6b02 100644 --- a/src/Protocols/AsnProtocol.php +++ b/src/Protocols/AsnProtocol.php @@ -36,7 +36,7 @@ public function __construct(RdapClientInterface $client, ?AsnService $service = public function getService(): AsnService { if (!isset($this->services) || !$this->services instanceof AsnService) { - $this->services = AsnService::fromURL(self::ASN_URI); + $this->services = $this->loadService(AsnService::class, self::ASN_URI); } return $this->services; } diff --git a/src/Protocols/DomainProtocol.php b/src/Protocols/DomainProtocol.php index 1704d22..5e9781c 100644 --- a/src/Protocols/DomainProtocol.php +++ b/src/Protocols/DomainProtocol.php @@ -45,7 +45,7 @@ public function setService(DomainService $service): void public function getService(): DomainService { if (!isset($this->services) || !($this->services instanceof DomainService)) { - $this->services = DomainService::fromURL(self::DOMAIN_URI); + $this->services = $this->loadService(DomainService::class, self::DOMAIN_URI); } return $this->services; } diff --git a/src/Protocols/IPv4Protocol.php b/src/Protocols/IPv4Protocol.php index 18018d2..4c809c7 100644 --- a/src/Protocols/IPv4Protocol.php +++ b/src/Protocols/IPv4Protocol.php @@ -47,7 +47,7 @@ public function setService(Ipv4Service $service): void public function getService(): Ipv4Service { if (!isset($this->services) || !($this->services instanceof Ipv4Service)) { - $this->services = Ipv4Service::fromURL(self::IPV4_URI); + $this->services = $this->loadService(Ipv4Service::class, self::IPV4_URI); } return $this->services; } diff --git a/src/Protocols/IPv6Protocol.php b/src/Protocols/IPv6Protocol.php index d73f95d..e8564e5 100644 --- a/src/Protocols/IPv6Protocol.php +++ b/src/Protocols/IPv6Protocol.php @@ -47,7 +47,7 @@ public function setService(Ipv6Service $service): void public function getService(): Ipv6Service { if (!isset($this->services) || !($this->services instanceof Ipv6Service)) { - $this->services = Ipv6Service::fromURL(self::IPV6_URI); + $this->services = $this->loadService(Ipv6Service::class, self::IPV6_URI); } return $this->services; } diff --git a/src/Protocols/NsProtocol.php b/src/Protocols/NsProtocol.php index dca4ace..5245eb1 100644 --- a/src/Protocols/NsProtocol.php +++ b/src/Protocols/NsProtocol.php @@ -46,7 +46,7 @@ public function setService(NsService $service): void public function getService(): NsService { if (!isset($this->services) || !($this->services instanceof NsService)) { - $this->services = NsService::fromURL(self::NS_URI); + $this->services = $this->loadService(NsService::class, self::NS_URI); } return $this->services; } diff --git a/src/Protocols/RdapRequestProtocol.php b/src/Protocols/RdapRequestProtocol.php index bb9a6aa..54187d1 100644 --- a/src/Protocols/RdapRequestProtocol.php +++ b/src/Protocols/RdapRequestProtocol.php @@ -5,6 +5,7 @@ use ArrayAccess\RdapClient\Exceptions\MismatchProtocolBehaviorException; use ArrayAccess\RdapClient\Exceptions\RdapRemoteRequestException; +use ArrayAccess\RdapClient\Interfaces\RdapHttpClientAwareInterface; use ArrayAccess\RdapClient\Interfaces\RdapProtocolInterface; use ArrayAccess\RdapClient\Interfaces\RdapRequestInterface; use ArrayAccess\RdapClient\Interfaces\RdapResponseInterface; @@ -158,6 +159,17 @@ public function getResponse(): RdapResponseInterface $this->errorCode ); } + $client = $this->getProtocol()->getClient(); + if ($client instanceof RdapHttpClientAwareInterface && $client->getHttpClient() !== null) { + try { + $content = $client->fetch($this->rdapSearchURL); + } catch (RdapRemoteRequestException $e) { + $this->errorCode = $e->getCode(); + $this->errorMessage = $e->getMessage(); + throw $e; + } + return $this->response = $this->protocol->createResponse($content, $this); + } set_error_handler(function (int $code, string $message, string $file, int $line) : bool { $this->errorCode = $code; $this->errorMessage = $message; diff --git a/src/Services/AbstractRdapService.php b/src/Services/AbstractRdapService.php index e02c0c3..43dbfef 100644 --- a/src/Services/AbstractRdapService.php +++ b/src/Services/AbstractRdapService.php @@ -407,7 +407,26 @@ public static function fromURL(string $url) : static ); restore_error_handler(); - $data = is_string($content) ? json_decode($content, true) : null; + $service = static::fromJson(is_string($content) ? $content : '', $url); + if ($fileCache) { + file_put_contents($fileCache, $content); + } + return $service; + } + + /** + * Create a new instance from the content of an IANA bootstrap file + * + * @param string $json + * @param string $source The URL or file the content came from, used in the exception message + * @return static + * @throws InvalidServiceDefinitionException if the content is not a valid bootstrap file + * @throws \Exception + * @noinspection PhpFullyQualifiedNameUsageInspection + */ + public static function fromJson(string $json, string $source = 'JSON') : static + { + $data = json_decode($json, true); if (!is_array($data) || !is_string($data['version']??null) || !is_string($data['description']??null) @@ -415,12 +434,9 @@ public static function fromURL(string $url) : static || !is_array($data['services']??null) ) { throw new InvalidServiceDefinitionException( - sprintf('Protocol service URL "%s" return invalid data', $url) + sprintf('Protocol service URL "%s" return invalid data', $source) ); } - if ($fileCache) { - file_put_contents($fileCache, $content); - } // @phpstan-ignore-next-line return new static( $data['version'],