From 7767d91ccb7d5bda5bff9419755a548c00e5a235 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 9 Jan 2024 00:13:42 -0800 Subject: [PATCH] LocalHttpClient - How do you make an internal/headless HTTP request? --- Civi/Test/LocalHttpClient.php | 276 ++++++++++++++++++++++ Civi/Test/LocalHttpClient/ClassProps.php | 31 +++ Civi/Test/LocalHttpClient/SuperGlobal.php | 34 +++ 3 files changed, 341 insertions(+) create mode 100644 Civi/Test/LocalHttpClient.php create mode 100644 Civi/Test/LocalHttpClient/ClassProps.php create mode 100644 Civi/Test/LocalHttpClient/SuperGlobal.php diff --git a/Civi/Test/LocalHttpClient.php b/Civi/Test/LocalHttpClient.php new file mode 100644 index 0000000000..5463935b36 --- /dev/null +++ b/Civi/Test/LocalHttpClient.php @@ -0,0 +1,276 @@ + FALSE]); + * $response = $c->sendRequest(new Request('GET', '/civicrm/foo?reset=1&bar=100')); + * $response = $c->sendRequest(new Request('GET', '/civicrm/whiz?reset=1&bang=200')); + * + * In theory, this could be the basis for headless HTTP testing with client-libraries like Guzzle, Mink, or BrowserKit. + * + * WHY: CiviCRM predates the PSR HTTP OOP conventions -- many things are built with $_GET, $_REQUEST, etc. + * To simulate an HTTP request to these, we swap-in and swap-out values for $_GET, $_REQUEST, etc. + * Consequently, there is some limited isolation between the parent/requester and child/requestee. + * + * NOTE: You can improve the isolation more with `reboot=>TRUE`. This will swap (and reinitialize) + * the CiviCRM runtime-config and service-container. However, there is no comprehensive option to + * swap all static properties (other classes), so some data may still leak between requester+requestee. + * + * NOTE: This is primarily intended for use in headless testing (CIVICRM_UF=UnitTests). It may + * or may not be quirky with real UFs. + * + * @link https://www.php-fig.org/psr/psr-18/ + */ +class LocalHttpClient implements ClientInterface { + + /** + * List of scopes which should be backed-up, (re)populated, (re)set for the duration of the subrequest. + * + * @var array + * Ex: ['_GET' => new SuperGlobal('_GET')] + */ + protected array $scopes; + + /** + * List of scopes which should be inherited/extended within the subrequest. + * + * @var array + * Ex: ['_COOKIE', '_SERVER'] + */ + protected array $inherit; + + /** + * Whether to generate the HTML er + * + * @var bool + */ + protected bool $htmlHeader; + + /** + * @param array $options + * - reboot (bool): TRUE if you want to re-bootstrap CiviCRM (config/container) on each request + * Default: FALSE + * - htmlHeader (bool): TRUE if you want the generated page to include the full HTML header + * This may become standard (non-optional). It's opt-out to help debug/work-around some early + * quirks when first using LocalHttpClient in CI. + * - globals (string[]): List of (super)globals that should be backed-up, populated, used, and restored. + * Default: ['_GET', '_POST', '_COOKIE', '_FILES', '_SERVER', '_REQUEST'] + * - inherit (string[]): When populating these (super)globals, build on top of the existing values. + * Default: ['_COOKIE', '_SERVER'] + */ + public function __construct(array $options = []) { + $defaultOptions = [ + 'reboot' => FALSE, + 'htmlHeader' => TRUE, + 'globals' => ['_GET', '_POST', '_COOKIE', '_FILES', '_SERVER', '_REQUEST'], + 'inherit' => ['_COOKIE', '_SERVER'], + ]; + $options = array_merge($defaultOptions, $options); + + $this->inherit = $options['inherit']; + $this->htmlHeader = $options['htmlHeader']; + $this->scopes = []; + + foreach ($options['globals'] as $scopeName) { + $this->scopes[$scopeName] = new SuperGlobal($scopeName); + } + + if ($options['reboot']) { + $classes = [ + \Civi::class, + \CRM_Core_Config::class, + \CRM_Utils_Hook::class, + \Civi\Core\Resolver::class, + \CRM_Queue_Service::class, + \CRM_Utils_System::class, + \CRM_Utils_Cache::class, + ]; + foreach ($classes as $class) { + $this->scopes[$class] = new ClassProps($class); + } + } + } + + public function sendRequest(RequestInterface $request): ResponseInterface { + $backup = $this->getAllValues(); + try { + $this->initScopes($request); + + $var = \CRM_Core_Config::singleton()->userFrameworkURLVar; + if (!isset($_GET[$var])) { + $_GET[$var] = ltrim($request->getUri()->getPath(), '/'); + } + $body = $this->invoke($_GET[$var]); + // FIXME: There's probably a way to instrument CRM_Utils_System_UnitTests to do this better. + return new Response(200, [], $body); + } + catch (\CRM_Core_Exception_PrematureExitException $e) { + if (isset($e->errorData['response'])) { + return $e->errorData['response']; + } + // FIXME: There are some things which emit PrematureExitException but don't provide the $response object. + // We should probably revise \CRM_Utils_System::redirect() and returnJsonResponse() + else { + throw $e; + } + } + finally { + $this->restoreAllValues($backup); + } + } + + protected function initScopes(RequestInterface $request) { + foreach ($this->scopes as $scopeName => $scope) { + if (!in_array($scopeName, $this->inherit)) { + $scope->unsetKeys(array_keys($scope->getValues())); + } + + $method = 'initValues' . $scopeName; + $initValues = is_callable([$this, $method]) ? $this->$method($request) : []; + $scope->setValues($initValues); + } + if (in_array('CRM_Core_Config', $this->scopes)) { + \CRM_Core_Config::singleton(); + } + } + + /** + * Map data from the request to $_GET. + * + * @param \Psr\Http\Message\RequestInterface $request + * @return array + */ + protected function initValues_GET(RequestInterface $request): array { + $result = []; + parse_str($request->getUri()->getQuery() ?: '', $result); + return $result; + } + + /** + * Map data from the request to $_POST. + * + * @param \Psr\Http\Message\RequestInterface $request + * @return array + */ + protected function initValues_POST(RequestInterface $request): array { + $result = []; + if ($request->getMethod() === 'POST') { + $contentTypes = $request->getHeader('Content-Type'); + if (in_array('application/x-www-form-urlencoded', $contentTypes) || empty($contentTypes)) { + $body = (string) $request->getBody(); + parse_str($body, $result); + } + } + return $result; + } + + /** + * Map data from the request to $_REQUEST. + * + * @param \Psr\Http\Message\RequestInterface $request + * @return array + */ + protected function initValues_REQUEST(RequestInterface $request): array { + $sources = ['g' => '_GET', 'p' => '_POST', 'c' => '_COOKIE']; + + if (ini_get('request_order')) { + $order = strtolower(ini_get('request_order')); + } + elseif (ini_get('variables_order')) { + $order = strtolower(ini_get('variables_order')); + } + else { + $order = 'gpc'; + } + + $result = []; + for ($i = 0; $i < strlen($order); $i++) { + if (isset($sources[$order[$i]])) { + $scope = $this->scopes[$sources[$order[$i]]]; + $result = array_merge($result, $scope->getValues()); + } + } + return $result; + } + + protected function initValues_SERVER(RequestInterface $request): array { + $uri = $request->getUri(); + + $result = []; + $result['REQUEST_METHOD'] = $request->getMethod(); + $result['REQUEST_URI'] = $uri->getPath(); + if ($uri->getQuery()) { + $result['REQUEST_URI'] .= '?' . $uri->getQuery(); + } + if ($uri->getHost()) { + $result['HTTP_HOST'] = $uri->getHost(); + if ($uri->getPort()) { + $result['HTTP_HOST'] .= ':' . $uri->getPort(); + $result['SERVER_PORT'] = $uri->getPort(); + } + $result['SERVER_NAME'] = $uri->getHost(); + } + $result['HTTP_USER_AGENT'] = __CLASS__; + return $result; + } + + protected function invoke(string $route): ?string { + if ($this->htmlHeader) { + \CRM_Core_Resources::singleton()->addCoreResources('html-header'); + } + + ob_start(); + try { + $pageContent = \CRM_Core_Invoke::_invoke(explode('/', $route)); + } + finally { + $printedContent = ob_get_clean(); + } + + if (empty($pageContent) && !empty($printedContent)) { + $pageContent = $printedContent; + } + + $locale = \CRM_Core_I18n::getLocale(); + $lang = substr($locale, 0, 2); + $dir = \CRM_Core_I18n::isLanguageRTL($locale) ? 'rtl' : 'ltr'; + $head = $this->htmlHeader ? \CRM_Core_Region::instance('html-header')->render('') : ''; + + return << + +$head +$pageContent + +PAGETPL; + } + + public function getAllValues(): array { + $backup = []; + foreach ($this->scopes as $scopeName => $scope) { + $backup[$scopeName] = $scope->getValues(); + } + return $backup; + } + + protected function restoreAllValues(array &$backup): void { + foreach ($this->scopes as $scopeName => $scope) { + $extraKeys = array_diff(array_keys($scope->getValues()), array_keys($backup[$scopeName])); + $scope->unsetKeys($extraKeys); + $scope->setValues($backup[$scopeName]); + } + } + +} diff --git a/Civi/Test/LocalHttpClient/ClassProps.php b/Civi/Test/LocalHttpClient/ClassProps.php new file mode 100644 index 0000000000..a894b1482a --- /dev/null +++ b/Civi/Test/LocalHttpClient/ClassProps.php @@ -0,0 +1,31 @@ +class = new \ReflectionClass($class); + } + + public function getValues() { + return $this->class->getStaticProperties() ?: []; + } + + public function setValues(iterable $values): void { + foreach ($values as $key => $value) { + $this->class->setStaticPropertyValue($key, $value); + } + } + + public function unsetKeys(iterable $keys): void { + foreach ($keys as $key) { + $this->class->setStaticPropertyValue($key, NULL); + } + } + +} diff --git a/Civi/Test/LocalHttpClient/SuperGlobal.php b/Civi/Test/LocalHttpClient/SuperGlobal.php new file mode 100644 index 0000000000..15649255f4 --- /dev/null +++ b/Civi/Test/LocalHttpClient/SuperGlobal.php @@ -0,0 +1,34 @@ +name = $name; + } + + public function getValues() { + return $GLOBALS[$this->name]; + } + + public function setValues(iterable $values): void { + foreach ($values as $key => $value) { + $GLOBALS[$this->name][$key] = $value; + } + } + + public function unsetKeys(iterable $keys): void { + foreach ($keys as $key) { + unset($GLOBALS[$this->name][$key]); + } + } + +} -- 2.25.1