diff --git a/composer.json b/composer.json index 761a36a..295abf5 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,9 @@ } ], "require": { - "php": ">=8.1" + "php": ">=8.1", + "symfony/intl": "^6.3", + "symfony/polyfill-ctype": "^1.27" }, "require-dev": { "phpunit/phpunit": "^10.0", diff --git a/docs/03-rules.md b/docs/03-rules.md index 2cf68da..664f28c 100644 --- a/docs/03-rules.md +++ b/docs/03-rules.md @@ -8,6 +8,7 @@ ## Basic Rules - [NotBlank](03x-rules-not-blank.md) +- [Type](03x-rules-type.md) ## Comparison Rules @@ -20,6 +21,7 @@ ## Choice Rules - [Choice](03x-rules-choice.md) +- [Country](03x-rules-country.md) ## Other Rules diff --git a/docs/03x-rules-choice.md b/docs/03x-rules-choice.md index 41e78c5..5339b3b 100644 --- a/docs/03x-rules-choice.md +++ b/docs/03x-rules-choice.md @@ -40,7 +40,7 @@ Validator::choice(['red', 'green', 'blue'], multiple: true, minConstraint: 2, ma ``` > **Note** -> An `UnexpectedValueException` will be thrown when `multiple` is `true` and value to be validated is not an `array`. +> An `UnexpectedValueException` will be thrown when `multiple` is `true` and the input value is not an `array`. > **Note** > An `UnexpectedValueException` will be thrown when the `minConstraint` value is greater than or equal to the `maxConstraint` value. diff --git a/docs/03x-rules-country.md b/docs/03x-rules-country.md new file mode 100644 index 0000000..a65858e --- /dev/null +++ b/docs/03x-rules-country.md @@ -0,0 +1,55 @@ +# Country + +Validates that a value is a valid country code. + +```php +Country( + string $code = 'alpha-2', + string $message = 'The "{{ name }}" value is not a valid country code, "{{ value }}" given.' +); +``` + +## Basic Usage + +```php +// Default alpha-2 code +Validator::country()->validate('PT'); // true +Validator::country(code: 'alpha-2')->validate('PT'); // true + +// Alpha-3 code +Validator::country(code: 'alpha-3')->validate('PRT'); // true +``` + +> **Note** +> An `UnexpectedValueException` will be thrown when the `code` value is not a valid option. + +> **Note** +> An `UnexpectedValueException` will be thrown when the input value is not a `string`. + +## Options + +### `code` + +type: `string` default: `alpha-2` + +Set code type to validate the country. +Check the [official country codes](https://en.wikipedia.org/wiki/ISO_3166-1#Current_codes) list for more information. + +Available options: + +- `alpha-2`: two-letter code +- `alpha-3`: three-letter code + +### `message` + +type: `string` default: `The "{{ name }}" value is not a valid country code, "{{ value }}" given.` + +Message that will be shown if the input value is not a valid country code. + +The following parameters are available: + +| Parameter | Description | +|---------------|---------------------------| +| `{{ value }}` | The current invalid value | +| `{{ name }}` | Name of the invalid value | +| `{{ code }}` | Selected code type | \ No newline at end of file diff --git a/docs/03x-rules-type.md b/docs/03x-rules-type.md new file mode 100644 index 0000000..977f3c5 --- /dev/null +++ b/docs/03x-rules-type.md @@ -0,0 +1,90 @@ +# Type + +Validates that a value is of a specific type. + +If an array with multiple types is provided, it will validate if the value is of at least one of the given types. +For example, if `['alpha', 'numeric']` is provided, it will validate if the value is of type `alpha` or of type `numeric`. + +```php +Type( + string|array $constraint, + string $message = 'The "{{ name }}" value should be of type "{{ constraint }}", "{{ value }}" given.' +); +``` + +## Basic Usage + +```php +// Single type +Validator::type('string')->validate('green'); // true +Validator::type('alphanumeric')->validate('gr33n'); // true + +// Multiple types +// Validates if value is of at least one of the provided types +Validator::type(['alpha', 'numeric'])->validate('green'); // true (alpha) +Validator::type(['alpha', 'numeric'])->validate('33'); // true (numeric) +Validator::type(['alpha', 'numeric'])->validate('gr33n'); // false (not alpha nor numeric) + +// Class or interface type +Validator::type(\DateTime::class)->validate(new \DateTime()); // true +Validator::type(\DateTimeInterface::class)->validate(new \DateTime()); // true +``` + +> **Note** +> An `UnexpectedValueException` will be thrown when a constraint type, class or interface is invalid. + +## Options + +### `constraint` + +type: `string`|`array` `required` + +Type(s) to validate the input value type. +Can validate instances of classes and interfaces. + +If an array with multiple types is provided, it will validate if the value is of at least one of the given types. +For example, if `['alpha', 'numeric']` is provided, it will validate if the value is of type `alpha` or of type `numeric`. + +Available data type constraints: + +- [`bool`](https://www.php.net/manual/en/function.is-bool.php), [`boolean`](https://www.php.net/manual/en/function.is-bool.php) +- [`int`](https://www.php.net/manual/en/function.is-int.php), [`integer`](https://www.php.net/manual/en/function.is-int.php), [`long`](https://www.php.net/manual/en/function.is-int.php) +- [`float`](https://www.php.net/manual/en/function.is-float.php), [`double`](https://www.php.net/manual/en/function.is-float.php), [`real`](https://www.php.net/manual/en/function.is-float.php) +- [`numeric`](https://www.php.net/manual/en/function.is-numeric.php) +- [`string`](https://www.php.net/manual/en/function.is-string.php) +- [`scalar`](https://www.php.net/manual/en/function.is-scalar.php) +- [`array`](https://www.php.net/manual/en/function.is-array.php) +- [`iterable`](https://www.php.net/manual/en/function.is-iterable.php) +- [`countable`](https://www.php.net/manual/en/function.is-countable.php) +- [`callable`](https://www.php.net/manual/en/function.is-callable.php) +- [`object`](https://www.php.net/manual/en/function.is-object.php) +- [`resource`](https://www.php.net/manual/en/function.is-resource.php) +- [`null`](https://www.php.net/manual/en/function.is-null.php) + +Available character type constraints: + +- [`alphanumeric`](https://www.php.net/manual/en/function.ctype-alnum) +- [`alpha`](https://www.php.net/manual/en/function.ctype-alpha.php) +- [`digit`](https://www.php.net/manual/en/function.ctype-digit.php) +- [`control`](https://www.php.net/manual/en/function.ctype-cntrl.php) +- [`punctuation`](https://www.php.net/manual/en/function.ctype-punct.php) +- [`hexadecimal`](https://www.php.net/manual/en/function.ctype-xdigit.php) +- [`graph`](https://www.php.net/manual/en/function.ctype-graph.php) +- [`printable`](https://www.php.net/manual/en/function.ctype-print.php) +- [`whitespace`](https://www.php.net/manual/en/function.ctype-space.php) +- [`lowercase`](https://www.php.net/manual/en/function.ctype-lower.php) +- [`uppercase`](https://www.php.net/manual/en/function.ctype-upper.php) + +### `message` + +type `string` default: `The "{{ name }}" value should be of type "{{ constraint }}", "{{ value }}" given.` + +Message that will be shown if input value is not of a specific type. + +The following parameters are available: + +| Parameter | Description | +|--------------------|---------------------------| +| `{{ value }}` | The current invalid value | +| `{{ name }}` | Name of the invalid value | +| `{{ constraint }}` | The valid type(s) | \ No newline at end of file diff --git a/src/ChainedValidatorInterface.php b/src/ChainedValidatorInterface.php index 020a43b..283d31b 100644 --- a/src/ChainedValidatorInterface.php +++ b/src/ChainedValidatorInterface.php @@ -32,6 +32,11 @@ public function choice( string $maxMessage = 'The "{{ name }}" value must have at most {{ maxConstraint }} choices, {{ numValues }} choices given.' ): ChainedValidatorInterface; + public function country( + string $code = 'alpha-2', + string $message = 'The "{{ name }}" value is not a valid country code, "{{ value }}" given.' + ): ChainedValidatorInterface; + public function greaterThan( mixed $constraint, string $message = 'The "{{ name }}" value should be greater than "{{ constraint }}", "{{ value }}" given.' @@ -64,4 +69,9 @@ public function range( ): ChainedValidatorInterface; public function rule(RuleInterface $constraint): ChainedValidatorInterface; + + public function type( + string|array $constraint, + string $message = 'The "{{ name }}" value should be of type "{{ constraint }}", "{{ value }}" given.' + ): ChainedValidatorInterface; } \ No newline at end of file diff --git a/src/Exception/CountryException.php b/src/Exception/CountryException.php new file mode 100644 index 0000000..7c75655 --- /dev/null +++ b/src/Exception/CountryException.php @@ -0,0 +1,5 @@ +code, self::CODE_OPTIONS)) { + throw new UnexpectedValueException( + \sprintf( + 'Invalid code "%s". Accepted values are: "%s".', + $this->code, + \implode(", ", self::CODE_OPTIONS) + ) + ); + } + + if (!\is_string($value)) { + throw new UnexpectedValueException( + \sprintf('Expected value of type "string", "%s" given.', get_debug_type($value)) + ); + } + + // Keep original value for parameters + $input = strtoupper($value); + + if ( + ($this->code === self::ALPHA_2_CODE && !Countries::exists($input)) + || ($this->code === self::ALPHA_3_CODE && !Countries::alpha3CodeExists($input)) + ) { + throw new CountryException( + message: $this->message, + parameters: [ + 'name' => $name, + 'value' => $value, + 'code' => $this->code + ] + ); + } + } +} \ No newline at end of file diff --git a/src/Rule/Type.php b/src/Rule/Type.php new file mode 100644 index 0000000..a902558 --- /dev/null +++ b/src/Rule/Type.php @@ -0,0 +1,110 @@ + 'is_bool', + self::BOOLEAN => 'is_bool', + self::INT => 'is_int', + self::INTEGER => 'is_int', + self::LONG => 'is_int', + self::FLOAT => 'is_float', + self::DOUBLE => 'is_float', + self::REAL => 'is_float', + self::NUMERIC => 'is_numeric', + self::STRING => 'is_string', + self::SCALAR => 'is_scalar', + self::ARRAY => 'is_array', + self::ITERABLE => 'is_iterable', + self::COUNTABLE => 'is_countable', + self::CALLABLE => 'is_callable', + self::OBJECT => 'is_object', + self::RESOURCE => 'is_resource', + self::NULL => 'is_null', + self::ALPHANUMERIC => 'ctype_alnum', + self::ALPHA => 'ctype_alpha', + self::DIGIT => 'ctype_digit', + self::CONTROL => 'ctype_cntrl', + self::PUNCTUATION => 'ctype_punct', + self::HEXADECIMAL => 'ctype_xdigit', + self::GRAPH => 'ctype_graph', + self::PRINTABLE => 'ctype_print', + self::WHITESPACE => 'ctype_space', + self::LOWERCASE => 'ctype_lower', + self::UPPERCASE => 'ctype_upper' + ]; + + public function __construct( + private readonly string|array $constraint, + private readonly string $message = 'The "{{ name }}" value should be of type "{{ constraint }}", "{{ value }}" given.' + ) {} + + public function assert(mixed $value, string $name): void + { + $constraints = (array) $this->constraint; + + foreach ($constraints as $constraint) { + if (isset(self::TYPE_FUNCTIONS[$constraint]) && (self::TYPE_FUNCTIONS[$constraint])($value)) { + return; + } + + if ($value instanceof $constraint) { + return; + } + + if (!isset(self::TYPE_FUNCTIONS[$constraint]) && !\class_exists($constraint) && !\interface_exists($constraint)) { + throw new UnexpectedValueException( + \sprintf( + 'Invalid constraint type "%s". Accepted values are: "%s"', + $constraint, + \implode('", "', \array_keys(self::TYPE_FUNCTIONS)) + ) + ); + } + } + + throw new TypeException( + message: $this->message, + parameters: [ + 'name' => $name, + 'value' => $value, + 'constraint' => $this->constraint + ] + ); + } +} \ No newline at end of file diff --git a/src/StaticValidatorInterface.php b/src/StaticValidatorInterface.php index 166df1d..6cd8298 100644 --- a/src/StaticValidatorInterface.php +++ b/src/StaticValidatorInterface.php @@ -22,6 +22,11 @@ public static function choice( string $maxMessage = 'The "{{ name }}" value must have at most {{ maxConstraint }} choices, {{ numValues }} choices given.' ): ChainedValidatorInterface; + public static function country( + string $code = 'alpha-2', + string $message = 'The "{{ name }}" value is not a valid country code, "{{ value }}" given.' + ): ChainedValidatorInterface; + public static function greaterThan( mixed $constraint, string $message = 'The "{{ name }}" value should be greater than "{{ constraint }}", "{{ value }}" given.' @@ -54,4 +59,9 @@ public static function range( ): ChainedValidatorInterface; public static function rule(RuleInterface $constraint): ChainedValidatorInterface; + + public static function type( + string|array $constraint, + string $message = 'The "{{ name }}" value should be of type "{{ constraint }}", "{{ value }}" given.' + ): ChainedValidatorInterface; } \ No newline at end of file diff --git a/tests/CountryTest.php b/tests/CountryTest.php new file mode 100644 index 0000000..bc8965d --- /dev/null +++ b/tests/CountryTest.php @@ -0,0 +1,57 @@ + [new Country('invalid'), 'PT', $codeMessage]; + yield 'invalid type' => [new Country(), 123, $typeMessage]; + } + + public static function provideRuleFailureConditionData(): \Generator + { + $exception = CountryException::class; + $message = '/The "(.*)" value is not a valid country code, "(.*)" given./'; + + yield 'default' => [new Country(), 'PRT', $exception, $message]; + yield 'alpha2' => [new Country(code: 'alpha-2'), 'PRT', $exception, $message]; + yield 'alpha3' => [new Country(code: 'alpha-3'), 'PT', $exception, $message]; + } + + public static function provideRuleSuccessConditionData(): \Generator + { + yield 'default' => [new Country(), 'PT']; + yield 'alpha2' => [new Country(code: 'alpha-2'), 'PT']; + yield 'alpha2 lowercase' => [new Country(code: 'alpha-2'), 'pt']; + yield 'alpha3' => [new Country(code: 'alpha-3'), 'PRT']; + yield 'alpha3 lowercase' => [new Country(code: 'alpha-3'), 'prt']; + } + + public static function provideRuleMessageOptionData(): \Generator + { + yield 'message' => [ + new Country( + message: 'The "{{ name }}" value "{{ value }}" is not a valid "{{ code }}" country code.' + ), + 'invalid', + 'The "test" value "invalid" is not a valid "alpha-2" country code.' + ]; + } +} \ No newline at end of file diff --git a/tests/NotBlankTest.php b/tests/NotBlankTest.php index 554860d..663c151 100644 --- a/tests/NotBlankTest.php +++ b/tests/NotBlankTest.php @@ -17,15 +17,15 @@ class NotBlankTest extends AbstractTest public static function provideRuleFailureConditionData(): \Generator { $exception = NotBlankException::class; - $exceptionMessage = '/The "(.*)" value should not be blank, "(.*)" given./'; + $message = '/The "(.*)" value should not be blank, "(.*)" given./'; - yield 'null' => [new NotBlank(), null, $exception, $exceptionMessage]; - yield 'false' => [new NotBlank(), false, $exception, $exceptionMessage]; - yield 'blank string' => [new NotBlank(), '', $exception, $exceptionMessage]; - yield 'blank array' => [new NotBlank(), [], $exception, $exceptionMessage]; + yield 'null' => [new NotBlank(), null, $exception, $message]; + yield 'false' => [new NotBlank(), false, $exception, $message]; + yield 'blank string' => [new NotBlank(), '', $exception, $message]; + yield 'blank array' => [new NotBlank(), [], $exception, $message]; - yield 'normalizer whitespace' => [new NotBlank(normalizer: 'trim'), ' ', $exception, $exceptionMessage]; - yield 'normalizer whitespace function' => [new NotBlank(normalizer: fn($value) => trim($value)), ' ', $exception, $exceptionMessage]; + yield 'normalizer whitespace' => [new NotBlank(normalizer: 'trim'), ' ', $exception, $message]; + yield 'normalizer whitespace function' => [new NotBlank(normalizer: fn($value) => trim($value)), ' ', $exception, $message]; } public static function provideRuleSuccessConditionData(): \Generator diff --git a/tests/TypeTest.php b/tests/TypeTest.php new file mode 100644 index 0000000..fbe559e --- /dev/null +++ b/tests/TypeTest.php @@ -0,0 +1,117 @@ + [new Type('invalid'), 'string', $message]; + } + + public static function provideRuleFailureConditionData(): \Generator + { + $exception = TypeException::class; + $message = '/The "(.*)" value should be of type "(.*)", "(.*)" given./'; + + yield 'bool' => [new Type('bool'), 'invalid', $exception, $message]; + yield 'boolean' => [new Type('boolean'), 'invalid', $exception, $message]; + yield 'int' => [new Type('int'), 'invalid', $exception, $message]; + yield 'integer' => [new Type('integer'), 'invalid', $exception, $message]; + yield 'long' => [new Type('long'), 'invalid', $exception, $message]; + yield 'float' => [new Type('float'), 'invalid', $exception, $message]; + yield 'double' => [new Type('double'), 'invalid', $exception, $message]; + yield 'real' => [new Type('real'), 'invalid', $exception, $message]; + yield 'numeric' => [new Type('numeric'), 'invalid', $exception, $message]; + yield 'string' => [new Type('string'), 123, $exception, $message]; + yield 'scalar' => [new Type('scalar'), [], $exception, $message]; + yield 'array' => [new Type('array'), 'invalid', $exception, $message]; + yield 'iterable' => [new Type('iterable'), 'invalid', $exception, $message]; + yield 'countable' => [new Type('countable'), 'invalid', $exception, $message]; + yield 'callable' => [new Type('callable'), 'invalid', $exception, $message]; + yield 'object' => [new Type('object'), 'invalid', $exception, $message]; + yield 'resource' => [new Type('resource'), 'invalid', $exception, $message]; + yield 'null' => [new Type('null'), 'invalid', $exception, $message]; + yield 'alphanumeric' => [new Type('alphanumeric'), 'foo!#$bar', $exception, $message]; + yield 'alpha' => [new Type('alpha'), 'arf12', $exception, $message]; + yield 'digit' => [new Type('digit'), 'invalid', $exception, $message]; + yield 'control' => [new Type('control'), 'arf12', $exception, $message]; + yield 'punctuation' => [new Type('punctuation'), 'ABasdk!@!$#', $exception, $message]; + yield 'hexadecimal' => [new Type('hexadecimal'), 'AR1012', $exception, $message]; + yield 'graph' => [new Type('graph'), "asdf\n\r\t", $exception, $message]; + yield 'printable' => [new Type('printable'), "asdf\n\r\t", $exception, $message]; + yield 'whitespace' => [new Type('whitespace'), "\narf12", $exception, $message]; + yield 'lowercase' => [new Type('lowercase'), 'Invalid', $exception, $message]; + yield 'uppercase' => [new Type('uppercase'), 'invalid', $exception, $message]; + + yield 'class' => [new Type(\DateTime::class), 'invalid', $exception, $message]; + yield 'interface' => [new Type(\DateTimeInterface::class), 'invalid', $exception, $message]; + + yield 'multiple types' => [new Type(['digit', 'numeric']), 'invalid', $exception, $message]; + } + + public static function provideRuleSuccessConditionData(): \Generator + { + yield 'bool' => [new Type('bool'), true]; + yield 'boolean' => [new Type('boolean'), false]; + yield 'int' => [new Type('int'), 1]; + yield 'integer' => [new Type('integer'), 2]; + yield 'long' => [new Type('long'), 3]; + yield 'float' => [new Type('float'), 1.1]; + yield 'double' => [new Type('double'), 1.2]; + yield 'real' => [new Type('real'), 1.3]; + yield 'numeric' => [new Type('numeric'), 123]; + yield 'string' => [new Type('string'), 'string']; + yield 'scalar' => [new Type('scalar'), 'string']; + yield 'array' => [new Type('array'), [1, 2, 3]]; + yield 'iterable' => [new Type('iterable'), new \ArrayIterator([1, 2, 3])]; + yield 'countable' => [new Type('countable'), new \ArrayIterator([1, 2, 3])]; + yield 'callable' => [new Type('callable'), 'trim']; + yield 'object' => [new Type('object'), new \stdClass()]; + yield 'resource' => [new Type('resource'), fopen('php://stdout', 'r')]; + yield 'null' => [new Type('null'), null]; + yield 'alphanumeric' => [new Type('alphanumeric'), 'abc123']; + yield 'alpha' => [new Type('alpha'), 'abc']; + yield 'digit' => [new Type('digit'), '123']; + yield 'control' => [new Type('control'), "\n\r\t"]; + yield 'punctuation' => [new Type('punctuation'), '*&$()']; + yield 'hexadecimal' => [new Type('hexadecimal'), 'AB10BC99']; + yield 'graph' => [new Type('graph'), 'LKA#@%.54']; + yield 'printable' => [new Type('printable'), 'LKA#@%.54']; + yield 'whitespace' => [new Type('whitespace'), "\n\r\t"]; + yield 'lowercase' => [new Type('lowercase'), 'string']; + yield 'uppercase' => [new Type('uppercase'), 'STRING']; + + yield 'class' => [new Type(\DateTime::class), new \DateTime()]; + yield 'interface' => [new Type(\DateTimeInterface::class), new \DateTime()]; + + yield 'multiple types' => [new Type(['alpha', 'numeric']), '123']; + } + + public static function provideRuleMessageOptionData(): \Generator + { + yield 'message' => [ + new Type( + constraint: 'int', + message: 'The "{{ name }}" value is not of type "{{ constraint }}".' + ), + 'string', + 'The "test" value is not of type "int".' + ]; + } + +} \ No newline at end of file