Implement Civi::url()
authorTim Otten <totten@civicrm.org>
Mon, 17 Jul 2023 10:03:24 +0000 (03:03 -0700)
committerTim Otten <totten@civicrm.org>
Mon, 24 Jul 2023 08:12:30 +0000 (01:12 -0700)
Civi.php
Civi/Core/Url.php [new file with mode: 0644]

index d582bba2b080bb307bccccb276862de803c7b751..83a264cb942ad5a5543b43185aedbb333a587ac2 100644 (file)
--- a/Civi.php
+++ b/Civi.php
@@ -218,4 +218,62 @@ class Civi {
     return \Civi\Core\Container::getBootService('settings_manager')->getBagByDomain($domainID);
   }
 
+  /**
+   * Construct a URL based on a logical service address.
+   *
+   * Ex: Link to constituent's dashboard (on frontend UI)
+   *   $url = Civi::url('frontend://civicrm/user?reset=1');
+   *
+   * Ex: Link to constituent's dashboard (on frontend UI or backend UI -- whatever matches current page-view)
+   *   $url = Civi::url('//civicrm/user?reset=1');
+   *
+   * Ex: Link to constituent's dashboard (with method calls - good for dynamic options)
+   *   $url = Civi::url('frontend:')
+   *     ->setPath('civicrm/user')
+   *     ->addQuery(['reset' => 1]);
+   *
+   * Ex: Link to constituent's dashboard (with quick flags: absolute URL, SSL required, HTML escaping)
+   *   $url = Civi::url('frontend://civicrm/user?reset=1', 'ash');
+   *
+   * Ex: Link to constituent's dashboard (with method flags - good for dynamic options)
+   *   $url = Civi::url('frontend://civicrm/user?reset=1')
+   *     ->setPreferFormat('absolute')
+   *     ->setSsl(TRUE)
+   *     ->setHtmlEscape(TRUE);
+   *
+   * Ex: Link to a dynamically generated asset-file.
+   *   $url = Civi::url('assetBuilder://crm-l10n.js?locale=en_US');
+   *
+   * Ex: Link to a static asset (resource-file) in an extension.
+   *   $url = Civi::url('ext://org.civicrm.search_kit/css/crmSearchTasks.css');
+   *
+   * NOTE: CiviCRM is integrated into many environments, and they handle URL-construction different ways.
+   * For example, in Joomla+WordPress, there are separate sub-applications for the public-facing
+   * frontend UI (`/`) and the staff-facing backend UI (`/wp-admin/` or `/administrator/`) -- each follows
+   * a different URL-formula. But in Drupal, all use the same formula. To
+   *
+   * @param string $logicalUri
+   *   Logical URI. The scheme of the URI may be one of:
+   *     - 'frontend://' (Front-end page-route for constituents)
+   *     - 'backend://' (Back-end page-route for staff)
+   *     - 'service://` (Web-service page-route for automated integrations; aka webhooks and IPNs)
+   *     - 'current://' (Whichever UI is currently active)
+   *     - 'assetBuilder://' (Dynamically-generated asset-file)
+   *     - 'ext://' (Static asset-file provided by an extension)
+   *   An empty scheme (`//hello.txt`) is equivalent to `current://hello.txt`.
+   * @param string|null $flags
+   *   List of flags. Some combination of the following:
+   *   - 'a': absolute
+   *   - 'r': relative
+   *   - 'h': html
+   *   - 'p': plain text
+   *   - 's': ssl
+   *   FIXME: Should we have a flag for appending 'resCacheCode'?
+   * @return \Civi\Core\Url
+   *   URL object which may be modified or rendered as text.
+   */
+  public static function url(string $logicalUri, ?string $flags = NULL): \Civi\Core\Url {
+    return new \Civi\Core\Url($logicalUri, $flags);
+  }
+
 }
