From 5150af77cf362688fd3b8ea59523de83612c5077 Mon Sep 17 00:00:00 2001 From: Flavio Heleno Date: Tue, 26 Sep 2023 21:52:20 -0300 Subject: [PATCH] Add check:http-resp and view:http-resp commands Closes #8 --- bin/console.php | 6 +- config/dependencies.php | 6 +- .../Check/CheckHttpResponseCommand.php | 299 ++++++++++++++++++ .../Commands/View/ViewHttpResponseCommand.php | 216 +++++++++++++ src/Console/DataObjects/HTTP/HttpResponse.php | 89 +++++- src/Console/Services/HttpService.php | 167 ++++++++++ src/Console/Traits/DateUtilsTrait.php | 72 +++++ 7 files changed, 845 insertions(+), 10 deletions(-) create mode 100644 src/Console/Commands/Check/CheckHttpResponseCommand.php create mode 100644 src/Console/Commands/View/ViewHttpResponseCommand.php create mode 100644 src/Console/Services/HttpService.php diff --git a/bin/console.php b/bin/console.php index 5a79e01..027ff30 100755 --- a/bin/console.php +++ b/bin/console.php @@ -21,7 +21,9 @@ use Watchr\Console\Commands\Check\CheckAllCommand; use Watchr\Console\Commands\Check\CheckCertificateCommand; use Watchr\Console\Commands\Check\CheckDomainCommand; +use Watchr\Console\Commands\Check\CheckHttpResponseCommand; use Watchr\Console\Commands\View\ViewDomainCommand; +use Watchr\Console\Commands\View\ViewHttpResponseCommand; define( '__VERSION__', @@ -55,7 +57,9 @@ CheckAllCommand::getDefaultName() => CheckAllCommand::class, CheckCertificateCommand::getDefaultName() => CheckCertificateCommand::class, CheckDomainCommand::getDefaultName() => CheckDomainCommand::class, - ViewDomainCommand::getDefaultName() => ViewDomainCommand::class + CheckHttpResponseCommand::getDefaultName() => CheckHttpResponseCommand::class, + ViewDomainCommand::getDefaultName() => ViewDomainCommand::class, + ViewHttpResponseCommand::getDefaultName() => ViewHttpResponseCommand::class ] ) ); diff --git a/config/dependencies.php b/config/dependencies.php index 7dd16c8..34a984c 100644 --- a/config/dependencies.php +++ b/config/dependencies.php @@ -12,7 +12,7 @@ use Psr\Clock\ClockInterface; use Psr\Container\ContainerInterface; use Symfony\Component\Clock\NativeClock; -use Watchr\Console\Services\HTTP\HttpCheckService; +use Watchr\Console\Services\HttpService; return static function (ContainerBuilder $builder): void { $builder->addDefinitions( @@ -29,8 +29,8 @@ ClockInterface::class => static function (ContainerInterface $container): ClockInterface { return new NativeClock(); }, - HttpCheckService::class => static function (ContainerInterface $container): HttpCheckService { - return new HttpCheckService( + HttpService::class => static function (ContainerInterface $container): HttpService { + return new HttpService( 30, 120, sprintf('watchr (PHP %s; %s)', PHP_VERSION, PHP_OS_FAMILY) diff --git a/src/Console/Commands/Check/CheckHttpResponseCommand.php b/src/Console/Commands/Check/CheckHttpResponseCommand.php new file mode 100644 index 0000000..681ad5c --- /dev/null +++ b/src/Console/Commands/Check/CheckHttpResponseCommand.php @@ -0,0 +1,299 @@ +addOption( + 'method', + 'm', + InputOption::VALUE_REQUIRED, + 'The desired action to be performed', + 'GET' + ) + ->addOption( + 'add-header', + 'd', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Additional headers to be sent with request (format :)', + [] + ) + ->addOption( + 'body', + 'b', + InputOption::VALUE_REQUIRED, + 'Request body for POST, PUT and PATCH methods (prefix a filename with "@" to read its contents)' + ) + ->addOption( + 'auth-path', + 'a', + InputOption::VALUE_REQUIRED, + 'Path to a json file containing authentication type and required values' + ) + ->addOption( + 'status-code', + 'S', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Expected Response status code', + [200, 201, 202, 203, 204, 205, 206] + ) + ->addOption( + 'match-keyword', + 'K', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Keyword expected to match in the Response body contents' + ) + ->addOption( + 'not-match-keyword', + 'N', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Keyword expected not to match in the Response body contents' + ) + ->addOption( + 'no-body', + 'B', + InputOption::VALUE_NONE, + 'Assert that the Response body is empty' + ) + ->addOption( + 'fail-fast', + 'f', + InputOption::VALUE_NONE, + 'Exit immediately when a check fails instead of running all checks' + ) + ->addArgument( + 'url', + InputArgument::REQUIRED, + 'URL to be checked' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $url = (string)$input->getArgument('url'); + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + throw new InvalidArgumentException( + 'argument url must be a valid url' + ); + } + + $method = HttpRequestMethodEnum::tryFrom(strtoupper((string)$input->getOption('method'))); + $headers = (array)$input->getOption('add-header'); + $body = (string)$input->getOption('body'); + + $authentication = null; + $authPath = (string)$input->getOption('auth-path'); + if ($authPath !== '' && is_readable($authPath)) {} + + $statusCodes = (array)$input->getOption('status-code'); + + $matchKeywords = (array)$input->getOption('match-keyword'); + $notMatchKeywords = (array)$input->getOption('not-match-keyword'); + $noBody = (bool)$input->getOption('no-body'); + + $checks = [ + 'statusCodes' => $statusCodes !== [], + 'matchKeywords' => $matchKeywords !== [], + 'notMatchKeywords' => $notMatchKeywords !== [], + 'noBody' => $noBody === true + ]; + + $failFast = (bool)$input->getOption('fail-fast'); + + if ($output->isDebug() === true) { + $output->writeln(''); + $table = new Table($output); + $table + ->setHeaders(['Verification', 'Status', 'Value']) + ->addRows( + [ + [ + 'Status Code', + ($checks['statusCodes'] ? 'enabled' : 'disabled'), + $statusCodes === [] ? '-' : implode(', ', $statusCodes) + ], + [ + 'Match Keywords', + ($checks['matchKeywords'] ? 'enabled' : 'disabled'), + $matchKeywords === [] ? '-' : implode(', ', $matchKeywords) + ], + [ + 'Not Match Keywords', + ($checks['notMatchKeywords'] ? 'enabled' : 'disabled'), + $notMatchKeywords === [] ? '-' : implode(', ', $notMatchKeywords) + ], + [ + 'Empty Response Body', + ($checks['noBody'] ? 'enabled' : 'disabled'), + '-' + ] + ] + ) + ->render(); + + $output->writeln(''); + } + + $needHttp = ( + $checks['statusCodes'] || + $checks['matchKeywords'] || + $checks['notMatchKeywords'] || + $checks['noBody'] + ); + + if ($needHttp === false) { + $output->writeln( + 'All HTTP Response verifications are disabled, leaving', + OutputInterface::VERBOSITY_VERBOSE + ); + + return Command::SUCCESS; + } + + $output->writeln( + 'Starting HTTP Response checks', + OutputInterface::VERBOSITY_VERBOSE + ); + + $response = $this->httpService->request( + $url, + $method, + new HttpConfiguration($authentication, $body, $headers) + ); + + $errors = []; + if ($checks['statusCodes'] === true) { + $output->writeln( + sprintf( + 'Response status code: %d', + $response->responseCode + ), + OutputInterface::VERBOSITY_VERBOSE + ); + + if (in_array($response->responseCode, $statusCodes, true) === false) { + $errors[] = sprintf( + 'Response status code is not within expected list' + ); + + if ($failFast === true) { + $this->printErrors($errors, $output); + + return Command::FAILURE; + } + } + } + + $bodyContents = (string)$response->body; + $output->writeln( + sprintf( + 'Response body is %d bytes long', + strlen($bodyContents) + ), + OutputInterface::VERBOSITY_VERBOSE + ); + + if ($checks['matchKeywords'] === true) { + if ($bodyContents === '') { + $errors[] = 'Response body is empty, cannot match any keyword'; + + if ($failFast === true) { + $this->printErrors($errors, $output); + + return Command::FAILURE; + } + } + + foreach ($matchKeywords as $keyword) { + if (str_contains($bodyContents, $keyword) === false) { + $errors[] = sprintf( + 'Keyword "%s" was not found in response body', + $keyword + ); + + if ($failFast === true) { + $this->printErrors($errors, $output); + + return Command::FAILURE; + } + } + } + } + + if ($checks['notMatchKeywords'] === true) { + foreach ($notMatchKeywords as $keyword) { + if (str_contains($bodyContents, $keyword) === true) { + $errors[] = sprintf( + 'Keyword "%s" was found in response body', + $keyword + ); + + if ($failFast === true) { + $this->printErrors($errors, $output); + + return Command::FAILURE; + } + } + } + } + + if ($checks['noBody'] === true && $bodyContents !== '') { + $errors[] = 'Response body should be empty'; + + if ($failFast === true) { + $this->printErrors($errors, $output); + + return Command::FAILURE; + } + } + } catch (Exception $exception) { + $errors[] = $exception->getMessage(); + + if ($failFast === true) { + $this->printErrors($errors, $output); + + return Command::FAILURE; + } + } + + $output->writeln( + 'Finished HTTP Response checks', + OutputInterface::VERBOSITY_VERBOSE + ); + + if (count($errors) > 0) { + $this->printErrors($errors, $output); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + public function __construct(HttpService $httpService) { + parent::__construct(); + + $this->httpService = $httpService; + } +} diff --git a/src/Console/Commands/View/ViewHttpResponseCommand.php b/src/Console/Commands/View/ViewHttpResponseCommand.php new file mode 100644 index 0000000..5b651f9 --- /dev/null +++ b/src/Console/Commands/View/ViewHttpResponseCommand.php @@ -0,0 +1,216 @@ +addOption( + 'method', + 'm', + InputOption::VALUE_REQUIRED, + 'The desired action to be performed', + 'GET' + ) + ->addOption( + 'add-header', + 'd', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Additional headers to be sent with request (format :)', + [] + ) + ->addOption( + 'body', + 'b', + InputOption::VALUE_REQUIRED, + 'Request body for POST, PUT and PATCH methods (prefix a filename with "@" to read its contents)' + ) + ->addOption( + 'auth-path', + 'a', + InputOption::VALUE_REQUIRED, + 'Path to a json file containing authentication type and required values' + ) + ->addOption( + 'json', + 'j', + InputOption::VALUE_NONE, + 'Format the output as a JSON string' + ) + ->addArgument( + 'url', + InputArgument::REQUIRED, + 'URL to be requested' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $jsonOutput = (bool)$input->getOption('json'); + $url = $input->getArgument('url'); + try { + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + throw new InvalidArgumentException('argument url must be a valid url'); + } + + $method = HttpRequestMethodEnum::tryFrom(strtoupper((string)$input->getOption('method'))); + $headers = (array)$input->getOption('add-header'); + $body = (string)$input->getOption('body'); + + $authentication = null; + $authPath = (string)$input->getOption('auth-path'); + if ($authPath !== '' && is_readable($authPath)) {} + + $response = $this->httpService->request( + $url, + $method, + new HttpConfiguration($authentication, $body, $headers) + ); + + if ($jsonOutput === true) { + $output->write(json_encode($response)); + + return Command::SUCCESS; + } + + $output->writeln(''); + $table = new Table($output); + $table + ->setHeaderTitle('Details & Metrics') + ->setHeaders(['Description', 'Value']) + ->addRows( + [ + [ + 'Time it took from the start until the SSL connect/handshake was completed', + $this->fromMicroseconds($response->appConnectTime) + ], + [ + 'TLS certificate chain size', + count($response->certChain) . ' certificates' + ], + [ + 'Time it took to establish the connection', + $this->fromMicroseconds($response->connectTime) + ], + [ + 'Content length of download, read from "Content-Length" header', + $response->contentLength === -1 ? '-' : $response->contentLength . ' bytes' + ], + [ + 'The "Content-Type" of the requested document', + $response->contentType ?? '-' + ], + [ + 'The version used in the last HTTP connection', + $response->httpVersion === 0 ? '-' : 'HTTP/' . $response->httpVersion + ], + [ + 'Time until name resolving was complete', + $this->fromMicroseconds($response->namelookupTime) + ], + [ + 'Time from start until just before file transfer begins', + $this->fromMicroseconds($response->preTransferTime) + ], + [ + 'IP address of the most recent connection', + $response->primaryIp + ], + [ + 'Destination port of the most recent connection', + $response->primaryPort + ], + [ + 'The last response code', + $response->responseCode + ], + [ + 'Time until the first byte is about to be transferred', + $this->fromMicroseconds($response->startTransferTime) + ], + [ + 'Total transaction time for last transfer', + $this->fromMicroseconds($response->totalTime) + ], + [ + 'The redirect URL found in the last transaction', + $response->redirectUrl ?: '-' + ], + [ + 'Last effective URL', + $response->url + ] + ] + ) + ->render(); + $output->writeln(''); + + $output->writeln(''); + $table = new Table($output); + $table + ->setHeaderTitle('Response Headers') + ->setHeaders(['Name', 'Value']) + ->addRows( + array_map( + static function (string $value, string $key): array { + if (strlen($value) > 100) { + $value = substr($value, 0, 45) . '...' . substr($value, -45); + } + + return [$key, $value]; + }, + array_values($response->headers), + array_keys($response->headers) + ) + ) + ->render(); + $output->writeln(''); + + + return Command::SUCCESS; + } catch (Exception $exception) { + if ($jsonOutput === true) { + $out = ['error' => $exception->getMessage()]; + if ($output->isDebug() === true) { + $out['trace'] = $exception->getTrace(); + } + + $output->write(json_encode($out)); + + return Command::FAILURE; + } + + $output->writeln($exception->getMessage()); + if ($output->isDebug() === true) { + $output->writeln($exception->getTraceAsString()); + } + + return Command::FAILURE; + } + } + + public function __construct(HttpService $httpService) { + parent::__construct(); + + $this->httpService = $httpService; + } +} diff --git a/src/Console/DataObjects/HTTP/HttpResponse.php b/src/Console/DataObjects/HTTP/HttpResponse.php index e463a8d..4136318 100644 --- a/src/Console/DataObjects/HTTP/HttpResponse.php +++ b/src/Console/DataObjects/HTTP/HttpResponse.php @@ -3,29 +3,78 @@ namespace Watchr\Console\DataObjects\HTTP; +use JsonSerializable; use Watchr\Console\Contracts\Streams\StreamInterface; -final class HttpResponse { +final class HttpResponse implements JsonSerializable { + /** + * Time, in microseconds, it took from the start until the SSL connect/handshake to the remote host was completed. + */ public readonly int $appConnectTime; public readonly StreamInterface $body; /** + * The TLS certificate chain. + * * @var array> */ public readonly array $certChain; + /** + * Time in microseconds it took to establish the connection. + */ public readonly int $connectTime; - public readonly string $contentType; + /** + * Content length of download, read from "Content-Length" header + */ + public readonly int $contentLength; + /** + * The "Content-Type" of the requested document. + * Note: NULL indicates server did not send valid "Content-Type" header. + */ + public readonly string|null $contentType; /** * @var array */ public readonly array $headers; - public readonly int $httpVersion; + /** + * The version used in the last HTTP connection or 0 if the version can't be determined. + */ + public readonly string $httpVersion; + /** + * Time in microseconds until name resolving was complete. + */ public readonly int $namelookupTime; + /** + * Time in microseconds from start until just before file transfer begins. + */ public readonly int $preTransferTime; + /** + * IP address of the most recent connection. + */ public readonly string $primaryIp; + /** + * Destination port of the most recent connection. + */ public readonly int $primaryPort; + /** + * The last response code. + */ public readonly int $responseCode; + /** + * Time in microseconds until the first byte is about to be transferred. + */ public readonly int $startTransferTime; + /** + * Total transaction time in microseconds for last transfer. + */ public readonly int $totalTime; + /** + * The redirect URL found in the last transaction. + */ + public readonly string $redirectUrl; + /** + * Last effective URL. + */ + public readonly string $url; /** * @param array> $certChain @@ -36,21 +85,25 @@ public function __construct( StreamInterface $body, array $certChain, int $connectTime, - string $contentType, + int $contentLength, + string|null $contentType, array $headers, - int $httpVersion, + string $httpVersion, int $namelookupTime, int $preTransferTime, string $primaryIp, int $primaryPort, int $responseCode, int $startTransferTime, - int $totalTime + int $totalTime, + string $redirectUrl, + string $url ) { $this->appConnectTime = $appConnectTime; $this->body = $body; $this->certChain = $certChain; $this->connectTime = $connectTime; + $this->contentLength = $contentLength; $this->contentType = $contentType; $this->headers = $headers; $this->httpVersion = $httpVersion; @@ -61,5 +114,29 @@ public function __construct( $this->responseCode = $responseCode; $this->startTransferTime = $startTransferTime; $this->totalTime = $totalTime; + $this->redirectUrl = $redirectUrl; + $this->url = $url; + } + + public function jsonSerialize(): mixed { + return [ + 'appConnectTime' => $this->appConnectTime, + 'body' => (string)$this->body, + 'certChain' => $this->certChain, + 'connectTime' => $this->connectTime, + 'contentLength' => $this->contentLength, + 'contentType' => $this->contentType, + 'headers' => $this->headers, + 'httpVersion' => $this->httpVersion, + 'namelookupTime' => $this->namelookupTime, + 'preTransferTime' => $this->preTransferTime, + 'primaryIp' => $this->primaryIp, + 'primaryPort' => $this->primaryPort, + 'responseCode' => $this->responseCode, + 'startTransferTime' => $this->startTransferTime, + 'totalTime' => $this->totalTime, + 'redirectUrl' => $this->redirectUrl, + 'url' => $this->url + ]; } } diff --git a/src/Console/Services/HttpService.php b/src/Console/Services/HttpService.php new file mode 100644 index 0000000..85b4717 --- /dev/null +++ b/src/Console/Services/HttpService.php @@ -0,0 +1,167 @@ + + */ + private array $stdOpts; + + public function __construct( + int $connectTimeout, + int $timeout, + string $userAgent + ) { + $this->stdOpts = [ + CURLOPT_AUTOREFERER => true, + CURLOPT_COOKIESESSION => true, + CURLOPT_CERTINFO => true, + CURLOPT_FAILONERROR => false, + CURLOPT_DNS_SHUFFLE_ADDRESSES => true, + CURLOPT_DNS_USE_GLOBAL_CACHE => false, + CURLOPT_FILETIME => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_FORBID_REUSE => true, + CURLOPT_FRESH_CONNECT => true, + CURLOPT_TCP_NODELAY => true, + CURLOPT_HEADER => false, + CURLOPT_HTTP_CONTENT_DECODING => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => $connectTimeout, + CURLOPT_ENCODING => '', + CURLOPT_TIMEOUT => $timeout, + CURLOPT_COOKIEFILE => '', + CURLOPT_COOKIELIST => 'RELOAD', + CURLOPT_USERAGENT => $userAgent + ]; + } + + public function request( + string $url, + HttpRequestMethodEnum $requestMethod = HttpRequestMethodEnum::GET, + HttpConfiguration $configuration = null + ): HttpResponse { + $opts = [ + CURLOPT_NOBODY => $requestMethod === HttpRequestMethodEnum::HEAD, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_CUSTOMREQUEST => $requestMethod->value, + ]; + + if ($configuration !== null) { + $headers = []; + if ($configuration->authentication !== null) { + if ($configuration->authentication instanceof BasicAuthentication) { + $opts += [ + CURLOPT_HTTPAUTH => CURLAUTH_BASIC, + CURLOPT_USERNAME => $configuration->authentication->username, + CURLOPT_PASSWORD => $configuration->authentication->password + ]; + } else if ($configuration->authentication instanceof BearerTokenAuthentication) { + $opts += [ + CURLOPT_HTTPAUTH => CURLAUTH_BEARER, + CURLOPT_XOAUTH2_BEARER => $configuration->authentication->token + ]; + } else if ($configuration->authentication instanceof CookieAuthentication) { + $opts[CURLOPT_COOKIE] = sprintf( + '%s=%s', + $configuration->authentication->name, + $configuration->authentication->value + ); + } else if ($configuration->authentication instanceof DigestAuthentication) { + $opts += [ + CURLOPT_HTTPAUTH => CURLAUTH_DIGEST, + CURLOPT_USERNAME => $configuration->authentication->username, + CURLOPT_PASSWORD => $configuration->authentication->password + ]; + } else if ($configuration->authentication instanceof HeaderAuthentication) { + $headers[$configuration->authentication->name] = $configuration->authentication->value; + } + } + + if ($configuration->body !== null) { + $opts[CURLOPT_POSTFIELDS] = $configuration->body; + } + + $opts[CURLOPT_HTTPHEADER] = array_merge($configuration->headers, $headers); + } + + $responseHeaders = []; + $opts[CURLOPT_HEADERFUNCTION] = static function (CurlHandle $hCurl, string $data) use (&$responseHeaders): int { + $split = strpos($data, ': '); + if ($split === false) { + // ignore header lines that don't follow the expected format (: ) + return strlen($data); + } + + $name = trim(substr($data, 0, $split)); + $value = trim(substr($data, $split + 2)); + $responseHeaders[$name] = $value; + + return strlen($data); + }; + + if ($requestMethod === HttpRequestMethodEnum::HEAD) { + $responseBody = new NullStream(); + } else { + $responseBody = new Stream(fopen('php://temp', 'w+b')); + $opts[CURLOPT_WRITEFUNCTION] = static function (CurlHandle $hCurl, string $data) use ($responseBody): int { + return $responseBody->write($data); + }; + } + + $hCurl = curl_init($url); + if (curl_setopt_array($hCurl, $this->stdOpts + $opts) === false) { + throw new RuntimeException('Failed to set curl options'); + } + + curl_exec($hCurl); + if (curl_errno($hCurl) > 0) { + $curlError = curl_error($hCurl); + curl_close($hCurl); + + throw new RuntimeException($curlError); + } + + $responseBody->readOnly(); + + $response = new HttpResponse( + (int)curl_getinfo($hCurl, CURLINFO_APPCONNECT_TIME_T), + $responseBody, + curl_getinfo($hCurl, CURLINFO_CERTINFO), + (int)curl_getinfo($hCurl, CURLINFO_CONNECT_TIME_T), + (int)curl_getinfo($hCurl, CURLINFO_CONTENT_LENGTH_DOWNLOAD), + curl_getinfo($hCurl, CURLINFO_CONTENT_TYPE), + $responseHeaders, + (string)curl_getinfo($hCurl, CURLINFO_HTTP_VERSION), + (int)curl_getinfo($hCurl, CURLINFO_NAMELOOKUP_TIME_T), + (int)curl_getinfo($hCurl, CURLINFO_PRETRANSFER_TIME_T), + (string)curl_getinfo($hCurl, CURLINFO_PRIMARY_IP), + (int)curl_getinfo($hCurl, CURLINFO_PRIMARY_PORT), + (int)curl_getinfo($hCurl, CURLINFO_RESPONSE_CODE), + (int)curl_getinfo($hCurl, CURLINFO_STARTTRANSFER_TIME_T), + (int)curl_getinfo($hCurl, CURLINFO_TOTAL_TIME_T), + (string)curl_getinfo($hCurl, CURLINFO_REDIRECT_URL), + (string)curl_getinfo($hCurl, CURLINFO_EFFECTIVE_URL) + ); + + curl_close($hCurl); + + return $response; + } +} diff --git a/src/Console/Traits/DateUtilsTrait.php b/src/Console/Traits/DateUtilsTrait.php index 27ca089..7d7e5f3 100644 --- a/src/Console/Traits/DateUtilsTrait.php +++ b/src/Console/Traits/DateUtilsTrait.php @@ -63,4 +63,76 @@ private function humanReadableInterval(DateInterval $interval): string { return 'just now'; } + + private function fromMicroseconds(int $microseconds): string { + if ($microseconds < 1000) { + return "{$microseconds}us"; + } + + return sprintf( + '%s %s', + $this->fromMilliseconds(intdiv($microseconds, 1000)), + $this->fromMicroseconds($microseconds % 1000) + ); + } + + private function fromMilliseconds(int $milliseconds): string { + if ($milliseconds < 1000) { + return "{$milliseconds}ms"; + } + + return sprintf( + '%s %s', + $this->fromSeconds(intdiv($milliseconds, 1000)), + $this->fromMilliseconds($milliseconds % 1000) + ); + } + + private function fromSeconds(int $seconds): string { + if ($seconds < 60) { + return "{$seconds}s"; + } + + return sprintf( + '%s %s', + $this->fromMinutes(intdiv($seconds, 60)), + $this->fromSeconds($seconds % 60) + ); + } + + private function fromMinutes(int $minutes): string { + if ($minutes < 60) { + return "{$minutes}m"; + } + + return sprintf( + '%s %s', + $this->fromHours(intdiv($minutes, 60)), + $this->fromMinutes($minutes % 60) + ); + } + + private function fromHours(int $hours): string { + if ($hours < 24) { + return "{$hours}h"; + } + + return sprintf( + '%s %s', + $this->fromDays(intdiv($hours, 24)), + $this->fromHours($hours % 24) + ); + } + + private function fromDays(int $days): string { + if ($days < 365) { + return "{$days}d"; + } + + return sprintf( + '%dy %s', + intdiv($days, 365), + $this->fromDays($days % 365) + ); + } }