Skip to content

Commit b8bfa06

Browse files
authored
Merge pull request #156 from MjukBiltvatt/master
Add persistent session support with automatic token renewal and retry
2 parents 16a33a9 + 12edd85 commit b8bfa06

8 files changed

Lines changed: 616 additions & 64 deletions

File tree

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
"phpunit/phpunit": "*",
1919
"phpstan/phpstan": "^2.0"
2020
},
21+
"suggest": {
22+
"ext-apcu": "Optional: required only by the built-in ApcuSessionCache backend."
23+
},
2124
"autoload": {
2225
"psr-4": {
2326
"INTERMediator\\FileMakerServer\\RESTAPI\\": "src/"

src/FMDataAPI.php

Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace INTERMediator\FileMakerServer\RESTAPI;
44

5+
use INTERMediator\FileMakerServer\RESTAPI\SessionCache\SessionCacheInterface;
56
use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerLayout;
67
use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerRelation;
78
use INTERMediator\FileMakerServer\RESTAPI\Supporting\CommunicationProvider;
@@ -58,23 +59,33 @@ class FMDataAPI
5859
* Ex. [{"database"=>"<databaseName>", "username"=>"<username>", "password"=>"<password>"}].
5960
* If you use OAuth, "oAuthRequestId" and "oAuthIdentifier" keys have to be specified.
6061
* @param boolean $isUnitTest If it's set to true, the communication provider just works locally.
61-
*/
62-
public function __construct(string $solution,
63-
string $user,
64-
string|null $password,
65-
string|null $host = null,
66-
int|null $port = null,
67-
string|null $protocol = null,
68-
array|null $fmDataSource = null,
69-
bool $isUnitTest = false)
62+
* @param SessionCacheInterface|null $sessionCache Cache backend for persistent sessions.
63+
* If omitted, the library logs in and out on every database operation, or once
64+
* per communication scope when using startCommunication() / endCommunication().
65+
* If specified, session tokens are persisted and reused across requests via
66+
* startCommunication() / endCommunication(), avoiding redundant logins against the FileMaker Server.
67+
* When a session cache is specified, {@see self::setRetryOnAccessTokenInvalidation()} is
68+
* automatically set to true, ensuring the library re-authenticates and retries the request if
69+
* the cached token has expired on the FileMaker Server.
70+
*/
71+
public function __construct(string $solution,
72+
string $user,
73+
string|null $password,
74+
string|null $host = null,
75+
int|null $port = null,
76+
string|null $protocol = null,
77+
array|null $fmDataSource = null,
78+
bool $isUnitTest = false,
79+
SessionCacheInterface|null $sessionCache = null)
7080
{
7181
if (is_null($password)) {
7282
$password = "password"; // For testing purpose.
7383
}
84+
7485
if (!$isUnitTest) {
75-
$this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource);
86+
$this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionCache);
7687
} else {
77-
$this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource);
88+
$this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionCache);
7889
}
7990
}
8091

@@ -264,33 +275,42 @@ public function setThrowException(bool $value): void
264275
}
265276

266277
/**
267-
* Start a transaction which is a serial calling of multiple database operations before the single authentication.
268-
* Usually most methods login and logout before/after the database operation, and so a little bit of time is going to
269-
* take.
270-
* The startCommunication() login and endCommunication() logout, and methods between them don't log in/out, and
271-
* it can expect faster operations.
278+
* Start a communication scope with a shared authenticated session.
279+
*
280+
* Usually most methods login and logout before and after each database operation.
281+
* By calling startCommunication() and endCommunication(), methods between them don't
282+
* log in and out every time, and it can expect faster operations.
283+
*
284+
* Without a session cache, one authenticated session is kept for the duration of
285+
* the current communication scope and discarded when endCommunication() is called.
286+
*
287+
* With a session cache, the session token is persisted beyond the current communication
288+
* scope and reused across requests. If no cached token is available, a new session is
289+
* created and stored for future reuse.
290+
*
272291
* @throws Exception
273292
*/
274293
public function startCommunication(): void
275294
{
276-
try {
277-
if ($this->provider->login()) {
278-
$this->provider->keepAuth = true;
279-
}
280-
} catch (Exception $e) {
281-
$this->provider->keepAuth = false;
282-
throw $e;
283-
}
295+
$this->provider->startCommunication();
284296
}
285297