diff --git a/Civi/Core/Url.php b/Civi/Core/Url.php
new file mode 100644 (file)
index 0000000..009c94e
--- /dev/null
@@ -0,0 +1,353 @@
+<?php
+
+namespace Civi\Core;
+
+/**
+ * Generate a URL.
+ *
+ * As input, this class takes a *logical URI*, which may include a range of configurable sub-parts (path, query, fragment, etc).
+ *
+ * As output, it provides a *concrete URL* that can be used by a web-browser to make requests.
+ */
+class Url {
+
+  /**
+   * @var string
+   *   Ex: 'frontend', 'backend'
+   */
+  private $scheme;
+
+  /**
+   * @var string
+   *   Ex: 'civicrm/dashboard'
+   */
+  private $path;
+
+  /**
+   * @var string
+   *   Ex: abc=123&xyz=456
+   */
+  private $query;
+
+  /**
+   * @var string|null
+   */
+  private $fragment;
+
+  /**
+   * Preferred format.
+   *
+   * Note that this is not strictly guaranteed. It may sometimes return absolute URLs even if you
+   * prefer relative URLs (e.g. if there's no easy/correct way to form a relative URL).
+   *
+   * @var string|null
+   *   'relative' or 'absolute'
+   *   NULL means "decide automatically"
+   */
+  private $preferFormat;
+
+  /**
+   * Whether to HTML-encode the output.
+   *
+   * Note: Why does this exist? It's insane, IMHO. There's nothing intrinsically HTML-y about URLs.
+   * However, practically speaking, this class aims to replace `CRM_Utils_System::url()` which
+   * performed HTML encoding by default. Retaining some easy variant of this flag should make the
+   * off-ramp a bit smoother.
+   *
+   * @var bool
+   */
+  private $htmlEscape = FALSE;
+
+  /**
+   * @var bool|null
+   *    NULL means "decide automatically"
+   */
+  private $ssl = NULL;
+
+  /**
+   * @param string $logicalUri
+   * @param string|null $flags
+   * @see \Civi::url()
+   */
+  public function __construct(string $logicalUri, ?string $flags = NULL) {
+    if ($logicalUri[0] === '/') {
+      $logicalUri = 'current:' . $logicalUri;
+    }
+
+    $parsed = parse_url($logicalUri);
+    $this->scheme = $parsed['scheme'] ?? NULL;
+    $this->path = $parsed['host'] ?? NULL;
+    if (isset($parsed['path'])) {
+      $this->path .= $parsed['path'];
+    }
+    $this->query = $parsed['query'] ?? NULL;
+    $this->fragment = $parsed['fragment'] ?? NULL;
+
+    if ($flags !== NULL) {
+      $this->useFlags($flags);
+    }
+  }
+
+  /**
+   * @return string
+   */
+  public function getScheme() {
+    return $this->scheme;
+  }
+
+  /**
+   * @param string $scheme
+   */
+  public function setScheme(string $scheme): Url {
+    $this->scheme = $scheme;
+    return $this;
+  }
+
+  /**
+   * @return mixed
+   */
+  public function getPath() {
+    return $this->path;
+  }
+
+  /**
+   * @param string $path
+   */
+  public function setPath(string $path): Url {
+    $this->path = $path;
+    return $this;
+  }
+
+  /**
+   * @param string|string[] $pathParts
+   * @return $this
+   */
+  public function addPath($pathParts): Url {
+    $suffix = implode('/', (array) $pathParts);
+    if ($this->path === NULL) {
+      $this->path = $suffix;
+    }
+    else {
+      $this->path = rtrim($this->path, '/') . '/' . $suffix;
+    }
+    return $this;
+  }
+
+  /**
+   * @return string|null
+   */
+  public function getQuery(): ?string {
+    return $this->query;
+  }
+
+  /**
+   * @param string|array|null $query
+   */
+  public function setQuery($query): Url {
+    $this->query = \CRM_Utils_System::makeQueryString($query);
+    return $this;
+  }
+
+  /**
+   * @param string|array $query
+   * @return $this
+   */
+  public function addQuery($query): Url {
+    if ($this->query === NULL) {
+      $this->query = \CRM_Utils_System::makeQueryString($query);
+    }
+    else {
+      $this->query .= '&' . \CRM_Utils_System::makeQueryString($query);
+    }
+    return $this;
+  }
+
+  /**
+   * @return string|null
+   */
+  public function getFragment(): ?string {
+    return $this->fragment;
+  }
+
+  /**
+   * @param string|null $fragment
+   */
+  public function setFragment(?string $fragment): Url {
+    $this->fragment = \CRM_Utils_System::makeQueryString($fragment);
+    return $this;
+  }
+
+  /**
+   * @param string|array $fragment
+   * @return $this
+   */
+  public function addFragment($fragment): Url {
+    if ($this->fragment === NULL) {
+      $this->fragment = \CRM_Utils_System::makeQueryString($fragment);
+    }
+    else {
+      $this->fragment .= '&' . \CRM_Utils_System::makeQueryString($fragment);
+    }
+    return $this;
+  }
+
+  /**
+   * @return string|null
+   *   'relative' or 'absolute'
+   */
+  public function getPreferFormat(): ?string {
+    return $this->preferFormat;
+  }
+
+  /**
+   * @param string|null $preferFormat
+   */
+  public function setPreferFormat(?string $preferFormat): Url {
+    $this->preferFormat = $preferFormat;
+    return $this;
+  }
+
+  /**
+   * @return bool
+   */
+  public function getHtmlEscape(): bool {
+    return $this->htmlEscape;
+  }
+
+  /**
+   * @param bool $htmlEscape
+   */
+  public function setHtmlEscape(bool $htmlEscape): Url {
+    $this->htmlEscape = $htmlEscape;
+    return $this;
+  }
+
+  /**
+   * @return bool|null
+   */
+  public function getSsl(): ?bool {
+    return $this->ssl;
+  }
+
+  /**
+   * @param bool|null $ssl
+   */
+  public function setSsl(?bool $ssl): Url {
+    $this->ssl = $ssl;
+    return $this;
+  }
+
+  /**
+   * @param string $flags
+   *   A series of flag-letters. Any of the following:
+   *   - [a]bsolute
+   *   - [r]elative
+   *   - [h]tml
+   *   - [s]sl
+   * @return $this
+   */
+  public function useFlags(string $flags): Url {
+    $len = strlen($flags);
+    for ($i = 0; $i < $len; $i++) {
+      switch ($flags[$i]) {
+        // (a)bsolute url
+        case 'a':
+          $this->preferFormat = 'absolute';
+          break;
+
+        // (r)elative url
+        case 'r':
+          $this->preferFormat = 'relative';
+          break;
+
+        // (h)tml encoding
+        case 'h':
+          $this->htmlEscape = TRUE;
+          break;
+
+        // (p)lain text encoding
+        case 'p':
+          $this->htmlEscape = FALSE;
+          break;
+
+        // (s)sl
+        case 's';
+          $this->ssl = TRUE;
+          break;
+      }
+    }
+    return $this;
+  }
+
+  /**
+   * Render the final URL as a string.
+   *
+   * @return string
+   */
+  public function __toString(): string {
+    $userSystem = \CRM_Core_Config::singleton()->userSystem;
+    $scheme = $this->getScheme();
+    $preferFormat = $this->getPreferFormat();
+
+    // Translate subjective values to real values.
+    switch ($scheme) {
+      case 'current':
+        $preferFormat = $preferFormat ?: 'relative';
+        $scheme = $userSystem->isFrontEndPage() ? 'frontend' : 'backend';
+        // The current call could actually be a 'service' request, but we treat those as equivalent to 'frontend', so maybe it doesn't matter.
+        break;
+
+      case 'default':
+        // $preferFormat = $preferFormat ?: 'absolute';
+        // TODO pick $scheme = 'frontend' or 'backend' or 'service';
+        throw new \RuntimeException("FIXME: Implement lookup for default ");
+
+      default:
+        $preferFormat = $preferFormat ?: 'absolute';
+    }
+
+    switch ($scheme) {
+      case 'frontend':
+      case 'service':
+        $result = $userSystem->url($this->getPath(), $this->getQuery(), $preferFormat === 'absolute', $this->getFragment(), TRUE, FALSE, FALSE);
+        break;
+
+      case 'backend':
+        $result = $userSystem->url($this->getPath(), $this->getQuery(), $preferFormat === 'absolute', $this->getFragment(), FALSE, TRUE, FALSE);
+        break;
+
+      case 'assetBuilder':
+        $assetName = $this->getPath();
+        $assetParams = [];
+        parse_str('' . $this->getQuery(), $assetParams);
+        $result = \Civi::service('asset_builder')->getUrl($assetName, $assetParams);
+        break;
+
+      case 'ext':
+        $parts = explode('/', $this->getPath(), 2);
+        $result = \Civi::resources()->getUrl($parts[0], $parts[1] ?? NULL, FALSE);
+        if ($this->query) {
+          $result .= '?' . $this->query;
+        }
+        if ($this->fragment) {
+          $result .= '#' . $this->fragment;
+        }
+        break;
+
+      default:
+        throw new \RuntimeException("Unknown URL scheme: {$this->getScheme()}");
+    }
+
+    // TODO decide if the current default is good enough for future
+    $ssl = $this->getSsl() ?: \CRM_Utils_System::isSSL();
+    if ($ssl && str_starts_with($result, 'http:')) {
+      $result = 'https:' . substr($result, 5);
+    }
+    elseif (!$ssl && str_starts_with($result, 'https:')) {
+      $result = 'http:' . substr($result, 6);
+    }
+
+    return $this->htmlEscape ? htmlentities($result) : $result;
+  }
+
+}