From efc908b31bdd2f769449f8434e15e5fed43ab08f Mon Sep 17 00:00:00 2001 From: Victor Westerlund Date: Fri, 17 Nov 2023 12:21:13 +0100 Subject: [PATCH] initial commit --- .gitignore | 48 ++++++++++++++++++ README.md | 0 composer.json | 21 ++++++++ composer.lock | 59 ++++++++++++++++++++++ src/Rules.php | 123 +++++++++++++++++++++++++++++++++++++++++++++ src/Ruleset.php | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 380 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 src/Rules.php create mode 100644 src/Ruleset.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bceb854 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Bootstrapping # +################# +/node_modules +/public/hot +/public/storage +/storage/*.key +/vendor +.env +.env.backup +.phpunit.result.cache +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log + +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Icon? +ehthumbs.db +Thumbs.db +.directory + +# Tool specific files # +####################### +# vim +*~ +*.swp +*.swo +# sublime text & textmate +*.sublime-* +*.stTheme.cache +*.tmlanguage.cache +*.tmPreferences.cache +# Eclipse +.settings/* +# JetBrains, aka PHPStorm, IntelliJ IDEA +.idea/* +# NetBeans +nbproject/* +# Visual Studio Code +.vscode +# Sass preprocessor +.sass-cache/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4e10ea0 --- /dev/null +++ b/composer.json @@ -0,0 +1,21 @@ +{ + "name": "reflect/plugin-rules", + "description": "Add request search paramter and request body constraints to an API built with Reflect", + "type": "library", + "license": "GPL-3.0-only", + "authors": [ + { + "name": "Victor Westerlund", + "email": "victor.vesterlund@gmail.com" + } + ], + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "ReflectRules\\": "src/" + } + }, + "require": { + "victorwesterlund/xenum": "dev-master" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..4bb9daa --- /dev/null +++ b/composer.lock @@ -0,0 +1,59 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "035091a14ba6701d664e69d17b3f630b", + "packages": [ + { + "name": "victorwesterlund/xenum", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/VictorWesterlund/php-xenum.git", + "reference": "99b784841ee5b69fdfcc4c466ef54f3af4ea4a85" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/VictorWesterlund/php-xenum/zipball/99b784841ee5b69fdfcc4c466ef54f3af4ea4a85", + "reference": "99b784841ee5b69fdfcc4c466ef54f3af4ea4a85", + "shasum": "" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "victorwesterlund\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-only" + ], + "authors": [ + { + "name": "Victor Westerlund", + "email": "victor.vesterlund@gmail.com" + } + ], + "description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums", + "support": { + "issues": "https://github.com/VictorWesterlund/php-xenum/issues", + "source": "https://github.com/VictorWesterlund/php-xenum/tree/1.1.0" + }, + "time": "2023-10-09T11:32:07+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": { + "victorwesterlund/xenum": 20 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.0.0" +} diff --git a/src/Rules.php b/src/Rules.php new file mode 100644 index 0000000..6e9d180 --- /dev/null +++ b/src/Rules.php @@ -0,0 +1,123 @@ +property = $property; + } + + public function get_property_name(): string { + return $this->property; + } + + /* + # Constraints + Chain these methods to create rules for a particular property. + When all rules are defiend, the eval_* methods will be called + */ + + // A sequential array of additional Rule instances for a + private function object_rules(array $rules): self { + $this->object_rules = $rules; + return $this; + } + + // Set the minimum lenth/size for property + public function min(?int $value = null) { + $this->min = $value; + return $this; + } + + // Set the maximum length/size for property + public function max(?int $value = null) { + $this->max = $value; + return $this; + } + + // This property has to exist in scope + public function required(bool $flag = true): self { + $this->required = $flag; + return $this; + } + + // Set property Type + public function type(Type|string $type): self { + // Coerce string to Type enum + if (!($type instanceof Type)) { + $type = Type::fromName($string); + } + + $this->type = $type; + return $this; + } + + /* + # Eval methods + These methods are used to check conformity against set rules. + Methods are not called until all rules have been defined. + */ + + public function eval_required(array $scope): bool { + return array_key_exists($this->property, $scope); + } + + public function eval_type(mixed $value): bool { + return match($this->type) { + Type::NUMBER => is_numeric($value), + Type::STRING => is_string($value), + Type::BOOLEAN => is_bool($value), + Type::ARRAY => is_array($value), + Type::OBJECT => $this->eval_object($value), + Type::NULL => is_null($value), + default => true + }; + } + + public function eval_min(mixed $value): bool { + return match($this->type) { + Type::NUMBER => $this->eval_type($value) && $value >= $this->min, + Type::STRING => $this->eval_type($value) && strlen($value) >= $this->min, + Type::ARRAY, + Type::OBJECT => $this->eval_type($value) && count($value) >= $this->min, + default => true + }; + } + + public function eval_max(mixed $value): bool { + return match($this->type) { + Type::NUMBER => $this->eval_type($value) && $value <= $this->max, + Type::STRING => $this->eval_type($value) && strlen($value) <= $this->max, + Type::ARRAY, + Type::OBJECT => $this->eval_type($value) && count($value) <= $this->max, + default => true + }; + } + + // TODO: Recursive Rules eval of multidimensional object + public function eval_object(mixed $object): bool { + return is_array($object); + } + } \ No newline at end of file diff --git a/src/Ruleset.php b/src/Ruleset.php new file mode 100644 index 0000000..897f454 --- /dev/null +++ b/src/Ruleset.php @@ -0,0 +1,129 @@ +exit_on_errors = $exit_on_errors; + } + + // Return property names for all Rules in array + private static function get_property_names(array $rules): array { + return array_map(fn(Rules $rule): string => $rule->get_property_name(), $rules); + } + + // ---- + + // Append an error to the array of errors + private function add_error(string $name, string $message): self { + // Create sub array for name if it doesn't exist + if (!array_key_exists($name, $this->errors)) { + $this->errors[$name] = []; + } + + $this->errors[$name][] = $message; + return $this; + } + + private function eval_property_name_diff($rules, $scope_keys): void { + // Get property names that aren't defiend in the ruleset + $invalid_properties = array_diff($scope_keys, self::get_property_names($rules)); + + // Add error for each invalid property name + foreach ($invalid_properties as $invalid_property) { + $this->add_error($invalid_property, "Unknown property name '{$invalid_property}'"); + } + } + + // Evaluate Rules against a given value + private function eval_rules(Rules $rules, array &$scope): void { + $name = $rules->get_property_name(); + + // Check if property name exists in scope + if (!$rules->eval_required($scope)) { + // Set property name value on superglobal to null if not defined + $scope[$name] = null; + + // Don't perform further processing if the property is optional and not provided + if (!$rules->required) { + return; + } + + $this->add_error($name, "Value can not be empty"); + } + + $value = $scope[$name]; + + /* + Eval each rule that has been set. + The error messages will be returned + */ + + if ($rules->type && !$rules->eval_type($value)) { + $this->add_error($name, "Value must be of type '{$rules->type->name}'"); + } + + if ($rules->min && !$rules->eval_min($value)) { + $this->add_error($name, "Value must be larger or equal to {$rules->min}"); + } + + if ($rules->max && !$rules->eval_max($value)) { + $this->add_error($name, "Value must be smaller or equal to {$rules->max}"); + } + } + + // Evaluate all Rules in this Ruleset against values in scope and return true if no errors were found + private function eval_all_rules(array $rules, array &$scope): bool { + foreach ($rules as $rule) { + $this->eval_rules($rule, $scope); + } + + return empty($this->errors); + } + + // ---- + + // Perform request processing on GET properties (search parameters) + public function GET(array $rules): true|array|Response { + $this->eval_property_name_diff($rules, array_keys($_GET)); + + $is_valid = $this->eval_all_rules($rules, $_GET); + + // Return errors as a Reflect\Response + if (!$is_valid && $this->exit_on_errors) { + return new Response($this->errors, 422); + } + + return $is_valid ? true : $this->errors; + } + + // Perform request processing on POST properties (request body) + public function POST(array $rules): true|array|Response { + $this->eval_property_name_diff($rules, array_keys($_POST)); + + $is_valid = $this->eval_all_rules($rules, $_POST); + + // Return errors as a Reflect\Response + if (!$is_valid && $this->exit_on_errors) { + return new Response($this->errors, 422); + } + + return $is_valid ? true : $this->errors; + } + } \ No newline at end of file