diff --git a/src/Schema.php b/src/Schema.php index 14abece..a22f108 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -80,6 +80,7 @@ class Schema implements \JsonSerializable, \ArrayAccess { */ public function __construct($schema = []) { $this->schema = $schema; + $this->setFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE, true); } /** @@ -1010,10 +1011,8 @@ protected function validateString($value, ValidationField $field) { return Invalid::value(); } - $errorCount = $field->getErrorCount(); - $strFn = $this->hasFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE) ? "mb_strlen" : "strlen"; - $strLen = $strFn($value); - if (($minLength = $field->val('minLength', 0)) > 0 && $strLen < $minLength) { + $mbStrLen = mb_strlen($value); + if (($minLength = $field->val('minLength', 0)) > 0 && $mbStrLen < $minLength) { if (!empty($field->getName()) && $minLength === 1) { $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]); } else { @@ -1027,17 +1026,35 @@ protected function validateString($value, ValidationField $field) { ); } } - if (($maxLength = $field->val('maxLength', 0)) > 0 && $strLen > $maxLength) { + if (($maxLength = $field->val('maxLength', 0)) > 0 && $mbStrLen > $maxLength) { $field->addError( 'maxLength', [ 'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.', 'maxLength' => $maxLength, - 'overflow' => $strLen - $maxLength, + 'overflow' => $mbStrLen - $maxLength, 'status' => 422 ] ); } + + $useLengthAsByteLength = !$this->hasFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE); + $maxByteLength = $field->val('maxByteLength', $useLengthAsByteLength ? $maxLength : null); + if ($maxByteLength !== null && $maxByteLength > 0) { + $byteStrLen = strlen($value); + if ($byteStrLen > $maxByteLength) { + $field->addError( + 'maxByteLength', + [ + 'messageCode' => '{field} is {overflow} {overflow,plural,byte,bytes} too long.', + 'maxLength' => $maxLength, + 'overflow' => $byteStrLen - $maxByteLength, + 'status' => 422, + ] + ); + } + } + if ($pattern = $field->val('pattern')) { $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`'; diff --git a/tests/PropertyTest.php b/tests/PropertyTest.php index 8a77df5..7f61d92 100644 --- a/tests/PropertyTest.php +++ b/tests/PropertyTest.php @@ -26,6 +26,7 @@ public function testPropertyAccess() { $this->assertSame('foo', $schema->jsonSerialize()['description']); $this->assertSame('foo', $schema['description']); + $schema->setFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE, false); $this->assertSame(0, $schema->getFlags()); $behaviors = [ Schema::VALIDATE_EXTRA_PROPERTY_NOTICE, diff --git a/tests/StringValidationTest.php b/tests/StringValidationTest.php index 1c23ad1..c85a8be 100644 --- a/tests/StringValidationTest.php +++ b/tests/StringValidationTest.php @@ -59,7 +59,7 @@ public function provideMinLengthTests() { 'abcd' => ['abcd', ''], 'empty 1' => ['', 'missingField', 1], 'empty 0' => ['', '', 0], - 'unicode as bytes success' => ['😱', '', 4], + 'unicode as bytes success' => ['😱', 'minLength', 4], 'unicode as unicode fail' => ['😱', 'minLength', 2, Schema::VALIDATE_STRING_LENGTH_AS_UNICODE], 'unicode as unicode success' => ['😱', '', 1, Schema::VALIDATE_STRING_LENGTH_AS_UNICODE], ]; @@ -73,19 +73,14 @@ public function provideMinLengthTests() { * @param string $str The string to test. * @param string $code The expected error code, if any. * @param int $maxLength The max length to test. - * @param int $flags Flags to set on the schema. * * @dataProvider provideMaxLengthTests */ - public function testMaxLength($str, string $code = '', int $maxLength = 3, int $flags = null) { + public function testMaxLength($str, string $code = '', int $maxLength = 3) { $schema = Schema::parse(['str:s?' => [ 'maxLength' => $maxLength, ]]); - if ($flags !== null) { - $schema->setFlags($flags); - } - try { $schema->validate(['str' => $str]); @@ -111,14 +106,76 @@ public function provideMaxLengthTests() { 'ab' => ['ab'], 'abc' => ['abc'], 'abcd' => ['abcd', 'maxLength'], - 'long multibyte with unicode length' => ['😱', '', 2, Schema::VALIDATE_STRING_LENGTH_AS_UNICODE], - 'long multibyte with byte length' => ['😱', 'maxLength', 2], - 'exact amount multibyte with byte length' => ['😱', '', 4], ]; return $r; } + /** + * Test byte length validation. + * + * @param array $value + * @param string|array|null $exceptionMessages Null, an expected exception message, or multiple expected exception messages. + * @param bool $forceByteLength Set this to true to force all maxLengths to be byte length. + * + * @dataProvider provideByteLengths + */ + public function testByteLengthValidation(array $value, $exceptionMessages = null, bool $forceByteLength = false) { + $schema = Schema::parse([ + 'justLength:s?' => [ + 'maxLength' => 4, + ], + 'justByteLength:s?' => [ + 'maxByteLength' => 8, + ], + 'mixedLengths:s?' => [ + 'maxLength' => 4, + 'maxByteLength' => 6 + ], + ]); + if ($forceByteLength) { + $schema->setFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE, false); + } + + try { + $schema->validate($value); + // We were expecting success. + $this->assertTrue(true); + } catch (ValidationException $e) { + if ($exceptionMessages !== null) { + $actual = $e->getMessage(); + $exceptionMessages = is_array($exceptionMessages) ? $exceptionMessages : [$exceptionMessages]; + foreach ($exceptionMessages as $expected) { + $this->assertContains($expected, $actual); + } + } else { + throw $e; + } + } + } + + /** + * @return array + */ + public function provideByteLengths() { + return [ + 'maxLength - short' => [['justLength' => '😱']], + 'maxLength - equal' => [['justLength' => '😱😱😱😱']], + 'maxLength - long' => [['justLength' => '😱😱😱😱😱'], '1 characters too long'], + 'byteLength - short' => [['justByteLength' => '😱']], + 'byteLength - equal' => [['justByteLength' => '😱😱']], + 'byteLength - long' => [['justByteLength' => '😱😱a'], '1 byte too long'], + 'mixedLengths - short' => [['mixedLengths' => '😱']], + 'mixedLengths - equal' => [['mixedLengths' => '😱aa']], + 'mixedLengths - long bytes' => [['mixedLengths' => '😱😱'], '2 bytes too long'], + 'mixedLengths - long chars' => [['mixedLengths' => 'aaaaa'], '1 characters too long'], + 'mixedLengths - long chars - long bytes' => [['mixedLengths' => '😱😱😱😱😱'], ["1 characters too long", "14 bytes too long."]], + 'byteLength flag - short' => [['justLength' => '😱'], null, true], + 'byteLength flag - long' => [['justLength' => '😱😱😱😱'], '12 bytes too long', true], + 'byteLength property is preferred over byte length flag' => [['mixedLengths' => '😱😱'], '2 bytes too long', true] + ]; + } + /** * Test string pattern constraints. *