feat: evaluate whole Rulesets and set errors from Error enum (#9)

* wip: 2023-12-26T14:36:46+0100 (1703597806)

* feat: eval whole ruleset and return error enum name

* fix: remove unused package

* wip: 2023-12-26T14:36:46+0100 (1703597806)

* feat(doc): add new ruleset logic to README
This commit is contained in:
Victor Westerlund 2023-12-31 14:51:38 +01:00 committed by GitHub
parent 067896dafe
commit 3e380c0957
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 104 additions and 129 deletions

View file

@ -1,13 +1,12 @@
# Request validation plugin for the [Reflect API Framework](https://github.com/victorwesterlund/reflect) # Request validation plugin for the [Reflect API Framework](https://github.com/victorwesterlund/reflect)
This request pre-processor adds request validation to an API written for the Reflect API Framework. Enforce request constraints against set rules and optionally return errors back with a `Reflect\Response` before your endpoint's code even starts running. This request pre-processor adds request validation for an API written in the Reflect API Framework.
Write Reflect endpoints safer by assuming data is what you expect it to be before it reaches your endpoint's logic. This plugin will validate GET and POST parameters against user-defined constraints before letting a request through to a `Reflect\Endpoint`. Write safer Reflect endpoints by enforcing request data structure validation before the request reaches your endpoint's logic. This plugin validates GET and POST data (even JSON) and returns an array with scoped `Error`s that can be further acted upon if desired.
A `Reflect\Response` will be generated and handled by this plugin if request data doesn't meet the defiend constraints.
*Example:* *Example:*
``` ```
Request: /my-endpoint?key1=lorem-ipsum&key2=dolor GET Request: /my-endpoint?key1=lorem-ipsum&key2=dolor
Response: (HTTP 422) {"key2": ["Value must be of type 'STRING']} POST Body: {"key3":15, "key4":["hello", "world"]}
``` ```
```php ```php
use \Reflect\Endpoint; use \Reflect\Endpoint;
@ -30,9 +29,20 @@ class GET_MyEndpoint implements Endpoint {
->min(5) ->min(5)
->max(50), ->max(50),
(new Rules("key2") (new Rules("key2")
->required()
->type(Type::NUMBER) ->type(Type::NUMBER)
->max(255) ->max(255)
]); ]);
$this->rules->POST([
(new Rules("key3")
->type(Type::ARRAY)
->min(3),
(new Rules("key4")
->required()
->type(Type::STRING)
->max(255)
]);
} }
public function main(): Response { public function main(): Response {
@ -40,6 +50,26 @@ class GET_MyEndpoint implements Endpoint {
} }
} }
``` ```
```php
Ruleset->get_errors();
[
"GET" => [
"key2" => [
"INVALID_PROPERTY_TYPE" => ["STRING"]
]
],
"POST" => [
"key3" => [
"VALUE_MIN_ERROR" => 3
],
"key4" => [
"MISSING_REQUIRED_PROPERTY" => "key4"
]
]
]
```
Use `Ruleset->is_valid(): bool` to quickly check if any errors are set.
# Installation # Installation
@ -70,6 +100,16 @@ public function __construct() {
} }
``` ```
# Errors
Error
--|
[`Error::VALUE_MIN_ERROR`](#min)
[`Error::VALUE_MAX_ERROR`](#max)
[`Error::INVALID_PROPERTY_TYPE`](#type)
[`Error::INVALID_PROPERTY_VALUE`](#typeenum)
[`Error::MISSING_REQUIRED_PROPERTY`](#required)
# Available rules # Available rules
The following methods can be chained onto a `Rules` instance to enforce certain constraints on a particular property The following methods can be chained onto a `Rules` instance to enforce certain constraints on a particular property
@ -78,7 +118,9 @@ The following methods can be chained onto a `Rules` instance to enforce certain
Rules->required(bool = true); Rules->required(bool = true);
``` ```
Make a property mandatory by chaining the `required()` method. Omitting this rule will only validate other rules on the property IF the key has been provided in the current scope Make a property mandatory by chaining the `required()` method. Omitting this rule will only validate other rules on the property IF the key has been provided in the current scope.
Will set a `Error::MISSING_REQUIRED_PROPERTY` error on the current scope and property if failed.
## `type()` ## `type()`
```php ```php
@ -105,6 +147,8 @@ Type|Description
`Type::ENUM`|Value must be exactly one of pre-defined values ([**more information**](#typeenum)) `Type::ENUM`|Value must be exactly one of pre-defined values ([**more information**](#typeenum))
`Type::NULL`|Value must be null or ([**considered null for GET rules**](#null-coercion-from-string-for-search-parameters)) `Type::NULL`|Value must be null or ([**considered null for GET rules**](#null-coercion-from-string-for-search-parameters))
Will set a `Error::INVALID_PROPERTY_TYPE` error on the current scope and property if failed, except Type::ENUM that will set a `Error::INVALID_PROPERTY_VALUE` with an array of the valid vaules.
#### `Type::ENUM` #### `Type::ENUM`
Provided value for property must be an exact match of any value provided as an `array` to the second argument of `type(Type::ENUM, <whitelist>)` Provided value for property must be an exact match of any value provided as an `array` to the second argument of `type(Type::ENUM, <whitelist>)`
@ -116,6 +160,8 @@ Rules->type(Type::ENUM, [
``` ```
Any value that isn't `"FOO"` or `"BAR"` will be rejected. Any value that isn't `"FOO"` or `"BAR"` will be rejected.
Will set a `Error::INVALID_PROPERTY_VALUE` error on the current scope and property if failed.
#### Boolean coercion from string for search parameters #### Boolean coercion from string for search parameters
Search parameters are read as strings, a boolean is therefor coerced from the following rules. Search parameters are read as strings, a boolean is therefor coerced from the following rules.
@ -164,6 +210,8 @@ Type|Expects
**`min()` will not have an effect on [`Type`](#types)s not provided in this list.** **`min()` will not have an effect on [`Type`](#types)s not provided in this list.**
Will set a `Error::VALUE_MIN_ERROR` error on the current scope and property if failed
## `max()` ## `max()`
```php ```php
Rules->max(?int = null); Rules->max(?int = null);
@ -177,3 +225,5 @@ Type|Expects
`Type::ARRAY`, `Type::OBJECT`|Array size or object key count to be smaller or equal to provided value `Type::ARRAY`, `Type::OBJECT`|Array size or object key count to be smaller or equal to provided value
**`max()` will not have an effect on [`Type`](#types)s not provided in this list.** **`max()` will not have an effect on [`Type`](#types)s not provided in this list.**
Will set a `Error::VALUE_MAX_ERROR` error on the current scope and property if failed

View file

@ -14,8 +14,5 @@
"psr-4": { "psr-4": {
"ReflectRules\\": "src/" "ReflectRules\\": "src/"
} }
},
"require": {
"victorwesterlund/xenum": "^1.1"
} }
} }

42
composer.lock generated
View file

@ -4,46 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "98953f6b9df8b6761e2d57fc66815033", "content-hash": "7c02351f2b860153c02a8f683d9c540f",
"packages": [ "packages": [],
{
"name": "victorwesterlund/xenum",
"version": "1.1.1",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/php-xenum.git",
"reference": "8972f06f42abd1f382807a67e937d5564bb89699"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/php-xenum/zipball/8972f06f42abd1f382807a67e937d5564bb89699",
"reference": "8972f06f42abd1f382807a67e937d5564bb89699",
"shasum": ""
},
"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.1"
},
"time": "2023-11-20T10:10:39+00:00"
}
],
"packages-dev": [], "packages-dev": [],
"aliases": [], "aliases": [],
"minimum-stability": "dev", "minimum-stability": "dev",

View file

@ -2,14 +2,10 @@
namespace ReflectRules; namespace ReflectRules;
use \victorwesterlund\xEnum;
use \ReflectRules\Scope; use \ReflectRules\Scope;
// Supported types for is_type() // Supported types for is_type()
enum Type { enum Type {
use xEnum;
case NULL; case NULL;
case ENUM; case ENUM;
case ARRAY; case ARRAY;
@ -58,13 +54,13 @@
} }
// Set the minimum lenth/size for property // Set the minimum lenth/size for property
public function min(?int $value = null) { public function min(?int $value = null): self {
$this->min = $value; $this->min = $value;
return $this; return $this;
} }
// Set the maximum length/size for property // Set the maximum length/size for property
public function max(?int $value = null) { public function max(?int $value = null): self {
$this->max = $value; $this->max = $value;
return $this; return $this;
} }

View file

@ -2,13 +2,8 @@
namespace ReflectRules; namespace ReflectRules;
// Use the Response class from Reflect to override endpoint processing if requested
use \Reflect\Response;
use \ReflectRules\Rules; use \ReflectRules\Rules;
require_once "../vendor/autoload.php";
require_once "Rules.php"; require_once "Rules.php";
// Available superglobal scopes // Available superglobal scopes
@ -17,44 +12,34 @@
case POST = "_POST"; case POST = "_POST";
} }
class Ruleset { enum Error {
// This plugin will return exit with a Reflect\Response if errors are found case VALUE_MIN_ERROR;
private bool $exit_on_errors; case VALUE_MAX_ERROR;
case INVALID_PROPERTY_TYPE;
case INVALID_PROPERTY_VALUE;
case MISSING_REQUIRED_PROPERTY;
}
// Array of RuleError instances class Ruleset {
// Array of arrays with failed constraints
private array $errors = []; private array $errors = [];
public function __construct(bool $exit_on_errors = true) { public function __construct() {}
// Set exit on errors override flag
$this->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 // Append an error to the array of errors
private function add_error(string $name, string $message): self { private function add_error(Error $error, Scope $scope, string $property, mixed $expected): void {
// Create sub array for name if it doesn't exist // Create sub array if this is the first error in this scope
if (!array_key_exists($name, $this->errors)) { if (!array_key_exists($scope->name, $this->errors)) {
$this->errors[$name] = []; $this->errors[$scope->name] = [];
} }
$this->errors[$name][] = $message; // Create sub array if this is the first error for this property
return $this; if (!array_key_exists($property, $this->errors[$scope->name])) {
} $this->errors[$scope->name][$property] = [];
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}'");
} }
// Set expected value value for property in scope
$this->errors[$scope->name][$property][$error->name] = $expected;
} }
// Evaluate Rules against a given value // Evaluate Rules against a given value
@ -69,7 +54,8 @@
return; return;
} }
$this->add_error($name, "Value can not be empty"); $this->add_error(Error::MISSING_REQUIRED_PROPERTY, $scope, $name, $name);
return;
} }
// Get value from scope for the current property // Get value from scope for the current property
@ -80,64 +66,48 @@
The error messages will be returned The error messages will be returned
*/ */
// Value is not of the correct type or enum value
if ($rules->types && !$rules->eval_type($value, $scope)) { if ($rules->types && !$rules->eval_type($value, $scope)) {
// List names of each allowed type if (!$rules->enum) {
$types = implode(" or ", array_map(fn($type): string => $type->name, $rules->types)); // Get type names from enum
$types = array_map(fn(Type $type): string => $type->name, $rules->types);
// List allowed enum values $this->add_error(Error::INVALID_PROPERTY_TYPE, $scope, $name, $types);
if ($rules->enum) { } else {
$values = implode(" or ", array_map(fn($value): string => "'{$value}'", $rules->enum)); $this->add_error(Error::INVALID_PROPERTY_VALUE, $scope, $name, $rules->enum);
$this->add_error($name, "Value must be exactly: {$values}");
} }
$this->add_error($name, "Value must be of type {$types}");
} }
if ($rules->min && !$rules->eval_min($value, $scope)) { if ($rules->min && !$rules->eval_min($value, $scope)) {
$this->add_error($name, "Value must be larger or equal to {$rules->min}"); $this->add_error(Error::VALUE_MIN_ERROR, $scope, $name, $rules->min);
} }
if ($rules->max && !$rules->eval_max($value, $scope)) { if ($rules->max && !$rules->eval_max($value, $scope)) {
$this->add_error($name, "Value must be smaller or equal to {$rules->max}"); $this->add_error(Error::VALUE_MAX_ERROR, $scope, $name, $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, Scope $scope): bool {
foreach ($rules as $rule) {
$this->eval_rules($rule, $scope);
}
return empty($this->errors);
}
// ---- // ----
// Perform request processing on GET properties (search parameters) // Perform request processing on GET properties (search parameters)
public function GET(array $rules): true|array|Response { public function GET(array $rules): void {
$this->eval_property_name_diff($rules, array_keys($_GET)); foreach ($rules as $rule) {
$this->eval_rules($rule, Scope::GET);
$is_valid = $this->eval_all_rules($rules, Scope::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) // Perform request processing on POST properties (request body)
public function POST(array $rules): true|array|Response { public function POST(array $rules): void {
$this->eval_property_name_diff($rules, array_keys($_POST)); foreach ($rules as $rule) {
$this->eval_rules($rule, Scope::POST);
$is_valid = $this->eval_all_rules($rules, Scope::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; public function is_valid(): bool {
return empty($this->errors);
}
public function get_errors(): array {
return $this->errors;
} }
} }