From ef79c4209e19469fa86bbe09f2551a08dfc9cef6 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Tue, 21 Apr 2026 16:42:02 +0200 Subject: [PATCH] Added `PersistentSession` to be used for sharing a Data API session token across multiple PHP requests. --- src/FMDataAPI.php | 134 +++++++++++++++++- src/PersistentSession/ApcuSessionCache.php | 31 ++++ src/PersistentSession/PersistentSession.php | 120 ++++++++++++++++ .../SessionCacheInterface.php | 26 ++++ src/Supporting/CommunicationProvider.php | 10 +- 5 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 src/PersistentSession/ApcuSessionCache.php create mode 100644 src/PersistentSession/PersistentSession.php create mode 100644 src/PersistentSession/SessionCacheInterface.php diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 24047eb..cd31ec0 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -2,10 +2,14 @@ namespace INTERMediator\FileMakerServer\RESTAPI; +use Exception; +use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\ApcuSessionCache; +use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\PersistentSession; +use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\SessionCacheInterface; +use INTERMediator\FileMakerServer\RESTAPI\Supporting\CommunicationProvider; use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerLayout; use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerRelation; -use INTERMediator\FileMakerServer\RESTAPI\Supporting\CommunicationProvider; -use Exception; +use RuntimeException; /** * Class FMDataAPI is the wrapper of The REST API in Claris FileMaker Server and FileMaker Cloud for AWS. @@ -38,6 +42,12 @@ class FMDataAPI */ private CommunicationProvider|null $provider; + /** + * @var null|PersistentSession Keeping the PersistentSession object. + * @ignore + */ + private PersistentSession|null $persistentSession = null; + /** * FMDataAPI constructor. If you want to activate OAuth authentication, $user and $password are set as * oAuthRequestId and oAuthIdentifier. Moreover, call useOAuth method before accessing layouts. @@ -58,6 +68,8 @@ class FMDataAPI * Ex. [{"database"=>"", "username"=>"", "password"=>""}]. * If you use OAuth, "oAuthRequestId" and "oAuthIdentifier" keys have to be specified. * @param boolean $isUnitTest If it's set to true, the communication provider just works locally. + * @param SessionCacheInterface|null $sessionCache Cache backend for persistent sessions. If omitted, APCu will be + * used if available, otherwise, no caching will be used. */ public function __construct(string $solution, string $user, @@ -66,7 +78,8 @@ public function __construct(string $solution, int|null $port = null, string|null $protocol = null, array|null $fmDataSource = null, - bool $isUnitTest = false) + bool $isUnitTest = false, + SessionCacheInterface|null $sessionCache = null) { if (is_null($password)) { $password = "password"; // For testing purpose. @@ -76,6 +89,14 @@ public function __construct(string $solution, } else { $this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource); } + + if ($sessionCache !== null) { + $this->persistentSession = new PersistentSession($sessionCache, $solution, $user); + $this->provider->keepPersistentSession = true; + } elseif (function_exists('apcu_fetch')) { + $this->persistentSession = new PersistentSession(new ApcuSessionCache(), $solution, $user); + $this->provider->keepPersistentSession = true; + } } /** @@ -189,9 +210,9 @@ public function getFieldHTMLEncoding(): bool /** * Set session token - * @param string $value The session token. + * @param string|null $value The session token. */ - public function setSessionToken(string $value): void + public function setSessionToken(string|null $value): void { $this->provider->accessToken = $value; } @@ -293,6 +314,109 @@ public function endCommunication(): void $this->provider->logout(); } + /** + * Begin a persistent session which is a serial calling of any database operations, and keep the session token. + * + * This persistent session persists between multiple PHP requests. + * @throws Exception + */ + public function beginPersistentSession(): void + { + if ($this->persistentSession === null) { + throw new RuntimeException( + "Persistent sessions require a cache backend. Install ext-apcu or provide a SessionCacheInterface implementation." + ); + } + + try { + if (!$this->persistentSession->applyCachedSessionToken($this)) { + if ($this->provider->login()) { + $this->persistentSession->cacheCurrentSessionToken($this); + } + } + $this->provider->keepPersistentSession = true; + } catch (Exception $e) { + $this->persistentSession->clearCachedSessionToken(); + $this->setSessionToken(null); + $this->provider->keepPersistentSession = false; + throw $e; + } + } + + /** + * End a persistent session which is a serial calling of any database operations, and logout. + * @throws Exception + */ + public function closePersistentSession(): void + { + if ($this->persistentSession !== null) { + $this->persistentSession->clearCachedSessionToken(); + } + $this->provider->keepPersistentSession = false; + $this->provider->logout(); + } + + /** + * Execute a callback with this FMDataAPI instance. + * + * When persistent sessions are enabled, this method uses the cached session when available. If the session is no + * longer valid, it creates and caches a new one, then retries the callback once. + * + * When persistent sessions are not enabled, the callback is invoked immediately with this instance. + * + * Example: + * + * $client = new FMDataAPI('MySolution', 'MyUser', 'MyPassword'); + * $result = $client->execute( + * fn (FMDataAPI $fm) => $fm->layout('MyLayout')->query( + * // do stuff + * ) + * ); + * + * + * @template TReturn + * @param callable(FMDataAPI): TReturn $fn + * @return TReturn + * @throws Exception Any exception thrown by the callback or the underlying provider. + */ + public function execute(callable $fn) + { + if (!$this->provider->keepPersistentSession || $this->persistentSession === null) { + return $fn($this); + } + + if ($this->provider->throwExceptionInError) { + try { + return $fn($this); + } catch (Exception $e) { + if ($this->errorCode() == 952) { + $this->refreshSession(); + return $fn($this); + } + throw $e; + } + } + + $result = $fn($this); + if ($this->errorCode() == 952) { + $this->refreshSession(); + return $fn($this); + } + return $result; + } + + /** + * Clear the current persistent session state, log in again, and cache the refreshed session token. + * @throws Exception + */ + private function refreshSession(): void + { + $this->persistentSession->clearCachedSessionToken(); + $this->setSessionToken(null); + $this->provider->login(); + $this->persistentSession->cacheCurrentSessionToken($this); + } + /** * Set the value to the global field. * @param array $fields Associated array contains the global field names (Field names must be Fully Qualified) and its values. diff --git a/src/PersistentSession/ApcuSessionCache.php b/src/PersistentSession/ApcuSessionCache.php new file mode 100644 index 0000000..2ee8c68 --- /dev/null +++ b/src/PersistentSession/ApcuSessionCache.php @@ -0,0 +1,31 @@ +cache = $cache; + $this->database = $database; + $this->user = $user; + } + + /** + * Apply the cached session token to the FMDataAPI object if it exists. + * + * @param FMDataAPI $client The FMDataAPI object. + * @return bool True if the cached session token was applied. + */ + public function applyCachedSessionToken(FMDataAPI $client): bool + { + $token = $this->getCachedSessionToken(); + if ($token === false) { + return false; + } + $client->setSessionToken($token); + return true; + } + + /** + * Cache the current session token. + * + * @param FMDataAPI $client The FMDataAPI object. + */ + public function cacheCurrentSessionToken(FMDataAPI $client): void + { + $token = $this->getCurrentSessionTokenOrFail($client); + $this->cache->set($this->cacheKey(), $token, self::TOKEN_TTL); + } + + /** + * Clear the cached session token. + */ + public function clearCachedSessionToken(): void + { + $this->cache->delete($this->cacheKey()); + } + + /** + * Retrieve a cached token. Returns false if the key doesn't exist. + * @return string|false + */ + private function getCachedSessionToken(): string|false + { + return $this->cache->get($this->cacheKey()); + } + + /** + * @param FMDataAPI $client + * @return string + */ + private function getCurrentSessionTokenOrFail(FMDataAPI $client): string + { + $token = $client->getSessionToken(); + if ($token === null) { + throw new RuntimeException("Current session token is not available."); + } + return $token; + } + + /** + * @return string + */ + private function cacheKey(): string + { + return "fm_token:{$this->database}:{$this->user}"; + } +} diff --git a/src/PersistentSession/SessionCacheInterface.php b/src/PersistentSession/SessionCacheInterface.php new file mode 100644 index 0000000..173c882 --- /dev/null +++ b/src/PersistentSession/SessionCacheInterface.php @@ -0,0 +1,26 @@ +storeToProperties(); $this->accessToken = null; throw $e; } @@ -528,7 +536,7 @@ public function login(): bool */ public function logout(): void { - if ($this->keepAuth) { + if ($this->keepAuth || $this->keepPersistentSession) { return; } $params = ["sessions" => $this->accessToken];