Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 129 additions & 5 deletions src/FMDataAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Check failure on line 11 in src/FMDataAPI.php

View workflow job for this annotation

GitHub Actions / Run Phan

PhanUnreferencedUseNormal Possibly zero references to use statement for classlike/namespace FileMakerRelation (\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.
Expand Down Expand Up @@ -38,6 +42,12 @@
*/
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.
Expand All @@ -58,6 +68,8 @@
* Ex. [{"database"=>"<databaseName>", "username"=>"<username>", "password"=>"<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,
Expand All @@ -66,16 +78,25 @@
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.
}
if (!$isUnitTest) {
$this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource);

Check failure on line 88 in src/FMDataAPI.php

View workflow job for this annotation

GitHub Actions / Run PHPStan (5)

Parameter #5 $port of class INTERMediator\FileMakerServer\RESTAPI\Supporting\CommunicationProvider constructor expects string|null, int|null given.
} else {
$this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource);

Check failure on line 90 in src/FMDataAPI.php

View workflow job for this annotation

GitHub Actions / Run Phan

PhanUndeclaredClassMethod Call to method __construct from undeclared class \INTERMediator\FileMakerServer\RESTAPI\Supporting\TestProvider

Check failure on line 90 in src/FMDataAPI.php

View workflow job for this annotation

GitHub Actions / Run Phan

PhanTypeMismatchPropertyReal Assigning new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource) of type \INTERMediator\FileMakerServer\RESTAPI\Supporting\TestProvider to property but \INTERMediator\FileMakerServer\RESTAPI\FMDataAPI->provider is \INTERMediator\FileMakerServer\RESTAPI\Supporting\CommunicationProvider|null (no real type)

Check failure on line 90 in src/FMDataAPI.php

View workflow job for this annotation

GitHub Actions / Run PHPStan (5)

Parameter #5 $port of class INTERMediator\FileMakerServer\RESTAPI\Supporting\TestProvider constructor expects string|null, int|null given.
}

if ($sessionCache !== null) {
$this->persistentSession = new PersistentSession($sessionCache, $solution, $user);
$this->provider->keepPersistentSession = true;

Check failure on line 95 in src/FMDataAPI.php

View workflow job for this annotation

GitHub Actions / Run Phan

PhanUndeclaredClassProperty Reference to instance property keepPersistentSession from undeclared class \INTERMediator\FileMakerServer\RESTAPI\Supporting\TestProvider
} elseif (function_exists('apcu_fetch')) {
$this->persistentSession = new PersistentSession(new ApcuSessionCache(), $solution, $user);
$this->provider->keepPersistentSession = true;

Check failure on line 98 in src/FMDataAPI.php

View workflow job for this annotation

GitHub Actions / Run Phan

PhanUndeclaredClassProperty Reference to instance property keepPersistentSession from undeclared class \INTERMediator\FileMakerServer\RESTAPI\Supporting\TestProvider
}
}

/**
Expand Down Expand Up @@ -189,9 +210,9 @@

/**
* 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;
}
Expand Down Expand Up @@ -293,6 +314,109 @@
$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:
* <code>
* $client = new FMDataAPI('MySolution', 'MyUser', 'MyPassword');
* $result = $client->execute(
* fn (FMDataAPI $fm) => $fm->layout('MyLayout')->query(
* // do stuff
* )
* );
* </code>
*
* @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.
Expand Down
31 changes: 31 additions & 0 deletions src/PersistentSession/ApcuSessionCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace INTERMediator\FileMakerServer\RESTAPI\PersistentSession;

use RuntimeException;

class ApcuSessionCache implements SessionCacheInterface
{
public function __construct()
{
if (!function_exists('apcu_fetch')) {
throw new RuntimeException("APCu is required to use ApcuSessionCache.");
}
}

public function get(string $key): string|false
{
$value = apcu_fetch($key);
return is_string($value) ? $value : false;
}

public function set(string $key, string $value, int $ttl): void
{
apcu_store($key, $value, $ttl);
}

public function delete(string $key): void
{
apcu_delete($key);
}
}
120 changes: 120 additions & 0 deletions src/PersistentSession/PersistentSession.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

namespace INTERMediator\FileMakerServer\RESTAPI\PersistentSession;

use INTERMediator\FileMakerServer\RESTAPI\FMDataAPI;
use RuntimeException;

/**
* Class PersistentSession is the wrapper of persistent sessions.
* The object of this class is going to be generated by the FMDataAPI class,
* and you shouldn't call the constructor of this class.
*
* @package INTER-Mediator\FileMakerServer\RESTAPI\PersistentSession
* @link https://github.com/msyk/FMDataAPI GitHub Repository
* @version 36
*/
class PersistentSession
{
/**
* @var int
* @ignore
*/
private const TOKEN_TTL = 840;

/**
* @var SessionCacheInterface Cache backend for persistent sessions.
* @ignore
*/
private SessionCacheInterface $cache;
/**
* @var string Database name.
* @ignore
*/
private string $database;
/**
* @var string User name.
* @ignore
*/
private string $user;

/**
* @param SessionCacheInterface $cache
* @param string $database Database name.
* @param string $user User name.
*/
public function __construct(
SessionCacheInterface $cache,
string $database,
string $user)
{
$this->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}";
}
}
26 changes: 26 additions & 0 deletions src/PersistentSession/SessionCacheInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace INTERMediator\FileMakerServer\RESTAPI\PersistentSession;
interface SessionCacheInterface
{
/**
* Retrieve a cached token.
* @param string $key
* @return string|false returns the cached token, or false if the key doesn't exist.'
*/
public function get(string $key): string|false;

/**
* Store a token with a TTL in seconds.
* @param string $key
* @param string $value
* @param int $ttl
*/
public function set(string $key, string $value, int $ttl): void;

/**
* Delete a cached token.
* @param string $key
*/
public function delete(string $key): void;
}
10 changes: 9 additions & 1 deletion src/Supporting/CommunicationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@
* @ignore
*/
public bool $keepAuth = false;
/**
* @var bool Use persistent session for communication with FileMaker server. A persistent session is when a
* communication with FileMaker server is kept open for multiple PHP requests, making all PHP requests share the
* same session token.
* @ignore
*/
public bool $keepPersistentSession = false;