286298
/**
287-
* Finish a transaction which is a serial calling of any database operations, and logout.
299+
* Finish a communication scope.
300+
*
301+
* Without a session cache, the authenticated session for the current communication
302+
* scope is ended and the server session is logged out.
303+
*
304+
* With a session cache, the cached token's TTL is renewed if it still matches the
305+
* token held by this instance. If another process has replaced the cached token in
306+
* the meantime, only this instance's now-stale token is logged out, leaving the
307+
* newer cached token intact.
308+
*
288309
* @throws Exception
289310
*/
290311
public function endCommunication(): void
291312
{
292-
$this->provider->keepAuth = false;
293-
$this->provider->logout();
313+
$this->provider->endCommunication();
294314
}
295315

296316
/**
@@ -425,4 +445,44 @@ public function setExcludeTimeStampInException(bool $value = true): void
425445
{
426446
$this->provider->excludeTimeStampInException = $value;
427447
}
448+
449+
/**
450+
* Controls whether failed Data API calls are automatically retried after session invalidation.
451+
*
452+
* When enabled and a call fails with error 952 (invalid token) or 112 (window missing), the
453+
* current session is discarded, a new session is established, and the call is retried once.
454+
*
455+
* When a session cache is provided to the constructor, retry on token invalidation is always
456+
* active regardless of this setting. This flag only has an effect when no session cache is
457+
* configured.
458+
*
459+
* Warning: The retry runs in a fresh session. Any session-scoped state from the original session
460+
* is lost — for example, global fields set before the retry will not carry over.
461+
* @param bool $value
462+
*/
463+
public function setRetryOnAccessTokenInvalidation(bool $value = true): void
464+
{
465+
$this->provider->retryOnAccessTokenInvalidation = $value;
466+
}
467+
468+
/**
469+
* Overrides the time-to-live (TTL) of the cached FileMaker Data API session token.
470+
*
471+
* WARNING: Setting a TTL that exceeds the FileMaker Data API session timeout (15 minutes)
472+
* will cause the library to use expired tokens, resulting in authentication failures.
473+
* Do not use this method unless you fully understand the implications.
474+
*
475+
* The default TTL is 840 seconds (14 minutes), intentionally set one minute below the
476+
* FileMaker Data API session timeout of 15 minutes to ensure the cached token is
477+
* invalidated before it expires on the FileMaker Server.
478+
* @param int $ttl Time-to-live in seconds. Defaults to 840 seconds (14 minutes).
479+
* @throws Exception If a session cache is not set, an exception is thrown.
480+
*/
481+
public function setSessionCacheTtl(int $ttl = 840): void
482+
{
483+
if ($this->provider->sessionCache === null) {
484+
throw new Exception("setSessionCacheTtl() requires a session cache to be configured via the constructor.");
485+
}
486+
$this->provider->sessionCache->setTtl($ttl);
487+
}
428488
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace INTERMediator\FileMakerServer\RESTAPI\SessionCache;
6+
7+
/**
8+
* Base class for session cache implementations.
9+
*
10+
* Provides the cache key and TTL to concrete implementations, both of which
11+
* are managed internally by the library. The cache key and TTL will not change
12+
* during a single PHP request.
13+
*
14+
* As this cache stores FileMaker Data API session tokens, which are sensitive
15+
* credentials granting full API access on behalf of the authenticated user,
16+
* implementors must ensure that the underlying cache storage is secure and
17+
* not accessible to unauthorized parties.
18+
*
19+
* To provide a custom cache backend, extend this class and implement
20+
* {@see SessionCacheInterface::get()}, {@see SessionCacheInterface::set()},
21+
* and {@see SessionCacheInterface::delete()}, using {@see self::$key}
22+
* and {@see self::$ttl} in your implementations.
23+
*
24+
* @see ApcuSessionCache for an example implementation using APCu.
25+
* @see SessionCacheInterface for an alternative way to implement session caching without
26+
* extending this class.
27+
*/
28+
abstract class AbstractSessionCache implements SessionCacheInterface
29+
{
30+
/**
31+
* The cache key for the current session.
32+
*
33+
* Always set by the library via {@see SessionCacheInterface::setKey()} before any cache
34+
* operation is performed. Will not change during a single PHP request.
35+
* Implementing classes should use this property directly in their
36+
* {@see SessionCacheInterface::get()}, {@see SessionCacheInterface::set()},
37+
* and {@see SessionCacheInterface::delete()} implementations.
38+
*/
39+
protected string $key;
40+
41+
/**
42+
* The time-to-live in seconds for cached session tokens.
43+
*
44+
* Set by the library via {@see SessionCacheInterface::setTtl()} before any cache operation
45+
* is performed, defaulting to the value provided at construction time.
46+
* Will not change during a single PHP request. Implementing classes should
47+
* use this property directly in their {@see SessionCacheInterface::set()} implementation.
48+
*
49+
*/
50+
protected int $ttl;
51+
52+
/**
53+
* @param int $defaultTtl Default time-to-live in seconds for cached session tokens.
54+
* Defaults to 840 seconds (14 minutes), reflecting the
55+
* default FileMaker Data API session timeout. Adjust this
56+
* value if your FileMaker Server is configured with a
57+
* different session timeout.
58+
*/
59+
public function __construct(int $defaultTtl = 840)
60+
{
61+
$this->ttl = $defaultTtl;
62+
}
63+
64+
final public function setKey(string $key): void
65+
{
66+
$this->key = $key;
67+
}
68+
69+
final public function setTtl(int $ttl): void
70+
{
71+
$this->ttl = $ttl;
72+
}
73+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace INTERMediator\FileMakerServer\RESTAPI\SessionCache;
6+
7+
use RuntimeException;
8+
9+
/**
10+
* APCu-based session cache implementation.
11+
*
12+
* Caches FileMaker Data API session tokens using APCu, which stores data in
13+
* shared memory on the server. Note that depending on your setup APCu cache
14+
* may be shared across PHP processes on the same server, so cache keys must be
15+
* sufficiently unique to avoid collisions between different users and applications.
16+
*
17+
* Requires that the APCu extension is installed and enabled. See the
18+
* documentation here for more information: https://www.php.net/apcu
19+
*
20+
* As this cache stores sensitive FileMaker Data API session tokens, APCu is
21+
* only appropriate in environments where server memory access is properly
22+
* restricted.
23+
*
24+
* Note that cache operations in this implementation are not atomic. While care
25+
* has been taken to minimize the risk of race conditions, concurrent requests
26+
* sharing the same cache key may occasionally result in redundant
27+
* re-authentication against the FileMaker Server. This is considered an
28+
* acceptable trade-off given the constraints of the current implementation.
29+
*
30+
* @package INTER-Mediator\FileMakerServer\RESTAPI\SessionCache
31+
* @link https://github.com/msyk/FMDataAPI GitHub Repository
32+
* @version 36
33+
*/
34+
class ApcuSessionCache extends AbstractSessionCache
35+
{
36+
/**
37+
* ApcuSessionCache constructor.
38+
* @throws RuntimeException If APCu is not available.
39+
*/
40+
public function __construct()
41+
{
42+
parent::__construct();
43+
if (!function_exists('apcu_enabled') || !apcu_enabled()) {
44+
throw new RuntimeException("APCu is required to use ApcuSessionCache.");
45+
}
46+
}
47+
48+
/**
49+
* Retrieves the cached FileMaker Data API session token for the current session.
50+
*
51+
* @return string|null The cached session token, or null if no token exists
52+
* for the current key.
53+
*/
54+
public function get(): string|null
55+
{
56+
$value = apcu_fetch($this->key, $success);
57+
return $success && is_string($value) ? $value : null;
58+
}
59+
60+
/**
61+
* Persists a FileMaker Data API session token in APCu.
62+
*
63+
* @param string $value The FileMaker Data API session token to store.
64+
* This is a sensitive credential and must be treated as such.
65+
* @return bool True on success, false on failure.
66+
*/
67+
public function set(string $value): bool
68+
{
69+
return apcu_store($this->key, $value, $this->ttl);
70+
}
71+
72+
/**
73+
* Deletes the cached FileMaker Data API session token.
74+
*
75+
* Returns false both when the key does not exist and when deletion fails.
76+
*
77+
* @return bool True on success, false if the key did not exist or deletion failed.
78+
*/
79+
public function delete(): bool
80+
{
81+
return apcu_delete($this->key);
82+
}
83+
}

0 commit comments

Comments
 (0)