From daddee7806045e06d21c4313e7273f40a2f8f6c6 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Wed, 6 Mar 2024 17:50:16 -0500 Subject: [PATCH] feat: support action expression parsing, validation, and evaluation --- .../Models/Rule/ExpressionController.php | 89 +++++ .../Models/Rule/ValidateExpressionRequest.php | 46 +++ .../Expressions/ActionExpressionEvaluator.php | 95 ++++++ .../ActionExpressionLanguageProvider.php | 37 +++ .../Factory/ActionFactory.php | 8 +- .../Factory/ExpressionLanguageFactory.php | 45 +++ composer.json | 1 + composer.lock | 312 +++++++++++++++++- routes/api.php | 2 + 9 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 app/Api/V1/Controllers/Models/Rule/ExpressionController.php create mode 100644 app/Api/V1/Requests/Models/Rule/ValidateExpressionRequest.php create mode 100644 app/TransactionRules/Expressions/ActionExpressionEvaluator.php create mode 100644 app/TransactionRules/Expressions/ActionExpressionLanguageProvider.php create mode 100644 app/TransactionRules/Factory/ExpressionLanguageFactory.php diff --git a/app/Api/V1/Controllers/Models/Rule/ExpressionController.php b/app/Api/V1/Controllers/Models/Rule/ExpressionController.php new file mode 100644 index 0000000000..dae57c3261 --- /dev/null +++ b/app/Api/V1/Controllers/Models/Rule/ExpressionController.php @@ -0,0 +1,89 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V1\Controllers\Models\Rule; + +use FireflyIII\Api\V1\Controllers\Controller; +use FireflyIII\Api\V1\Requests\Models\Rule\ValidateExpressionRequest; +use FireflyIII\Repositories\Rule\RuleRepositoryInterface; +use FireflyIII\TransactionRules\Expressions\ActionExpressionEvaluator; +use FireflyIII\TransactionRules\Factory\ExpressionLanguageFactory; +use FireflyIII\User; +use Illuminate\Http\JsonResponse; +use Symfony\Component\ExpressionLanguage\SyntaxError; + +/** + * Class ExpressionController + */ +class ExpressionController extends Controller +{ + private RuleRepositoryInterface $ruleRepository; + + /** + * RuleController constructor. + * + + */ + public function __construct() + { + parent::__construct(); + $this->middleware( + function ($request, $next) { + /** @var User $user */ + $user = auth()->user(); + + $this->ruleRepository = app(RuleRepositoryInterface::class); + $this->ruleRepository->setUser($user); + + return $next($request); + } + ); + } + + /** + * This endpoint is documented at: + * https://api-docs.firefly-iii.org/?urls.primaryName=2.0.0%20(v1)#/rules/validateExpression + * + * @param ValidateExpressionRequest $request + * + * @return JsonResponse + */ + public function validateExpression(ValidateExpressionRequest $request): JsonResponse + { + $expr = $request->getExpression(); + $expressionLanguage = ExpressionLanguageFactory::get(); + $evaluator = new ActionExpressionEvaluator($expressionLanguage, $expr); + + try { + $evaluator->lint(); + return response()->json([ + "valid" => true, + ]); + } catch (SyntaxError $e) { + return response()->json([ + "valid" => false, + "error" => $e->getMessage() + ]); + } + } +} diff --git a/app/Api/V1/Requests/Models/Rule/ValidateExpressionRequest.php b/app/Api/V1/Requests/Models/Rule/ValidateExpressionRequest.php new file mode 100644 index 0000000000..b12c81d2b1 --- /dev/null +++ b/app/Api/V1/Requests/Models/Rule/ValidateExpressionRequest.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V1\Requests\Models\Rule; + +use FireflyIII\Support\Request\ChecksLogin; +use FireflyIII\Support\Request\ConvertsDataTypes; +use Illuminate\Foundation\Http\FormRequest; + +/** + * Class TestRequest + */ +class ValidateExpressionRequest extends FormRequest +{ + use ConvertsDataTypes; + use ChecksLogin; + + /** + * @return string + */ + public function getExpression(): string + { + return $this->convertString("expression"); + } +} diff --git a/app/TransactionRules/Expressions/ActionExpressionEvaluator.php b/app/TransactionRules/Expressions/ActionExpressionEvaluator.php new file mode 100644 index 0000000000..1f2fe1b6cc --- /dev/null +++ b/app/TransactionRules/Expressions/ActionExpressionEvaluator.php @@ -0,0 +1,95 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\TransactionRules\Expressions; + +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\SyntaxError; + +class ActionExpressionEvaluator +{ + private static array $NAMES = array("transaction"); + + private string $expr; + private bool $isExpression; + private ExpressionLanguage $expressionLanguage; + + public function __construct(ExpressionLanguage $expressionLanguage, string $expr) + { + $this->expressionLanguage = $expressionLanguage; + $this->expr = $expr; + + $this->isExpression = self::isExpression($expr); + } + + private static function isExpression(string $expr): bool + { + return str_starts_with($expr, "="); + } + + public function isValid(): bool + { + if (!$this->isExpression) { + return true; + } + + try { + $this->lint(array()); + return true; + } catch (SyntaxError $e) { + return false; + } + } + + private function lintExpression(string $expr): void + { + $this->expressionLanguage->lint($expr, self::$NAMES); + } + + public function lint(): void + { + if (!$this->isExpression) { + return; + } + + $this->lintExpression(substr($this->expr, 1)); + } + + private function evaluateExpression(string $expr, array $journal): string + { + $result = $this->expressionLanguage->evaluate($expr, [ + "transaction" => $journal + ]); + return strval($result); + } + + public function evaluate(array $journal): string + { + if (!$this->isExpression) { + return $this->expr; + } + + return $this->evaluateExpression(substr($this->expr, 1), $journal); + } +} diff --git a/app/TransactionRules/Expressions/ActionExpressionLanguageProvider.php b/app/TransactionRules/Expressions/ActionExpressionLanguageProvider.php new file mode 100644 index 0000000000..fbb3df1725 --- /dev/null +++ b/app/TransactionRules/Expressions/ActionExpressionLanguageProvider.php @@ -0,0 +1,37 @@ +. + */ + +namespace FireflyIII\TransactionRules\Expressions; + +use Symfony\Component\ExpressionLanguage\ExpressionFunction; +use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; + +class ActionExpressionLanguageProvider implements ExpressionFunctionProviderInterface +{ + public function getFunctions(): array + { + return [ + ExpressionFunction::fromPhp("substr"), + ExpressionFunction::fromPhp("strlen") + ]; + } +} diff --git a/app/TransactionRules/Factory/ActionFactory.php b/app/TransactionRules/Factory/ActionFactory.php index 0f7275e32e..c53d8c1a67 100644 --- a/app/TransactionRules/Factory/ActionFactory.php +++ b/app/TransactionRules/Factory/ActionFactory.php @@ -19,6 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ + declare(strict_types=1); namespace FireflyIII\TransactionRules\Factory; @@ -27,6 +28,8 @@ use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\RuleAction; use FireflyIII\Support\Domain; use FireflyIII\TransactionRules\Actions\ActionInterface; +use FireflyIII\TransactionRules\Expressions\ActionExpressionEvaluator; +use FireflyIII\TransactionRules\Factory\ExpressionLanguageFactory; use Illuminate\Support\Facades\Log; /** @@ -56,7 +59,10 @@ class ActionFactory $class = self::getActionClass($action->action_type); Log::debug(sprintf('self::getActionClass("%s") = "%s"', $action->action_type, $class)); - return new $class($action); + $expressionLanguage = ExpressionLanguageFactory::get(); + $expressionEvaluator = new ActionExpressionEvaluator($expressionLanguage, $action->action_value); + + return new $class($action, $expressionEvaluator); } /** diff --git a/app/TransactionRules/Factory/ExpressionLanguageFactory.php b/app/TransactionRules/Factory/ExpressionLanguageFactory.php new file mode 100644 index 0000000000..3225da66c3 --- /dev/null +++ b/app/TransactionRules/Factory/ExpressionLanguageFactory.php @@ -0,0 +1,45 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\TransactionRules\Factory; + +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use FireflyIII\TransactionRules\Expressions\ActionExpressionLanguageProvider; + +class ExpressionLanguageFactory +{ + protected static ExpressionLanguage $expressionLanguage; + + private static function constructExpressionLanguage(): ExpressionLanguage + { + $expressionLanguage = new ExpressionLanguage(); + $expressionLanguage->registerProvider(new ActionExpressionLanguageProvider()); + return $expressionLanguage; + } + + public static function get(): ExpressionLanguage + { + return self::$expressionLanguage ??= self::constructExpressionLanguage(); + } +} diff --git a/composer.json b/composer.json index f0143e13a5..d83366f6dc 100644 --- a/composer.json +++ b/composer.json @@ -105,6 +105,7 @@ "spatie/laravel-html": "^3.2", "spatie/laravel-ignition": "^2", "spatie/period": "^2.4", + "symfony/expression-language": "^6.3", "symfony/http-client": "^6.3", "symfony/mailgun-mailer": "^6.3", "therobfonz/laravel-mandrill-driver": "^5.0" diff --git a/composer.lock b/composer.lock index 214c235c1e..ae7655de64 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "639b971ea13ea3e6ed2f57f862a195b8", + "content-hash": "2dd09680aeb9e09c15bc6f6f19666952", "packages": [ { "name": "bacon/bacon-qr-code", @@ -5946,6 +5946,178 @@ }, "time": "2023-02-20T14:31:09+00:00" }, + { + "name": "symfony/cache", + "version": "v6.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "84aff8d948d6292d2b5a01ac622760be44dddc72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/84aff8d948d6292d2b5a01ac622760be44dddc72", + "reference": "84aff8d948d6292d2b5a01ac622760be44dddc72", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.3.6" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/dependency-injection": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/var-dumper": "<5.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/messenger": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v6.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-10-17T14:44:58+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "ad945640ccc0ae6e208bcea7d7de4b39b569896b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/ad945640ccc0ae6e208bcea7d7de4b39b569896b", + "reference": "ad945640ccc0ae6e208bcea7d7de4b39b569896b", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-23T14:45:45+00:00" + }, { "name": "symfony/console", "version": "v6.3.4", @@ -6398,6 +6570,70 @@ ], "time": "2023-05-23T14:45:45+00:00" }, + { + "name": "symfony/expression-language", + "version": "v6.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "6d560c4c80e7e328708efd923f93ad67e6a0c1c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/6d560c4c80e7e328708efd923f93ad67e6a0c1c0", + "reference": "6d560c4c80e7e328708efd923f93ad67e6a0c1c0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/cache": "^5.4|^6.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an engine that can compile and evaluate expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/expression-language/tree/v6.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-04-28T16:05:33+00:00" + }, { "name": "symfony/finder", "version": "v6.3.5", @@ -8525,6 +8761,80 @@ ], "time": "2023-09-12T10:11:35+00:00" }, + { + "name": "symfony/var-exporter", + "version": "v6.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "374d289c13cb989027274c86206ddc63b16a2441" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/374d289c13cb989027274c86206ddc63b16a2441", + "reference": "374d289c13cb989027274c86206ddc63b16a2441", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/var-dumper": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v6.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-10-13T09:16:49+00:00" + }, { "name": "therobfonz/laravel-mandrill-driver", "version": "5.0.0", diff --git a/routes/api.php b/routes/api.php index f69558e311..79211ea915 100644 --- a/routes/api.php +++ b/routes/api.php @@ -620,6 +620,8 @@ Route::group( Route::put('{rule}', ['uses' => 'UpdateController@update', 'as' => 'update']); Route::delete('{rule}', ['uses' => 'DestroyController@destroy', 'as' => 'delete']); + Route::post('validateExpression', ['uses' => 'ExpressionController@validateExpression', 'as' => 'validate']); + Route::get('{rule}/test', ['uses' => 'TriggerController@testRule', 'as' => 'test']); // TODO give results back Route::post('{rule}/trigger', ['uses' => 'TriggerController@triggerRule', 'as' => 'trigger']);