diff --git a/README.md b/README.md index 7ef811e..5f2598b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ composer require myerscode/utilities-web use Myerscode\Utilities\Web\Utility; $web = new Utility('https://example.com'); +// or use the static factory +$web = Utility::make('https://example.com'); // Get content from a URL $content = $web->content()->content(); @@ -34,11 +36,22 @@ $content = $web->content()->content(); // Get a DOM crawler for the page $dom = $web->content()->dom(); +// Decode JSON responses +$data = $web->content()->json(); + // Ping a host $result = $web->ping()->ping(); +$alive = $web->ping()->isAlive(); + +// Quick liveness check +$web->isAlive(); // Work with URLs $uri = $web->url(); + +// Check response status +$response = $web->response()->check(\Myerscode\Utilities\Web\Data\ResponseFrom::CURL); +$response->isSuccessful(); // true for 2xx ``` ## Available Utilities @@ -52,6 +65,20 @@ Ping hosts and check latency. ### [URI Utility](docs/uri-utility.md) Parse, build and manipulate URLs. +## Exception Handling + +All package exceptions extend `Myerscode\Utilities\Web\Exceptions\WebUtilityException`, which extends `RuntimeException`. This allows catching all package exceptions in one go: + +```php +use Myerscode\Utilities\Web\Exceptions\WebUtilityException; + +try { + $content = $web->content()->content(); +} catch (WebUtilityException $e) { + // Handle any package exception +} +``` + ## License The MIT License (MIT). Please see [License File](LICENSE) for more information. diff --git a/docs/content-utility.md b/docs/content-utility.md index a08b473..4323530 100644 --- a/docs/content-utility.md +++ b/docs/content-utility.md @@ -27,6 +27,40 @@ $dom = $utility->dom(); $title = $dom->filterXPath('//title')->text(); ``` +## json(): array + +Decode the response content as JSON. + +```php +$data = $utility->json(); +``` + +Throws `\JsonException` if the content is not valid JSON. + +## contentType(): ?string + +Get the Content-Type header from the response. + +```php +$type = $utility->contentType(); // 'text/html; charset=utf-8' +``` + +## headers(): array + +Get all response headers. + +```php +$headers = $utility->headers(); +``` + +## statusCode(): int + +Get the HTTP status code. + +```php +$code = $utility->statusCode(); // 200 +``` + ## response(): Response Get the full Response object containing status code, content and headers. @@ -38,6 +72,33 @@ $content = $response->content(); $headers = $response->headers(); ``` +## post(array $data): Response + +Send a POST request with form data. + +```php +$response = $utility->post(['name' => 'value']); +$code = $response->code(); +``` + +## withHeaders(array $headers): self + +Set custom request headers. + +```php +$utility->withHeaders(['Authorization' => 'Bearer token']); +$content = $utility->content(); +``` + +## withTimeout(int $seconds): self + +Set the request timeout in seconds. + +```php +$utility->withTimeout(60); +$content = $utility->content(); +``` + ## url(): string Get the URL the utility is using. diff --git a/docs/ping-utility.md b/docs/ping-utility.md index 991fd25..8ed4710 100644 --- a/docs/ping-utility.md +++ b/docs/ping-utility.md @@ -17,6 +17,38 @@ $result = $utility->ping(); // ['alive' => true, 'latency' => 12.0] ``` +## isAlive(): bool + +Check if the host is alive. + +```php +$utility->isAlive(); // true +``` + +## latency(): ?float + +Get the latency in milliseconds, or null if unreachable. + +```php +$utility->latency(); // 12.0 +``` + +## setTimeout(int $seconds): self + +Set the ping timeout in seconds. + +```php +$utility->setTimeout(5)->ping(); +``` + +## setTtl(int $ttl): self + +Set the TTL (time to live). + +```php +$utility->setTtl(128)->ping(); +``` + ## url(): string Get the URL the utility is using. diff --git a/docs/uri-utility.md b/docs/uri-utility.md index 1c7d5c5..a2dea98 100644 --- a/docs/uri-utility.md +++ b/docs/uri-utility.md @@ -5,7 +5,7 @@ Parse, build and manipulate URLs. ```php use Myerscode\Utilities\Web\UriUtility; -$utility = new UriUtility('https://example.com/path?foo=bar'); +$utility = new UriUtility('https://example.com/path?foo=bar#section'); ``` ## host(): string @@ -56,6 +56,14 @@ Get the raw query string. $utility->query(); // 'foo=bar' ``` +## fragment(): string + +Get the URL fragment (the part after #). + +```php +$utility->fragment(); // 'section' +``` + ## getQueryParameters(): array Get query parameters as an associative array. @@ -89,6 +97,97 @@ Replace the entire query string. $utility->setQuery(['new' => 'value']); ``` +## removeQueryParameter(string $key): self + +Remove a specific query parameter by key. + +```php +$utility->removeQueryParameter('foo'); +``` + +## hasQueryParameter(string $key): bool + +Check if a query parameter exists. + +```php +$utility->hasQueryParameter('foo'); // true +``` + +## withScheme(string $scheme): self + +Set or replace the URL scheme. + +```php +$utility->withScheme('http'); +``` + +## withHost(string $host): self + +Set or replace the URL host. + +```php +$utility->withHost('other.com'); +``` + +## withPath(string $path): self + +Set or replace the URL path. + +```php +$utility->withPath('/new/path'); +``` + +## withPort(?int $port): self + +Set or replace the URL port. Pass `null` to remove. + +```php +$utility->withPort(8080); +``` + +## withFragment(string $fragment): self + +Set or replace the URL fragment. + +```php +$utility->withFragment('top'); +``` + +## userInfo(): ?string + +Get the user info component of the URI, or null if not present. + +```php +$utility->userInfo(); // null +``` + +## isValid(): bool + +Check if the URL is valid without making a request. + +```php +$utility->isValid(); // true +``` + +## toArray(): array + +Get all parsed URL components as an associative array. + +```php +$utility->toArray(); +// ['scheme' => 'https', 'host' => 'example.com', 'port' => 443, 'path' => '/path', 'query' => 'foo=bar', 'fragment' => 'section'] +``` + +## equals(UriUtility $other): bool + +Compare two URIs for equality. + +```php +$a = new UriUtility('https://example.com'); +$b = new UriUtility('https://example.com'); +$a->equals($b); // true +``` + ## check(ResponseFrom $method): Response Check the URL response using curl, headers, or HTTP client. @@ -105,5 +204,5 @@ $response->code(); // 200 Get the full URL string. ```php -$utility->value(); // 'https://example.com/path?foo=bar' +$utility->value(); // 'https://example.com/path?foo=bar#section' ``` diff --git a/src/ContentUtility.php b/src/ContentUtility.php index 6571acb..e4afd9a 100644 --- a/src/ContentUtility.php +++ b/src/ContentUtility.php @@ -2,11 +2,11 @@ namespace Myerscode\Utilities\Web; -use Myerscode\Utilities\Web\Exceptions\FourHundredResponseException; use Myerscode\Utilities\Web\Exceptions\ContentNotFoundException; +use Myerscode\Utilities\Web\Exceptions\FiveHundredResponseException; +use Myerscode\Utilities\Web\Exceptions\FourHundredResponseException; use Myerscode\Utilities\Web\Exceptions\MaxRedirectsReachedException; use Myerscode\Utilities\Web\Exceptions\NetworkErrorException; -use Myerscode\Utilities\Web\Exceptions\FiveHundredResponseException; use Myerscode\Utilities\Web\Resource\Dom; use Myerscode\Utilities\Web\Resource\Response; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; @@ -15,9 +15,21 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; +use JsonException; class ContentUtility { + /** + * Custom request headers + * + * @var array + */ + private array $headers = []; + + /** + * Request timeout in seconds + */ + private int $timeout = 30; /** * The url to get content from */ @@ -45,6 +57,19 @@ public function content(): string return $response->content(); } + /** + * Get the Content-Type header from the response + * + * @throws FourHundredResponseException + * @throws MaxRedirectsReachedException + * @throws NetworkErrorException + * @throws FiveHundredResponseException + */ + public function contentType(): ?string + { + return $this->response()->header('content-type'); + } + /** * Get the content as a content dom * @@ -56,6 +81,70 @@ public function dom(): Dom return new Dom($this->content()); } + /** + * Get the response headers + * + * @return array + * + * @throws FourHundredResponseException + * @throws MaxRedirectsReachedException + * @throws NetworkErrorException + * @throws FiveHundredResponseException + */ + public function headers(): array + { + return $this->response()->headers(); + } + + /** + * Decode the response content as JSON + * + * @return array + * + * @throws ContentNotFoundException + * @throws FiveHundredResponseException + * @throws JsonException + */ + public function json(): array + { + return $this->response()->json(); + } + + /** + * Send a POST request with data + * + * @param array $data + * + * @throws FourHundredResponseException + * @throws MaxRedirectsReachedException + * @throws NetworkErrorException + * @throws FiveHundredResponseException + */ + public function post(array $data = []): Response + { + try { + $options = ['timeout' => $this->timeout]; + + if ($this->headers !== []) { + $options['headers'] = $this->headers; + } + + $options['body'] = $data; + + $response = $this->client()->request('POST', $this->uriUtility->url(), $options); + + return new Response($response->getStatusCode(), $response->getContent(), $response->getHeaders()); + } catch (TransportExceptionInterface $e) { + throw new NetworkErrorException($e->getMessage(), $e->getCode(), $e); + } catch (ClientExceptionInterface $e) { + throw new FourHundredResponseException($e->getMessage(), $e->getResponse()->getStatusCode(), $e); + } catch (RedirectionExceptionInterface) { + throw new MaxRedirectsReachedException(); + } catch (ServerExceptionInterface) { + throw new FiveHundredResponseException(); + } + } + /** * @throws FourHundredResponseException * @throws MaxRedirectsReachedException @@ -66,11 +155,12 @@ public function response(): Response { try { $response = $this->clientResponse(); + return new Response($response->getStatusCode(), $response->getContent(), $response->getHeaders()); } catch (TransportExceptionInterface $e) { throw new NetworkErrorException($e->getMessage(), $e->getCode(), $e); } catch (ClientExceptionInterface $e) { - throw new FourHundredResponseException($e->getMessage(), $e->getCode(), $e); + throw new FourHundredResponseException($e->getMessage(), $e->getResponse()->getStatusCode(), $e); } catch (RedirectionExceptionInterface) { throw new MaxRedirectsReachedException(); } catch (ServerExceptionInterface) { @@ -78,6 +168,19 @@ public function response(): Response } } + /** + * Get the HTTP status code + * + * @throws FourHundredResponseException + * @throws MaxRedirectsReachedException + * @throws NetworkErrorException + * @throws FiveHundredResponseException + */ + public function statusCode(): int + { + return $this->response()->code(); + } + /** * Get the url that the content is got from */ @@ -86,6 +189,28 @@ public function url(): string return $this->uriUtility->url(); } + /** + * Set custom request headers + * + * @param array $headers + */ + public function withHeaders(array $headers): self + { + $this->headers = array_merge($this->headers, $headers); + + return $this; + } + + /** + * Set the request timeout in seconds + */ + public function withTimeout(int $seconds): self + { + $this->timeout = $seconds; + + return $this; + } + /** * Create a client to send a http request */ @@ -96,10 +221,16 @@ protected function client(): HttpClientInterface protected function clientResponse(): ResponseInterface { + $options = ['timeout' => $this->timeout]; + + if ($this->headers !== []) { + $options['headers'] = $this->headers; + } + return $this->client()->request( 'GET', $this->uriUtility->url(), + $options, ); } - } diff --git a/src/Exceptions/ContentNotFoundException.php b/src/Exceptions/ContentNotFoundException.php index eb90113..c05fcd2 100644 --- a/src/Exceptions/ContentNotFoundException.php +++ b/src/Exceptions/ContentNotFoundException.php @@ -2,8 +2,6 @@ namespace Myerscode\Utilities\Web\Exceptions; -use Exception; - -class ContentNotFoundException extends Exception +class ContentNotFoundException extends WebUtilityException { } diff --git a/src/Exceptions/EmptyUrlException.php b/src/Exceptions/EmptyUrlException.php index efaa034..aceaede 100644 --- a/src/Exceptions/EmptyUrlException.php +++ b/src/Exceptions/EmptyUrlException.php @@ -2,8 +2,6 @@ namespace Myerscode\Utilities\Web\Exceptions; -use Exception; - -class EmptyUrlException extends Exception +class EmptyUrlException extends WebUtilityException { } diff --git a/src/Exceptions/FiveHundredResponseException.php b/src/Exceptions/FiveHundredResponseException.php index 4f958f8..bfb3c79 100644 --- a/src/Exceptions/FiveHundredResponseException.php +++ b/src/Exceptions/FiveHundredResponseException.php @@ -2,8 +2,6 @@ namespace Myerscode\Utilities\Web\Exceptions; -use Exception; - -class FiveHundredResponseException extends Exception +class FiveHundredResponseException extends WebUtilityException { } diff --git a/src/Exceptions/FourHundredResponseException.php b/src/Exceptions/FourHundredResponseException.php index d2d3688..0de9b4c 100644 --- a/src/Exceptions/FourHundredResponseException.php +++ b/src/Exceptions/FourHundredResponseException.php @@ -2,8 +2,6 @@ namespace Myerscode\Utilities\Web\Exceptions; -use Exception; - -class FourHundredResponseException extends Exception +class FourHundredResponseException extends WebUtilityException { } diff --git a/src/Exceptions/InvalidUrlException.php b/src/Exceptions/InvalidUrlException.php index fe6f736..7e49e0c 100644 --- a/src/Exceptions/InvalidUrlException.php +++ b/src/Exceptions/InvalidUrlException.php @@ -2,8 +2,6 @@ namespace Myerscode\Utilities\Web\Exceptions; -use Exception; - -class InvalidUrlException extends Exception +class InvalidUrlException extends WebUtilityException { } diff --git a/src/Exceptions/MaxRedirectsReachedException.php b/src/Exceptions/MaxRedirectsReachedException.php index 7ae975e..78a005e 100644 --- a/src/Exceptions/MaxRedirectsReachedException.php +++ b/src/Exceptions/MaxRedirectsReachedException.php @@ -2,8 +2,6 @@ namespace Myerscode\Utilities\Web\Exceptions; -use Exception; - -class MaxRedirectsReachedException extends Exception +class MaxRedirectsReachedException extends WebUtilityException { } diff --git a/src/Exceptions/NetworkErrorException.php b/src/Exceptions/NetworkErrorException.php index f08ddc7..d8d5a20 100644 --- a/src/Exceptions/NetworkErrorException.php +++ b/src/Exceptions/NetworkErrorException.php @@ -2,8 +2,6 @@ namespace Myerscode\Utilities\Web\Exceptions; -use Exception; - -class NetworkErrorException extends Exception +class NetworkErrorException extends WebUtilityException { } diff --git a/src/Exceptions/UnsupportedCheckMethodException.php b/src/Exceptions/UnsupportedCheckMethodException.php index 810da47..4394285 100644 --- a/src/Exceptions/UnsupportedCheckMethodException.php +++ b/src/Exceptions/UnsupportedCheckMethodException.php @@ -2,8 +2,6 @@ namespace Myerscode\Utilities\Web\Exceptions; -use Exception; - -class UnsupportedCheckMethodException extends Exception +class UnsupportedCheckMethodException extends WebUtilityException { } diff --git a/src/Exceptions/WebUtilityException.php b/src/Exceptions/WebUtilityException.php new file mode 100644 index 0000000..002e918 --- /dev/null +++ b/src/Exceptions/WebUtilityException.php @@ -0,0 +1,9 @@ +ping()['alive']; + } + + /** + * Get the latency in milliseconds, or null if unreachable + */ + public function latency(): ?float + { + return $this->ping()['latency']; + } + + /** + * Ping a urls host and return whether it's alive and the latency */ public function ping(): array { @@ -36,20 +52,15 @@ public function ping(): array 'latency' => null, ]; - $ttl = $this->ttl; - $timeout = $this->timeout; - $host = escapeshellarg($this->uriUtility->host()); - + $host = $this->uriUtility->host(); $pingCmd = $this->getPingCommand($host); + $escapedHost = escapeshellarg($host); - // Construct the ping command - if (str_starts_with(strtoupper(PHP_OS), 'WIN')) { - $exec_string = sprintf('%s -n 1 -i %d -w %d %s', $pingCmd, $ttl, $timeout * 1000, $host); - } elseif (strtoupper(PHP_OS) === 'DARWIN') { - $exec_string = sprintf('%s -c 1 -t %d %s', $pingCmd, $ttl, $host); - } else { - $exec_string = sprintf('%s -c 1 -t %d -w %d %s', $pingCmd, $ttl, $timeout, $host); - } + $exec_string = match ($this->detectOs()) { + 'WIN' => sprintf('%s -n 1 -i %d -w %d %s', $pingCmd, $this->ttl, $this->timeout * 1000, $escapedHost), + 'DARWIN' => sprintf('%s -c 1 -t %d %s', $pingCmd, $this->ttl, $escapedHost), + default => sprintf('%s -c 1 -t %d -w %d %s', $pingCmd, $this->ttl, $this->timeout, $escapedHost), + }; $output = []; @@ -60,9 +71,9 @@ public function ping(): array } foreach ($output as $line) { - if (preg_match('/time[=<]?\\s?(?