/**
* @var bool
Expand Down Expand Up @@ -378,7 +385,7 @@
}
$result['sort'] = $sort;
}
return $result;

Check failure on line 388 in src/Supporting/CommunicationProvider.php

View workflow job for this annotation

GitHub Actions / Run Phan

PhanTypeMismatchReturnNullable Returning $result of type ?array|?array{sort?:array{}|non-empty-list<array{fieldName:mixed,sortOrder:'ascend'|string}>|null,query?:mixed|null|string[][],fieldData:string[]}|?non-empty-array<mixed,mixed> but justifyRequest() is declared to return array (expected returned value to be non-nullable)
}

/**
Expand Down Expand Up @@ -514,6 +521,7 @@
return true;
}
} catch (Exception $e) {
$this->storeToProperties();
$this->accessToken = null;
throw $e;
}
Expand All @@ -528,7 +536,7 @@
*/
public function logout(): void
{
if ($this->keepAuth) {
if ($this->keepAuth || $this->keepPersistentSession) {
return;
}
$params = ["sessions" => $this->accessToken];
Expand Down Expand Up @@ -620,11 +628,11 @@
$request = $this->justifyRequest($request);
}
$ch = $this->_createCurlHandle($url);
curl_setopt($ch, CURLOPT_VERBOSE, 0);

Check failure on line 631 in src/Supporting/CommunicationProvider.php

View workflow job for this annotation

GitHub Actions / Run PHPStan (5)

Parameter #3 $value of function curl_setopt expects bool, int given.
curl_setopt($ch, CURLOPT_HEADER, 1);

Check failure on line 632 in src/Supporting/CommunicationProvider.php

View workflow job for this annotation

GitHub Actions / Run PHPStan (5)

Parameter #3 $value of function curl_setopt expects bool, int given.
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
if ($methodLower == 'post') {
curl_setopt($ch, CURLOPT_POST, 1);

Check failure on line 635 in src/Supporting/CommunicationProvider.php

View workflow job for this annotation

GitHub Actions / Run PHPStan (5)

Parameter #3 $value of function curl_setopt expects bool, int given.
} elseif (in_array($methodLower, ['put', 'patch', 'delete', 'get'], true)) {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($methodLower));
}
Expand Down Expand Up @@ -796,7 +804,7 @@
*/
public function getCurlInfo($key): mixed
{
return $this->curlInfo[$key];

Check failure on line 807 in src/Supporting/CommunicationProvider.php

View workflow job for this annotation

GitHub Actions / Run Phan

PhanTypeArraySuspiciousNullable Suspicious array access to $this->curlInfo of nullable type ?array|array|null
}

/**
Expand Down Expand Up @@ -896,7 +904,7 @@
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_DEFAULT);
if ($this->isCertValidating) {
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);

Check failure on line 907 in src/Supporting/CommunicationProvider.php

View workflow job for this annotation

GitHub Actions / Run PHPStan (5)

Parameter #3 $value of function curl_setopt expects bool, int given.
/* Use the OS native certificate authorities, if possible.
This fixes SSL validation errors if `php.ini` doesn't have [curl] `curl.cainfo`,
set properly of if this PEM file isn't up to date.
Expand All @@ -907,7 +915,7 @@
}
} else {
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);

Check failure on line 918 in src/Supporting/CommunicationProvider.php

View workflow job for this annotation

GitHub Actions / Run PHPStan (5)

Parameter #3 $value of function curl_setopt expects bool, int given.
}
if (!is_null($this->timeout)) {
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
Expand Down
Loading