From 9907e84f4ce3eb9ef6aafe7b98914a971c797b7c Mon Sep 17 00:00:00 2001 From: Julien Date: Mon, 8 Nov 2021 14:40:27 +0100 Subject: [PATCH 01/18] Allows columns to be nulled or emptied on Updates --- src/Connection/FluentFMRepository.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Connection/FluentFMRepository.php b/src/Connection/FluentFMRepository.php index 7b039ed..9b6bc2b 100644 --- a/src/Connection/FluentFMRepository.php +++ b/src/Connection/FluentFMRepository.php @@ -143,10 +143,10 @@ public function update(string $layout, array $fields = [], int $recordId = null) $response = $this->client->patch(Url::records($layout, $id), [ 'Content-Type' => 'application/json', 'headers' => $this->authHeader(), - 'json' => ['fieldData' => array_filter($fields)], + 'json' => ['fieldData' => $fields], ]); - Response::check($response, ['fieldData' => array_filter($fields)]); + Response::check($response, ['fieldData' => $fields]); } return true; From a7f99d1bc6cfad1c46f5d555a0e9bc461a4edaec Mon Sep 17 00:00:00 2001 From: Julien Date: Mon, 8 Nov 2021 14:42:12 +0100 Subject: [PATCH 02/18] Own packaging to quickly fix some issues --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e971c42..c7687d4 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "thyyppa/fluent-fm", + "name": "sib-retail/fluent-fm", "description": "A PHP package for FileMaker Server's Data API using a fluent query builder style interface.", "type": "library", "license": "MIT", From d9ed742407d911f2270cbcc0f2561808d5191bbb Mon Sep 17 00:00:00 2001 From: Julien Date: Mon, 8 Nov 2021 14:51:52 +0100 Subject: [PATCH 03/18] Own namespace --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c7687d4..b4eca83 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ }, "autoload": { "psr-4": { - "Hyyppa\\FluentFM\\": "src/" + "SIB\\FluentFM\\": "src/" } }, "autoload-dev": { From 2a9c7959d5fbfbf21eceb8d871ed785778dd84b4 Mon Sep 17 00:00:00 2001 From: Julien Date: Mon, 8 Nov 2021 15:16:54 +0100 Subject: [PATCH 04/18] Reverting the namespace to avoid changing all files --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b4eca83..c7687d4 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ }, "autoload": { "psr-4": { - "SIB\\FluentFM\\": "src/" + "Hyyppa\\FluentFM\\": "src/" } }, "autoload-dev": { From a32022749b9f1715e83c492f7bba7ddd5bf1f8d5 Mon Sep 17 00:00:00 2001 From: AnnoyingTechnology Date: Mon, 8 Nov 2021 16:10:43 +0100 Subject: [PATCH 05/18] Add support for layout's metadata retrieval --- src/Connection/FluentFMRepository.php | 20 ++++++++++++++++++++ src/Connection/Response.php | 18 ++++++++++++++++++ src/Connection/Url.php | 11 +++++++++++ src/Contract/FluentFM.php | 9 +++++++++ 4 files changed, 58 insertions(+) diff --git a/src/Connection/FluentFMRepository.php b/src/Connection/FluentFMRepository.php index 9b6bc2b..16411c8 100644 --- a/src/Connection/FluentFMRepository.php +++ b/src/Connection/FluentFMRepository.php @@ -53,6 +53,26 @@ public function records($layout, $id = null): FluentFM return $this; } + /** + * {@inheritdoc} + */ + public function metadata($layout): FluentFM + { + $this->callback = function () use ($layout) { + $response = $this->client->get(Url::metadata($layout), [ + 'Content-Type' => 'application/json', + 'headers' => $this->authHeader(), + 'query' => $this->queryString(), + ]); + + Response::check($response, $this->queryString()); + + return Response::metadata($response); + }; + + return $this; + } + /** * {@inheritdoc} */ diff --git a/src/Connection/Response.php b/src/Connection/Response.php index ead7a76..502ad98 100644 --- a/src/Connection/Response.php +++ b/src/Connection/Response.php @@ -44,6 +44,24 @@ public static function records(ResponseInterface $response, bool $with_portals = return $records; } + /** + * Get response returned metadata. + * + * @param ResponseInterface $response + * + * @return array + */ + public static function metadata(ResponseInterface $response): array + { + $metas = []; + + foreach (static::body($response)->response->fieldMetaData as $metadata) { + $metas[$metadata->name] = (array) $metadata; + } + + return $metas; + } + /** * Get response returned message. * diff --git a/src/Connection/Url.php b/src/Connection/Url.php index 183d066..e355582 100644 --- a/src/Connection/Url.php +++ b/src/Connection/Url.php @@ -20,6 +20,17 @@ public static function records(string $layout, int $id = null): string return 'layouts/'.$layout.'/records'.$record; } + /** + * @param string $layout + * + * @return string + */ + public static function metadata(string $layout): string + { + + return 'layouts/'.$layout; + } + /** * @param string $layout * diff --git a/src/Contract/FluentFM.php b/src/Contract/FluentFM.php index 9ab9340..076add3 100644 --- a/src/Contract/FluentFM.php +++ b/src/Contract/FluentFM.php @@ -29,6 +29,15 @@ public function record($layout, $id): self; */ public function records($layout, $id = null): self; + /** + * Get metadata from filemaker table. + * + * @param $layout + * + * @return FluentFM + */ + public function metadata($layout): self; + /** * Find records matching current query parameters. * From 891d1a53bd1fd6b160ddbbd6fff9d1cd5523b832 Mon Sep 17 00:00:00 2001 From: AnnoyingTechnology Date: Mon, 8 Nov 2021 16:15:12 +0100 Subject: [PATCH 06/18] Document the metadata retrieval feature --- readme.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/readme.md b/readme.md index 84ec6e0..f563c8d 100644 --- a/readme.md +++ b/readme.md @@ -178,6 +178,14 @@ $fm->find('customers') --- +#### Retrieving Metadata + +To retrieve a Layout's metadata (columns types and properties) +```php +$fm->metadata('customers') + ->exec(); +``` + #### Chainable commands ```php From 399e093db4b8c92426bd3edfc528f763cf8fa31d Mon Sep 17 00:00:00 2001 From: AnnoyingTechnology Date: Tue, 16 Nov 2021 14:36:45 +0100 Subject: [PATCH 07/18] Fixes an issue where delete matching no objects would throw exceptions --- src/Connection/FluentFMRepository.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Connection/FluentFMRepository.php b/src/Connection/FluentFMRepository.php index 16411c8..41fc237 100644 --- a/src/Connection/FluentFMRepository.php +++ b/src/Connection/FluentFMRepository.php @@ -264,6 +264,12 @@ public function delete(string $layout, int $recordId = null): FluentFM $this->callback = function () use ($layout, $recordId) { $recordIds = $recordId ? [$recordId] : array_keys($this->find($layout)->get()); + // if we haven't found anything to delete + if(!$recordIds) { + // don't attempt to delete anything, since that would fail + return true; + } + foreach ($recordIds as $id) { $response = $this->client->delete(Url::records($layout, $id), [ 'headers' => $this->authHeader(), From f8858ec487639e90fd450db242f6320d586866a3 Mon Sep 17 00:00:00 2001 From: Julien Date: Tue, 29 Nov 2022 10:47:38 +0100 Subject: [PATCH 08/18] Allows use of PHP 8.0 and 8.1 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c7687d4..9135fbd 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ } ], "require": { - "php": "^7.2", + "php": "^7.2 || ^8.0 || ^8.1", "guzzlehttp/guzzle": "^7.0.1", "ext-json": "*", "ramsey/uuid": "^3.8 || ^4.0" From a59542e592e945648e260d02af5da7ea6a7aa521 Mon Sep 17 00:00:00 2001 From: Julien Date: Tue, 14 Feb 2023 16:06:54 +0100 Subject: [PATCH 09/18] Support authentication token caching and reuse --- src/Connection/BaseConnection.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Connection/BaseConnection.php b/src/Connection/BaseConnection.php index aad7f0e..f8c8a7f 100644 --- a/src/Connection/BaseConnection.php +++ b/src/Connection/BaseConnection.php @@ -103,6 +103,18 @@ protected function authHeader(): array */ protected function getToken(): string { + // if we have a cached token available + if( + \Polyfony\Cache::has( + \Polyfony\Config::get('filemaker','token_cache_name') ?? 'FilemakerDataAPIToken' + ) + ) { + // use this token instead of hitting the API + return $this->token = \Polyfony\Cache::get( + \Polyfony\Config::get('filemaker','token_cache_name') ?? 'FilemakerDataAPIToken' + ); + } + try { $header = $this->client->post('sessions', [ 'headers' => [ @@ -115,6 +127,14 @@ protected function getToken(): string throw new FilemakerException('Filemaker did not return an auth token. Is the server online?', 404); } + // cache the token (it has an actual lifetime of 15 minutes, we cache it for only 14) + \Polyfony\Cache::put( + \Polyfony\Config::get('filemaker','token_cache_name') ?? 'FilemakerDataAPIToken', + $header[0], + true, + \Polyfony\Config::get('filemaker','token_cache_duration') ?? 60 + ); + return $this->token = $header[0]; } catch (ClientException $e) { throw new FilemakerException('Filemaker access unauthorized - please check your credentials', 401, $e); From 06ae436aa8d119f3533c096fe1daf93523d44be3 Mon Sep 17 00:00:00 2001 From: Julien Date: Tue, 14 Feb 2023 16:07:36 +0100 Subject: [PATCH 10/18] Don't automatically close the session and destroy the token --- src/Connection/FluentFMRepository.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Connection/FluentFMRepository.php b/src/Connection/FluentFMRepository.php index 41fc237..4ec7bd1 100644 --- a/src/Connection/FluentFMRepository.php +++ b/src/Connection/FluentFMRepository.php @@ -483,7 +483,6 @@ public function reset(): self public function __destruct() { try { - $this->logout(); unset($this->client); } catch (\Exception $e) { } From a63316a4364552556b21e9f68539e8bfe826e823 Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 23 Feb 2023 10:53:04 +0100 Subject: [PATCH 11/18] Associate cached tokens to the host+database couple --- src/Connection/BaseConnection.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Connection/BaseConnection.php b/src/Connection/BaseConnection.php index f8c8a7f..561bd6e 100644 --- a/src/Connection/BaseConnection.php +++ b/src/Connection/BaseConnection.php @@ -105,14 +105,10 @@ protected function getToken(): string { // if we have a cached token available if( - \Polyfony\Cache::has( - \Polyfony\Config::get('filemaker','token_cache_name') ?? 'FilemakerDataAPIToken' - ) + \Polyfony\Cache::has($this->getTokenCacheName()) ) { // use this token instead of hitting the API - return $this->token = \Polyfony\Cache::get( - \Polyfony\Config::get('filemaker','token_cache_name') ?? 'FilemakerDataAPIToken' - ); + return $this->token = \Polyfony\Cache::get($this->getTokenCacheName()); } try { @@ -129,7 +125,7 @@ protected function getToken(): string // cache the token (it has an actual lifetime of 15 minutes, we cache it for only 14) \Polyfony\Cache::put( - \Polyfony\Config::get('filemaker','token_cache_name') ?? 'FilemakerDataAPIToken', + $this->getTokenCacheName(), $header[0], true, \Polyfony\Config::get('filemaker','token_cache_duration') ?? 60 @@ -150,4 +146,18 @@ protected function getToken(): string throw $e; } } + + /** + * Returns a token cache name, unique to this the current database and host + * + * @return string + */ + protected function getTokenCacheName() :string { + + return 'filemaker-data-api-token-' . sha1( + $this->config('host') . + $this->config('file') + ); + + } } From 1806f0623b5472d257bd57aa0bcfa72c688cc1a3 Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 23 Feb 2023 10:54:13 +0100 Subject: [PATCH 12/18] Indentation fix --- src/Connection/BaseConnection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Connection/BaseConnection.php b/src/Connection/BaseConnection.php index 561bd6e..a0f4ae4 100644 --- a/src/Connection/BaseConnection.php +++ b/src/Connection/BaseConnection.php @@ -156,7 +156,7 @@ protected function getTokenCacheName() :string { return 'filemaker-data-api-token-' . sha1( $this->config('host') . - $this->config('file') + $this->config('file') ); } From fb2a4dfbe26c436754387d881aae1f0046d6a29e Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 23 Feb 2023 15:30:32 +0100 Subject: [PATCH 13/18] Optional Token caching mechanism using APCu --- src/Connection/BaseConnection.php | 120 ++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 16 deletions(-) diff --git a/src/Connection/BaseConnection.php b/src/Connection/BaseConnection.php index a0f4ae4..f61aa51 100644 --- a/src/Connection/BaseConnection.php +++ b/src/Connection/BaseConnection.php @@ -12,6 +12,7 @@ */ abstract class BaseConnection { + /** * @var Client */ @@ -25,7 +26,7 @@ abstract class BaseConnection /** * @var array */ - protected $config; + protected $config = ['token_ttl'=>870]; /** * @var string @@ -47,7 +48,9 @@ abstract class BaseConnection */ public function __construct(array $config, Client $client = null) { - $this->config = $config; + // we merge with the default config containing the newly introduced token cache ttl + // as to keep this new feature backward compatible + $this->config = array_merge($this->config, $config); $options = [ 'base_uri' => sprintf( @@ -95,23 +98,28 @@ protected function authHeader(): array } /** - * Request api access token from server. + * Request api access token from server or from the cache if available. * * @return string * @throws FilemakerException * @throws ErrorException */ - protected function getToken(): string + protected function getToken(?bool $require_new_authentication = false): string { - // if we have a cached token available + // if we have a cached token available and we are not required to do a fresh authentication if( - \Polyfony\Cache::has($this->getTokenCacheName()) + $this->isCacheAvailable() && + $this->hasCachedToken() && + !$require_new_authentication ) { - // use this token instead of hitting the API - return $this->token = \Polyfony\Cache::get($this->getTokenCacheName()); + // prolong the life of this cached token (as the Data API extends the lifetime of tokens each time they are used) + $this->extendCachedTokenTtl(); + // and retrieve it from the cache instead of hitting the API's authentication endpoint + return $this->token = $this->getCachedToken(); } try { + $header = $this->client->post('sessions', [ 'headers' => [ 'Content-Type' => 'application/json', @@ -123,13 +131,12 @@ protected function getToken(): string throw new FilemakerException('Filemaker did not return an auth token. Is the server online?', 404); } - // cache the token (it has an actual lifetime of 15 minutes, we cache it for only 14) - \Polyfony\Cache::put( - $this->getTokenCacheName(), - $header[0], - true, - \Polyfony\Config::get('filemaker','token_cache_duration') ?? 60 - ); + // if we have a caching system available + if($this->isCacheAvailable()) { + // cache the token (it has an actual lifetime of 15 minutes, + // we cache it for only almost 15 minutes (-30 sec) by default) + $this->cacheToken($header[0]); + } return $this->token = $header[0]; } catch (ClientException $e) { @@ -147,8 +154,20 @@ protected function getToken(): string } } + /** + * Checks if we are able to use the APCu cache + * + * @return bool + */ + protected function isCacheAvailable() :bool { + + return extension_loaded('apcu') && apcu_enabled(); + + } + /** * Returns a token cache name, unique to this the current database and host + * It also includes a hash of the user and pass, in case multiple apps run on the same APCu * * @return string */ @@ -156,8 +175,77 @@ protected function getTokenCacheName() :string { return 'filemaker-data-api-token-' . sha1( $this->config('host') . - $this->config('file') + $this->config('file') . + $this->config('user') . + $this->config('pass') + ); + + } + + /** + * Check if we have a non expired token for the current database and host + * + * @return bool + */ + protected function hasCachedToken() :bool { + + return apcu_exists($this->getTokenCacheName()); + + } + + /** + * Returns a cached token, supposedly valid for the current database and host + * + * @return string + */ + protected function getCachedToken() :string { + + return apcu_fetch($this->getTokenCacheName()); + + } + + /** + * Removes a cached token, useful when the Data API responds + * that it is invalid (ie. after a reboot of the filemaker instance) + * + * @return bool + */ + protected function removeCachedToken() :bool { + + return apcu_delete($this->getTokenCacheName()); + + } + + /** + * Stores a token for future use without the authentication step (avoiding an HTTP roundtrip) + * + * @return bool + */ + protected function cacheToken(string $token) :bool { + + return apcu_store( + $this->getTokenCacheName(), + $token, + $this->config('token_ttl') + ); + + } + + /** + * Extends the Time To Live of a cached token, to match Filemaker's + * own extension of the token validity when we use it + * + * @return void + */ + protected function extendCachedTokenTtl() :void { + + // override the current token with the same one, but with a new ttl + apcu_store( + $this->getTokenCacheName(), + $this->getCachedToken(), + $this->config('token_ttl') ); } + } From ac848b20f380e8cb39b4797645b899bfcf51fe77 Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 23 Feb 2023 15:40:27 +0100 Subject: [PATCH 14/18] Documentation of the Token Caching mechanism --- readme.md | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index f563c8d..fb7546e 100644 --- a/readme.md +++ b/readme.md @@ -71,10 +71,10 @@ Otherwise replace `localhost` with the server address. use Hyyppa\FluentFM\Connection\FluentFMRepository; $fm = new FluentFMRepository([ - 'file' => 'FilemakerFilename', - 'host' => '127.0.0.1', - 'user' => 'Admin', - 'pass' => 'secret', + 'file' => 'FilemakerFilename', + 'host' => '127.0.0.1', + 'user' => 'Admin', + 'pass' => 'secret' ]); // get a single record as array @@ -259,6 +259,34 @@ $fm->metadata('customers') ->reset() ``` +#### Persistent Authentication + +If your PHP installation has the APCu module installed, this library is able to cache authentication tokens between requests. + +Tokens are : +* associated to a filemaker host, database, user and password. +* valid for 15 minutes from time of creation or last use to query the Data API. + +Everytime a token is used to make a new Data API request, its TTL (time to live) in the cache is prolonged by 14.5 minutes. + +This mirrors Filemaker's mechanisms which extends the validity of tokens everytime they are used (with a maximum idle lifetime of 15 minutes). + +If a token is invalidated while still stored in cache, the Exception is caught by the library and a single other attempt to authenticate and obtain a new token is done transparently by the library. + +To define a custom TTL (and TTL extension duration), specify the `token_ttl` when instanciating a `FluentFMRepository` object. + +```php +$fm = new FluentFMRepository([ + 'file' => 'FilemakerFilename', + 'host' => '127.0.0.1', + 'user' => 'Admin', + 'pass' => 'secret', + 'token_ttl' => 60, // shorter token cache TTL specified here +]); +``` + +**This feature should not be used on shared hostings or servers on which you cannot ensure that your APCu is strictly yours** + ### Troubleshooting #### Error: SSL certificate problem: unable to get local issuer certificate From d658d958ac61507b4535e5cd56764dd3a3031bc1 Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 23 Feb 2023 15:48:17 +0100 Subject: [PATCH 15/18] Suggest the use of APCu extension to enable token caching --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index 9135fbd..6bca6f0 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,9 @@ "phpunit/phpunit": "8.5.5", "symfony/var-dumper": "^4.1" }, + "suggest": { + "ext-apcu": "*" + }, "autoload": { "psr-4": { "Hyyppa\\FluentFM\\": "src/" From 73df39b712be5052f687c16356163331f16a11e9 Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 23 Feb 2023 15:59:34 +0100 Subject: [PATCH 16/18] Disable automatic token destruction if caching is available Otherwise Tokens get destroy from Filemaker's auth database when the fluent-fm object is destroyed. --- src/Connection/FluentFMRepository.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Connection/FluentFMRepository.php b/src/Connection/FluentFMRepository.php index 4ec7bd1..32a4476 100644 --- a/src/Connection/FluentFMRepository.php +++ b/src/Connection/FluentFMRepository.php @@ -483,6 +483,10 @@ public function reset(): self public function __destruct() { try { + // only destroy the token on Filemaker if we don't have a caching mechanism available + if(!$this->isCacheAvailable()) { + $this->logout(); + } unset($this->client); } catch (\Exception $e) { } From 23cfec1ef7b3284f52076ca9b715496a88e18e78 Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 23 Feb 2023 16:22:21 +0100 Subject: [PATCH 17/18] Bypass the token caching in case of 401 Status Code --- src/Connection/FluentFMRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Connection/FluentFMRepository.php b/src/Connection/FluentFMRepository.php index 32a4476..21a1229 100644 --- a/src/Connection/FluentFMRepository.php +++ b/src/Connection/FluentFMRepository.php @@ -371,7 +371,7 @@ public function get() $results = ($this->callback)(); } catch (\Exception $e) { if ($e->getCode() === 401 || $e->getCode() === 952) { - $this->getToken(); + $this->getToken(true); $results = ($this->callback)(); } elseif ($e instanceof RequestException && $response = $e->getResponse()) { Response::check($response, $this->queryString()); From dcf4f8dcc38be61e8214fbae226e37fec7fb6b58 Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 23 Feb 2023 16:27:51 +0100 Subject: [PATCH 18/18] Restore the original package org and name for PR --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6bca6f0..36e9bb9 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "sib-retail/fluent-fm", + "name": "thyyppa/fluent-fm", "description": "A PHP package for FileMaker Server's Data API using a fluent query builder style interface.", "type": "library", "license": "MIT",