From 11b05e9712b9a12db030b03104d11188dd4a3e7a Mon Sep 17 00:00:00 2001 From: "Missael H. Anda" Date: Fri, 3 Feb 2023 19:11:15 -0600 Subject: [PATCH 1/4] parse custom rules and closure rules --- src/Extracting/ParsesValidationRules.php | 603 ++++++++++++----------- 1 file changed, 310 insertions(+), 293 deletions(-) diff --git a/src/Extracting/ParsesValidationRules.php b/src/Extracting/ParsesValidationRules.php index 9c3c0b08..50253c04 100644 --- a/src/Extracting/ParsesValidationRules.php +++ b/src/Extracting/ParsesValidationRules.php @@ -11,6 +11,7 @@ use Knuckles\Scribe\Exceptions\ScribeException; use Knuckles\Scribe\Tools\ConsoleOutputUtils as c; use Knuckles\Scribe\Tools\WritingUtils as w; +use ReflectionClass; use Throwable; trait ParsesValidationRules @@ -164,300 +165,322 @@ protected function normaliseRules(array $rules): array */ protected function parseRule($rule, array &$parameterData, bool $independentOnly, array $allParameters = []): bool { - try { - if (!(is_string($rule) || $rule instanceof Rule)) { - return true; - } + if ($rule instanceof Rule) { + if (method_exists($rule, 'docs')) { + $customData = call_user_func_array([$rule, 'docs'], []) ?: []; - // Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]]) - $parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule); - [$rule, $arguments] = $parsedRule; + if (isset($customData['description'])) { + $parameterData['description'] .= ' ' . $customData['description']; + unset($customData['description']); + } - $dependentRules = ['between', 'max', 'min', 'size', 'gt', 'gte', 'lt', 'lte', 'before', 'after', 'before_or_equal', 'after_or_equal']; - if ($independentOnly && in_array($rule, $dependentRules)) { - return false; + $parameterData = array_merge($parameterData, $customData); } + } elseif ($rule instanceof \Closure) { + $reflection = new \ReflectionFunction($rule); + + if (is_string($description = $reflection->getDocComment())) { + $finalDescription = ''; + // Cleanup comment block and extract just the description + foreach (explode("\n", $description) as $line) { + $cleaned = preg_replace(['/^\/\*+\s*/', '/^\*+\s*/', '/\*+\/$/'], '', trim($line)); + if ($cleaned != '') $finalDescription .= ' ' . $cleaned; + } - // Reminders: - // 1. Append to the description (with a leading space); don't overwrite. - // 2. Avoid testing on the value of $parameterData['type'], - // as that may not have been set yet, since the rules can be in any order. - // For this reason, only deterministic rules are supported - // 3. All rules supported must be rules that we can generate a valid dummy value for. - switch ($rule) { - case 'required': - $parameterData['required'] = true; - break; - case 'accepted': - $parameterData['required'] = true; - $parameterData['type'] = 'boolean'; - $parameterData['description'] .= ' Must be accepted.'; - $parameterData['setter'] = fn() => true; - break; - - /* - * Primitive types. No description should be added - */ - case 'bool': - case 'boolean': - $parameterData['setter'] = function () { - return Arr::random([true, false]); - }; - $parameterData['type'] = 'boolean'; - break; - case 'string': - $parameterData['setter'] = function () use ($parameterData) { - return $this->generateDummyValue('string', ['name' => $parameterData['name']]); - }; - $parameterData['type'] = 'string'; - break; - case 'int': - case 'integer': - $parameterData['setter'] = function () { - return $this->generateDummyValue('integer'); - }; - $parameterData['type'] = 'integer'; - break; - case 'numeric': - $parameterData['setter'] = function () { - return $this->generateDummyValue('number'); - }; - $parameterData['type'] = 'number'; - break; - case 'array': - $parameterData['setter'] = function () { - return [$this->generateDummyValue('string')]; - }; - $parameterData['type'] = 'array'; // The cleanup code in normaliseArrayAndObjectParameters() will set this to a valid type (x[] or object) - break; - case 'file': - $parameterData['type'] = 'file'; - $parameterData['description'] .= ' Must be a file.'; - $parameterData['setter'] = function () { - return $this->generateDummyValue('file'); - }; - break; - - /** - * Special string types - */ - case 'alpha': - $parameterData['description'] .= " Must contain only letters."; - $parameterData['setter'] = function () { - return $this->getFaker()->lexify('??????'); - }; - break; - case 'alpha_dash': - $parameterData['description'] .= " Must contain only letters, numbers, dashes and underscores."; - $parameterData['setter'] = function () { - return $this->getFaker()->lexify('???-???_?'); - }; - break; - case 'alpha_num': - $parameterData['description'] .= " Must contain only letters and numbers."; - $parameterData['setter'] = function () { - return $this->getFaker()->bothify('#?#???#'); - }; - break; - case 'timezone': - // Laravel's message merely says "The value must be a valid zone" - $parameterData['description'] .= " Must be a valid time zone, such as Africa/Accra."; - $parameterData['setter'] = $this->getFakeFactoryByName('timezone'); - break; - case 'email': - $parameterData['description'] .= ' ' . $this->getDescription($rule); - $parameterData['setter'] = $this->getFakeFactoryByName('email'); - $parameterData['type'] = 'string'; - break; - case 'url': - $parameterData['setter'] = $this->getFakeFactoryByName('url'); - $parameterData['type'] = 'string'; - // Laravel's message is "The value format is invalid". Ugh.🤮 - $parameterData['description'] .= " Must be a valid URL."; - break; - case 'ip': - $parameterData['description'] .= ' ' . $this->getDescription($rule); - $parameterData['type'] = 'string'; - $parameterData['setter'] = function () { - return $this->getFaker()->ipv4(); - }; - break; - case 'json': - $parameterData['type'] = 'string'; - $parameterData['description'] .= ' ' . $this->getDescription($rule); - $parameterData['setter'] = function () { - return json_encode([$this->getFaker()->word(), $this->getFaker()->word(),]); - }; - break; - case 'date': - $parameterData['type'] = 'string'; - $parameterData['description'] .= ' ' . $this->getDescription($rule); - $parameterData['setter'] = fn() => date('Y-m-d\TH:i:s', time()); - break; - case 'date_format': - $parameterData['type'] = 'string'; - // Laravel description here is "The value must match the format Y-m-d". Not descriptive enough. - $parameterData['description'] .= " Must be a valid date in the format {$arguments[0]}."; - $parameterData['setter'] = function () use ($arguments) { - return date($arguments[0], time()); - }; - break; - case 'after': - case 'after_or_equal': - $parameterData['type'] = 'string'; - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "{$arguments[0]}"]); - // TODO introduce the concept of "modifiers", like date_format - // The startDate may refer to another field, in which case, we just ignore it for now. - $startDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0]; - $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween($startDate, '+100 years')->format('Y-m-d'); - break; - case 'before': - case 'before_or_equal': - $parameterData['type'] = 'string'; - // The argument can be either another field or a date - // The endDate may refer to another field, in which case, we just ignore it for now. - $endDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0]; - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "{$arguments[0]}"]); - $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween('-30 years', $endDate)->format('Y-m-d'); - break; - case 'starts_with': - $parameterData['description'] .= ' Must start with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments); - $parameterData['setter'] = fn() => $this->getFaker()->lexify("{$arguments[0]}????");; - break; - case 'ends_with': - $parameterData['description'] .= ' Must end with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments); - $parameterData['setter'] = fn() => $this->getFaker()->lexify("????{$arguments[0]}");; - break; - case 'uuid': - $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' '; - $parameterData['setter'] = $this->getFakeFactoryByName('uuid'); - break; - case 'regex': - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':regex' => $arguments[0]]); - $parameterData['setter'] = fn() => $this->getFaker()->regexify($arguments[0]);; - break; - - /** - * Special number types. - */ - case 'digits': - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':digits' => $arguments[0]]); - $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", $arguments[0])); - $parameterData['type'] = 'string'; - break; - case 'digits_between': - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]]); - $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", rand($arguments[0], $arguments[1]))); - $parameterData['type'] = 'string'; - break; - - /** - * These rules can apply to numbers, strings, arrays or files - */ - case 'size': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':size' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) - ); - $parameterData['setter'] = $this->getDummyValueGenerator($parameterData['type'], ['size' => $arguments[0]]); - break; - case 'min': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':min' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) - ); - $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), fieldName: $parameterData['name']); - break; - case 'max': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':max' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) - ); - $max = min($arguments[0], 25); - $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], 1, $max, $parameterData['name']); - break; - case 'between': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':min' => $arguments[0], ':max' => $arguments[1]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) - ); - // Avoid exponentially complex operations by using the minimum length - $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), floatval($arguments[0]) + 1, $parameterData['name']); - break; - - /** - * Special file types. - */ - case 'image': - $parameterData['type'] = 'file'; - $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' '; - $parameterData['setter'] = function () { - // This is fine because the file example generator generates an image - return $this->generateDummyValue('file'); - }; - break; - - /** - * Other rules. - */ - case 'in': - // Not using the rule description here because it only says "The attribute is invalid" - $parameterData['description'] .= ' Must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; - $parameterData['setter'] = function () use ($arguments) { - return Arr::random($arguments); - }; - break; - - /** - * These rules only add a description. Generating valid examples is too complex. - */ - case 'not_in': - $parameterData['description'] .= ' Must not be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; - break; - case 'required_if': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':other' => "{$arguments[0]}", ':value' => w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1))] - ) . ' '; - break; - case 'required_unless': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':other' => "" . array_shift($arguments) . "", ':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] - ) . ' '; - break; - case 'required_with': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] - ) . ' '; - break; - case 'required_without': - $description = $this->getDescription( - $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] - ) . ' '; - $parameterData['description'] .= str_replace('must be present', 'is not present', $description); - break; - case 'required_with_all': - case 'required_without_all': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments, "and")] - ) . ' '; - break; - case 'accepted_if': - $parameterData['type'] = 'boolean'; - $parameterData['description'] .= " Must be accepted when $arguments[0] is " . w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1)); - $parameterData['setter'] = fn() => true; - break; - case 'same': - case 'different': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':other' => "{$arguments[0]}"] - ) . ' '; - break; - - default: - // Other rules not supported - break; + $parameterData['description'] .= $finalDescription; } + } elseif (is_string($rule)) { + try { + // Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]]) + $parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule); + [$rule, $arguments] = $parsedRule; - return true; - } catch (Throwable $e) { - throw CouldntProcessValidationRule::forParam($parameterData['name'], $rule, $e); + $dependentRules = ['between', 'max', 'min', 'size', 'gt', 'gte', 'lt', 'lte', 'before', 'after', 'before_or_equal', 'after_or_equal']; + if ($independentOnly && in_array($rule, $dependentRules)) { + return false; + } + + // Reminders: + // 1. Append to the description (with a leading space); don't overwrite. + // 2. Avoid testing on the value of $parameterData['type'], + // as that may not have been set yet, since the rules can be in any order. + // For this reason, only deterministic rules are supported + // 3. All rules supported must be rules that we can generate a valid dummy value for. + switch ($rule) { + case 'required': + $parameterData['required'] = true; + break; + case 'accepted': + $parameterData['required'] = true; + $parameterData['type'] = 'boolean'; + $parameterData['description'] .= ' Must be accepted.'; + $parameterData['setter'] = fn() => true; + break; + + /* + * Primitive types. No description should be added + */ + case 'bool': + case 'boolean': + $parameterData['setter'] = function () { + return Arr::random([true, false]); + }; + $parameterData['type'] = 'boolean'; + break; + case 'string': + $parameterData['setter'] = function () use ($parameterData) { + return $this->generateDummyValue('string', ['name' => $parameterData['name']]); + }; + $parameterData['type'] = 'string'; + break; + case 'int': + case 'integer': + $parameterData['setter'] = function () { + return $this->generateDummyValue('integer'); + }; + $parameterData['type'] = 'integer'; + break; + case 'numeric': + $parameterData['setter'] = function () { + return $this->generateDummyValue('number'); + }; + $parameterData['type'] = 'number'; + break; + case 'array': + $parameterData['setter'] = function () { + return [$this->generateDummyValue('string')]; + }; + $parameterData['type'] = 'array'; // The cleanup code in normaliseArrayAndObjectParameters() will set this to a valid type (x[] or object) + break; + case 'file': + $parameterData['type'] = 'file'; + $parameterData['description'] .= ' Must be a file.'; + $parameterData['setter'] = function () { + return $this->generateDummyValue('file'); + }; + break; + + /** + * Special string types + */ + case 'alpha': + $parameterData['description'] .= " Must contain only letters."; + $parameterData['setter'] = function () { + return $this->getFaker()->lexify('??????'); + }; + break; + case 'alpha_dash': + $parameterData['description'] .= " Must contain only letters, numbers, dashes and underscores."; + $parameterData['setter'] = function () { + return $this->getFaker()->lexify('???-???_?'); + }; + break; + case 'alpha_num': + $parameterData['description'] .= " Must contain only letters and numbers."; + $parameterData['setter'] = function () { + return $this->getFaker()->bothify('#?#???#'); + }; + break; + case 'timezone': + // Laravel's message merely says "The value must be a valid zone" + $parameterData['description'] .= " Must be a valid time zone, such as Africa/Accra."; + $parameterData['setter'] = $this->getFakeFactoryByName('timezone'); + break; + case 'email': + $parameterData['description'] .= ' ' . $this->getDescription($rule); + $parameterData['setter'] = $this->getFakeFactoryByName('email'); + $parameterData['type'] = 'string'; + break; + case 'url': + $parameterData['setter'] = $this->getFakeFactoryByName('url'); + $parameterData['type'] = 'string'; + // Laravel's message is "The value format is invalid". Ugh.🤮 + $parameterData['description'] .= " Must be a valid URL."; + break; + case 'ip': + $parameterData['description'] .= ' ' . $this->getDescription($rule); + $parameterData['type'] = 'string'; + $parameterData['setter'] = function () { + return $this->getFaker()->ipv4(); + }; + break; + case 'json': + $parameterData['type'] = 'string'; + $parameterData['description'] .= ' ' . $this->getDescription($rule); + $parameterData['setter'] = function () { + return json_encode([$this->getFaker()->word(), $this->getFaker()->word(),]); + }; + break; + case 'date': + $parameterData['type'] = 'string'; + $parameterData['description'] .= ' ' . $this->getDescription($rule); + $parameterData['setter'] = fn() => date('Y-m-d\TH:i:s', time()); + break; + case 'date_format': + $parameterData['type'] = 'string'; + // Laravel description here is "The value must match the format Y-m-d". Not descriptive enough. + $parameterData['description'] .= " Must be a valid date in the format {$arguments[0]}."; + $parameterData['setter'] = function () use ($arguments) { + return date($arguments[0], time()); + }; + break; + case 'after': + case 'after_or_equal': + $parameterData['type'] = 'string'; + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "{$arguments[0]}"]); + // TODO introduce the concept of "modifiers", like date_format + // The startDate may refer to another field, in which case, we just ignore it for now. + $startDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0]; + $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween($startDate, '+100 years')->format('Y-m-d'); + break; + case 'before': + case 'before_or_equal': + $parameterData['type'] = 'string'; + // The argument can be either another field or a date + // The endDate may refer to another field, in which case, we just ignore it for now. + $endDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0]; + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "{$arguments[0]}"]); + $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween('-30 years', $endDate)->format('Y-m-d'); + break; + case 'starts_with': + $parameterData['description'] .= ' Must start with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments); + $parameterData['setter'] = fn() => $this->getFaker()->lexify("{$arguments[0]}????");; + break; + case 'ends_with': + $parameterData['description'] .= ' Must end with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments); + $parameterData['setter'] = fn() => $this->getFaker()->lexify("????{$arguments[0]}");; + break; + case 'uuid': + $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' '; + $parameterData['setter'] = $this->getFakeFactoryByName('uuid'); + break; + case 'regex': + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':regex' => $arguments[0]]); + $parameterData['setter'] = fn() => $this->getFaker()->regexify($arguments[0]);; + break; + + /** + * Special number types. + */ + case 'digits': + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':digits' => $arguments[0]]); + $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", $arguments[0])); + $parameterData['type'] = 'string'; + break; + case 'digits_between': + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]]); + $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", rand($arguments[0], $arguments[1]))); + $parameterData['type'] = 'string'; + break; + + /** + * These rules can apply to numbers, strings, arrays or files + */ + case 'size': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':size' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) + ); + $parameterData['setter'] = $this->getDummyValueGenerator($parameterData['type'], ['size' => $arguments[0]]); + break; + case 'min': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':min' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) + ); + $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), fieldName: $parameterData['name']); + break; + case 'max': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':max' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) + ); + $max = min($arguments[0], 25); + $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], 1, $max, $parameterData['name']); + break; + case 'between': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':min' => $arguments[0], ':max' => $arguments[1]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) + ); + // Avoid exponentially complex operations by using the minimum length + $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), floatval($arguments[0]) + 1, $parameterData['name']); + break; + + /** + * Special file types. + */ + case 'image': + $parameterData['type'] = 'file'; + $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' '; + $parameterData['setter'] = function () { + // This is fine because the file example generator generates an image + return $this->generateDummyValue('file'); + }; + break; + + /** + * Other rules. + */ + case 'in': + // Not using the rule description here because it only says "The attribute is invalid" + $parameterData['description'] .= ' Must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; + $parameterData['setter'] = function () use ($arguments) { + return Arr::random($arguments); + }; + break; + + /** + * These rules only add a description. Generating valid examples is too complex. + */ + case 'not_in': + $parameterData['description'] .= ' Must not be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; + break; + case 'required_if': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':other' => "{$arguments[0]}", ':value' => w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1))] + ) . ' '; + break; + case 'required_unless': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':other' => "" . array_shift($arguments) . "", ':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] + ) . ' '; + break; + case 'required_with': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] + ) . ' '; + break; + case 'required_without': + $description = $this->getDescription( + $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] + ) . ' '; + $parameterData['description'] .= str_replace('must be present', 'is not present', $description); + break; + case 'required_with_all': + case 'required_without_all': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments, "and")] + ) . ' '; + break; + case 'accepted_if': + $parameterData['type'] = 'boolean'; + $parameterData['description'] .= " Must be accepted when $arguments[0] is " . w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1)); + $parameterData['setter'] = fn() => true; + break; + case 'same': + case 'different': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':other' => "{$arguments[0]}"] + ) . ' '; + break; + + default: + // Other rules not supported + break; + } + } catch (Throwable $e) { + throw CouldntProcessValidationRule::forParam($parameterData['name'], $rule, $e); + } } + + return true; } /** @@ -474,12 +497,6 @@ protected function parseStringRuleIntoRuleAndArguments($rule): array { $ruleArguments = []; - // Convert any custom Rule objects to strings - if ($rule instanceof Rule) { - $className = substr(strrchr(get_class($rule), "\\"), 1); - return [$className, []]; - } - if (strpos($rule, ':') !== false) { [$rule, $argumentsString] = explode(':', $rule, 2); From fd920b4c494506f084130a7e2dd97473d1f5a874 Mon Sep 17 00:00:00 2001 From: "Missael H. Anda" Date: Tue, 7 Feb 2023 12:24:07 -0600 Subject: [PATCH 2/4] tests for parsing closure and custom rules --- src/Extracting/ParsesValidationRules.php | 35 ++++++---- tests/Strategies/GetFromFormRequestTest.php | 42 ++++++++++++ tests/Unit/ValidationRuleParsingTest.php | 76 +++++++++++++++++++++ 3 files changed, 139 insertions(+), 14 deletions(-) diff --git a/src/Extracting/ParsesValidationRules.php b/src/Extracting/ParsesValidationRules.php index 50253c04..156f67fd 100644 --- a/src/Extracting/ParsesValidationRules.php +++ b/src/Extracting/ParsesValidationRules.php @@ -6,6 +6,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; +use Illuminate\Validation\ClosureValidationRule; use Knuckles\Scribe\Exceptions\CouldntProcessValidationRule; use Knuckles\Scribe\Exceptions\ProblemParsingValidationRules; use Knuckles\Scribe\Exceptions\ScribeException; @@ -165,30 +166,36 @@ protected function normaliseRules(array $rules): array */ protected function parseRule($rule, array &$parameterData, bool $independentOnly, array $allParameters = []): bool { - if ($rule instanceof Rule) { - if (method_exists($rule, 'docs')) { - $customData = call_user_func_array([$rule, 'docs'], []) ?: []; - - if (isset($customData['description'])) { - $parameterData['description'] .= ' ' . $customData['description']; - unset($customData['description']); - } - - $parameterData = array_merge($parameterData, $customData); - } - } elseif ($rule instanceof \Closure) { - $reflection = new \ReflectionFunction($rule); + if ($rule instanceof ClosureValidationRule || $rule instanceof \Closure) { + $reflection = new \ReflectionFunction($rule instanceof ClosureValidationRule ? $rule->callback : $rule); if (is_string($description = $reflection->getDocComment())) { $finalDescription = ''; // Cleanup comment block and extract just the description foreach (explode("\n", $description) as $line) { - $cleaned = preg_replace(['/^\/\*+\s*/', '/^\*+\s*/', '/\*+\/$/'], '', trim($line)); + $cleaned = preg_replace(['/\*+\/$/', '/^\/\*+\s*/', '/^\*+\s*/'], '', trim($line)); if ($cleaned != '') $finalDescription .= ' ' . $cleaned; } $parameterData['description'] .= $finalDescription; } + } elseif ($rule instanceof Rule) { + if (method_exists($rule, 'docs')) { + $customData = call_user_func_array([$rule, 'docs'], []) ?: []; + + if (isset($customData['description'])) { + $parameterData['description'] .= ' ' . $customData['description']; + } + if (isset($customData['example'])) { + $parameterData['setter'] = fn () => $customData['example']; + } elseif (isset($customData['setter'])) { + $parameterData['setter'] = $customData['setter']; + } + + $parameterData = array_merge($parameterData, Arr::except($customData, [ + 'description', 'example', 'setter', + ])); + } } elseif (is_string($rule)) { try { // Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]]) diff --git a/tests/Strategies/GetFromFormRequestTest.php b/tests/Strategies/GetFromFormRequestTest.php index 22180eb1..90351309 100644 --- a/tests/Strategies/GetFromFormRequestTest.php +++ b/tests/Strategies/GetFromFormRequestTest.php @@ -240,6 +240,27 @@ public function allows_customisation_of_form_request_instantiation() Globals::$__instantiateFormRequestUsing = null; } + /** @test */ + public function custom_rule_example_doesnt_override_form_request_example() + { + $strategy = new BodyParameters\GetFromFormRequest(new DocumentationConfig([])); + $parametersFromFormRequest = $strategy->getParametersFromValidationRules( + [ + 'dummy' => ['required', new DummyValidationRule], + ], + [ + 'dummy' => [ + 'description' => 'New description.', + 'example' => 'Overrided example.', + ], + ], + ); + + $parsed = $strategy->normaliseArrayAndObjectParameters($parametersFromFormRequest); + $this->assertEquals('Overrided example.', $parsed['dummy']['example']); + $this->assertEquals('New description. This is a dummy test rule.', $parsed['dummy']['description']); + } + protected function fetchViaBodyParams(\ReflectionMethod $method): array { $strategy = new BodyParameters\GetFromFormRequest(new DocumentationConfig([])); @@ -254,3 +275,24 @@ protected function fetchViaQueryParams(\ReflectionMethod $method): array return $strategy->getParametersFromFormRequest($method, $route); } } + +class DummyValidationRule implements \Illuminate\Contracts\Validation\Rule +{ + public function passes($attribute, $value) + { + return true; + } + + public function message() + { + return '.'; + } + + public static function docs() + { + return [ + 'description' => 'This is a dummy test rule.', + 'example' => 'Default example, only added if none other give.', + ]; + } +} diff --git a/tests/Unit/ValidationRuleParsingTest.php b/tests/Unit/ValidationRuleParsingTest.php index e9a904bd..453ddf3c 100644 --- a/tests/Unit/ValidationRuleParsingTest.php +++ b/tests/Unit/ValidationRuleParsingTest.php @@ -451,6 +451,61 @@ public function child_does_not_overwrite_parent_status() $this->assertCount(2, $results); $this->assertEquals(true, $results['array_param']['required']); } + + /** @test */ + public function can_parse_custom_closure_rules() + { + // Single line DocComment + $ruleset = [ + 'closure' => [ + 'bail', 'required', + /** This is a single line parsed closure rule. */ + function ($attribute, $value, $fail) { + $fail('Always fail.'); + }, + ], + ]; + + $results = $this->strategy->parse($ruleset); + $this->assertEquals( + 'This is a single line parsed closure rule.', + $results['closure']['description'] + ); + + // Block DocComment + $ruleset = [ + 'closure' => [ + 'bail', 'required', + /** + * This is a block DocComment + * parsed on a closure rule. + * Extra info. + */ + function ($attribute, $value, $fail) { + $fail('Always fail.'); + }, + ], + ]; + + $results = $this->strategy->parse($ruleset); + $this->assertEquals( + 'This is a block DocComment parsed on a closure rule. Extra info.', + $results['closure']['description'] + ); + } + + /** @test */ + public function can_parse_custom_rule_classes() + { + $ruleset = [ + // The page number. Example: 1 + 'custom_rule' => ['bail', 'required', new DummyWithDocsValidationRule], + ]; + + $results = $this->strategy->parse($ruleset); + $this->assertEquals(true, $results['custom_rule']['required']); + $this->assertEquals('This is a dummy test rule.', $results['custom_rule']['description']); + } } class DummyValidationRule implements \Illuminate\Contracts\Validation\Rule @@ -465,3 +520,24 @@ public function message() return '.'; } } + +class DummyWithDocsValidationRule implements \Illuminate\Contracts\Validation\Rule +{ + public function passes($attribute, $value) + { + return true; + } + + public function message() + { + return '.'; + } + + public static function docs() + { + return [ + 'description' => 'This is a dummy test rule.', + 'example' => 'Default example, only added if none other give.', + ]; + } +} From 17c6e1ed1875d2005acd84a17c4c928f61ae7f1a Mon Sep 17 00:00:00 2001 From: shalvah Date: Tue, 7 Feb 2023 20:20:07 +0100 Subject: [PATCH 3/4] Early returns --- src/Extracting/ParsesValidationRules.php | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Extracting/ParsesValidationRules.php b/src/Extracting/ParsesValidationRules.php index 156f67fd..f6d7d74c 100644 --- a/src/Extracting/ParsesValidationRules.php +++ b/src/Extracting/ParsesValidationRules.php @@ -166,6 +166,13 @@ protected function normaliseRules(array $rules): array */ protected function parseRule($rule, array &$parameterData, bool $independentOnly, array $allParameters = []): bool { + // Reminders: + // 1. Append to the description (with a leading space); don't overwrite. + // 2. Avoid testing on the value of $parameterData['type'], + // as that may not have been set yet, since the rules can be in any order. + // For this reason, only deterministic rules are supported + // 3. All rules supported must be rules that we can generate a valid dummy value for. + if ($rule instanceof ClosureValidationRule || $rule instanceof \Closure) { $reflection = new \ReflectionFunction($rule instanceof ClosureValidationRule ? $rule->callback : $rule); @@ -179,7 +186,11 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly $parameterData['description'] .= $finalDescription; } - } elseif ($rule instanceof Rule) { + + return true; + } + + if ($rule instanceof Rule) { if (method_exists($rule, 'docs')) { $customData = call_user_func_array([$rule, 'docs'], []) ?: []; @@ -187,7 +198,7 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly $parameterData['description'] .= ' ' . $customData['description']; } if (isset($customData['example'])) { - $parameterData['setter'] = fn () => $customData['example']; + $parameterData['setter'] = fn() => $customData['example']; } elseif (isset($customData['setter'])) { $parameterData['setter'] = $customData['setter']; } @@ -196,7 +207,11 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly 'description', 'example', 'setter', ])); } - } elseif (is_string($rule)) { + + return true; + } + + if (is_string($rule)) { try { // Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]]) $parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule); @@ -207,12 +222,6 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly return false; } - // Reminders: - // 1. Append to the description (with a leading space); don't overwrite. - // 2. Avoid testing on the value of $parameterData['type'], - // as that may not have been set yet, since the rules can be in any order. - // For this reason, only deterministic rules are supported - // 3. All rules supported must be rules that we can generate a valid dummy value for. switch ($rule) { case 'required': $parameterData['required'] = true; From 82c539f6d87d0ff25721a3992663adb921f0ec25 Mon Sep 17 00:00:00 2001 From: shalvah Date: Tue, 7 Feb 2023 20:22:24 +0100 Subject: [PATCH 4/4] Early returns --- src/Extracting/ParsesValidationRules.php | 562 ++++++++++++----------- 1 file changed, 282 insertions(+), 280 deletions(-) diff --git a/src/Extracting/ParsesValidationRules.php b/src/Extracting/ParsesValidationRules.php index f6d7d74c..e1996477 100644 --- a/src/Extracting/ParsesValidationRules.php +++ b/src/Extracting/ParsesValidationRules.php @@ -211,289 +211,291 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly return true; } - if (is_string($rule)) { - try { - // Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]]) - $parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule); - [$rule, $arguments] = $parsedRule; + if (!is_string($rule)) { + return false; + } - $dependentRules = ['between', 'max', 'min', 'size', 'gt', 'gte', 'lt', 'lte', 'before', 'after', 'before_or_equal', 'after_or_equal']; - if ($independentOnly && in_array($rule, $dependentRules)) { - return false; - } + try { + // Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]]) + $parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule); + [$rule, $arguments] = $parsedRule; - switch ($rule) { - case 'required': - $parameterData['required'] = true; - break; - case 'accepted': - $parameterData['required'] = true; - $parameterData['type'] = 'boolean'; - $parameterData['description'] .= ' Must be accepted.'; - $parameterData['setter'] = fn() => true; - break; - - /* - * Primitive types. No description should be added - */ - case 'bool': - case 'boolean': - $parameterData['setter'] = function () { - return Arr::random([true, false]); - }; - $parameterData['type'] = 'boolean'; - break; - case 'string': - $parameterData['setter'] = function () use ($parameterData) { - return $this->generateDummyValue('string', ['name' => $parameterData['name']]); - }; - $parameterData['type'] = 'string'; - break; - case 'int': - case 'integer': - $parameterData['setter'] = function () { - return $this->generateDummyValue('integer'); - }; - $parameterData['type'] = 'integer'; - break; - case 'numeric': - $parameterData['setter'] = function () { - return $this->generateDummyValue('number'); - }; - $parameterData['type'] = 'number'; - break; - case 'array': - $parameterData['setter'] = function () { - return [$this->generateDummyValue('string')]; - }; - $parameterData['type'] = 'array'; // The cleanup code in normaliseArrayAndObjectParameters() will set this to a valid type (x[] or object) - break; - case 'file': - $parameterData['type'] = 'file'; - $parameterData['description'] .= ' Must be a file.'; - $parameterData['setter'] = function () { - return $this->generateDummyValue('file'); - }; - break; - - /** - * Special string types - */ - case 'alpha': - $parameterData['description'] .= " Must contain only letters."; - $parameterData['setter'] = function () { - return $this->getFaker()->lexify('??????'); - }; - break; - case 'alpha_dash': - $parameterData['description'] .= " Must contain only letters, numbers, dashes and underscores."; - $parameterData['setter'] = function () { - return $this->getFaker()->lexify('???-???_?'); - }; - break; - case 'alpha_num': - $parameterData['description'] .= " Must contain only letters and numbers."; - $parameterData['setter'] = function () { - return $this->getFaker()->bothify('#?#???#'); - }; - break; - case 'timezone': - // Laravel's message merely says "The value must be a valid zone" - $parameterData['description'] .= " Must be a valid time zone, such as Africa/Accra."; - $parameterData['setter'] = $this->getFakeFactoryByName('timezone'); - break; - case 'email': - $parameterData['description'] .= ' ' . $this->getDescription($rule); - $parameterData['setter'] = $this->getFakeFactoryByName('email'); - $parameterData['type'] = 'string'; - break; - case 'url': - $parameterData['setter'] = $this->getFakeFactoryByName('url'); - $parameterData['type'] = 'string'; - // Laravel's message is "The value format is invalid". Ugh.🤮 - $parameterData['description'] .= " Must be a valid URL."; - break; - case 'ip': - $parameterData['description'] .= ' ' . $this->getDescription($rule); - $parameterData['type'] = 'string'; - $parameterData['setter'] = function () { - return $this->getFaker()->ipv4(); - }; - break; - case 'json': - $parameterData['type'] = 'string'; - $parameterData['description'] .= ' ' . $this->getDescription($rule); - $parameterData['setter'] = function () { - return json_encode([$this->getFaker()->word(), $this->getFaker()->word(),]); - }; - break; - case 'date': - $parameterData['type'] = 'string'; - $parameterData['description'] .= ' ' . $this->getDescription($rule); - $parameterData['setter'] = fn() => date('Y-m-d\TH:i:s', time()); - break; - case 'date_format': - $parameterData['type'] = 'string'; - // Laravel description here is "The value must match the format Y-m-d". Not descriptive enough. - $parameterData['description'] .= " Must be a valid date in the format {$arguments[0]}."; - $parameterData['setter'] = function () use ($arguments) { - return date($arguments[0], time()); - }; - break; - case 'after': - case 'after_or_equal': - $parameterData['type'] = 'string'; - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "{$arguments[0]}"]); - // TODO introduce the concept of "modifiers", like date_format - // The startDate may refer to another field, in which case, we just ignore it for now. - $startDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0]; - $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween($startDate, '+100 years')->format('Y-m-d'); - break; - case 'before': - case 'before_or_equal': - $parameterData['type'] = 'string'; - // The argument can be either another field or a date - // The endDate may refer to another field, in which case, we just ignore it for now. - $endDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0]; - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "{$arguments[0]}"]); - $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween('-30 years', $endDate)->format('Y-m-d'); - break; - case 'starts_with': - $parameterData['description'] .= ' Must start with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments); - $parameterData['setter'] = fn() => $this->getFaker()->lexify("{$arguments[0]}????");; - break; - case 'ends_with': - $parameterData['description'] .= ' Must end with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments); - $parameterData['setter'] = fn() => $this->getFaker()->lexify("????{$arguments[0]}");; - break; - case 'uuid': - $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' '; - $parameterData['setter'] = $this->getFakeFactoryByName('uuid'); - break; - case 'regex': - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':regex' => $arguments[0]]); - $parameterData['setter'] = fn() => $this->getFaker()->regexify($arguments[0]);; - break; - - /** - * Special number types. - */ - case 'digits': - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':digits' => $arguments[0]]); - $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", $arguments[0])); - $parameterData['type'] = 'string'; - break; - case 'digits_between': - $parameterData['description'] .= ' ' . $this->getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]]); - $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", rand($arguments[0], $arguments[1]))); - $parameterData['type'] = 'string'; - break; - - /** - * These rules can apply to numbers, strings, arrays or files - */ - case 'size': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':size' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) - ); - $parameterData['setter'] = $this->getDummyValueGenerator($parameterData['type'], ['size' => $arguments[0]]); - break; - case 'min': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':min' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) - ); - $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), fieldName: $parameterData['name']); - break; - case 'max': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':max' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) - ); - $max = min($arguments[0], 25); - $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], 1, $max, $parameterData['name']); - break; - case 'between': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':min' => $arguments[0], ':max' => $arguments[1]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) - ); - // Avoid exponentially complex operations by using the minimum length - $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), floatval($arguments[0]) + 1, $parameterData['name']); - break; - - /** - * Special file types. - */ - case 'image': - $parameterData['type'] = 'file'; - $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' '; - $parameterData['setter'] = function () { - // This is fine because the file example generator generates an image - return $this->generateDummyValue('file'); - }; - break; - - /** - * Other rules. - */ - case 'in': - // Not using the rule description here because it only says "The attribute is invalid" - $parameterData['description'] .= ' Must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; - $parameterData['setter'] = function () use ($arguments) { - return Arr::random($arguments); - }; - break; - - /** - * These rules only add a description. Generating valid examples is too complex. - */ - case 'not_in': - $parameterData['description'] .= ' Must not be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; - break; - case 'required_if': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':other' => "{$arguments[0]}", ':value' => w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1))] - ) . ' '; - break; - case 'required_unless': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':other' => "" . array_shift($arguments) . "", ':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] - ) . ' '; - break; - case 'required_with': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] - ) . ' '; - break; - case 'required_without': - $description = $this->getDescription( - $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] - ) . ' '; - $parameterData['description'] .= str_replace('must be present', 'is not present', $description); - break; - case 'required_with_all': - case 'required_without_all': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments, "and")] - ) . ' '; - break; - case 'accepted_if': - $parameterData['type'] = 'boolean'; - $parameterData['description'] .= " Must be accepted when $arguments[0] is " . w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1)); - $parameterData['setter'] = fn() => true; - break; - case 'same': - case 'different': - $parameterData['description'] .= ' ' . $this->getDescription( - $rule, [':other' => "{$arguments[0]}"] - ) . ' '; - break; - - default: - // Other rules not supported - break; - } - } catch (Throwable $e) { - throw CouldntProcessValidationRule::forParam($parameterData['name'], $rule, $e); + $dependentRules = ['between', 'max', 'min', 'size', 'gt', 'gte', 'lt', 'lte', 'before', 'after', 'before_or_equal', 'after_or_equal']; + if ($independentOnly && in_array($rule, $dependentRules)) { + return false; + } + + switch ($rule) { + case 'required': + $parameterData['required'] = true; + break; + case 'accepted': + $parameterData['required'] = true; + $parameterData['type'] = 'boolean'; + $parameterData['description'] .= ' Must be accepted.'; + $parameterData['setter'] = fn() => true; + break; + + /* + * Primitive types. No description should be added + */ + case 'bool': + case 'boolean': + $parameterData['setter'] = function () { + return Arr::random([true, false]); + }; + $parameterData['type'] = 'boolean'; + break; + case 'string': + $parameterData['setter'] = function () use ($parameterData) { + return $this->generateDummyValue('string', ['name' => $parameterData['name']]); + }; + $parameterData['type'] = 'string'; + break; + case 'int': + case 'integer': + $parameterData['setter'] = function () { + return $this->generateDummyValue('integer'); + }; + $parameterData['type'] = 'integer'; + break; + case 'numeric': + $parameterData['setter'] = function () { + return $this->generateDummyValue('number'); + }; + $parameterData['type'] = 'number'; + break; + case 'array': + $parameterData['setter'] = function () { + return [$this->generateDummyValue('string')]; + }; + $parameterData['type'] = 'array'; // The cleanup code in normaliseArrayAndObjectParameters() will set this to a valid type (x[] or object) + break; + case 'file': + $parameterData['type'] = 'file'; + $parameterData['description'] .= ' Must be a file.'; + $parameterData['setter'] = function () { + return $this->generateDummyValue('file'); + }; + break; + + /** + * Special string types + */ + case 'alpha': + $parameterData['description'] .= " Must contain only letters."; + $parameterData['setter'] = function () { + return $this->getFaker()->lexify('??????'); + }; + break; + case 'alpha_dash': + $parameterData['description'] .= " Must contain only letters, numbers, dashes and underscores."; + $parameterData['setter'] = function () { + return $this->getFaker()->lexify('???-???_?'); + }; + break; + case 'alpha_num': + $parameterData['description'] .= " Must contain only letters and numbers."; + $parameterData['setter'] = function () { + return $this->getFaker()->bothify('#?#???#'); + }; + break; + case 'timezone': + // Laravel's message merely says "The value must be a valid zone" + $parameterData['description'] .= " Must be a valid time zone, such as Africa/Accra."; + $parameterData['setter'] = $this->getFakeFactoryByName('timezone'); + break; + case 'email': + $parameterData['description'] .= ' ' . $this->getDescription($rule); + $parameterData['setter'] = $this->getFakeFactoryByName('email'); + $parameterData['type'] = 'string'; + break; + case 'url': + $parameterData['setter'] = $this->getFakeFactoryByName('url'); + $parameterData['type'] = 'string'; + // Laravel's message is "The value format is invalid". Ugh.🤮 + $parameterData['description'] .= " Must be a valid URL."; + break; + case 'ip': + $parameterData['description'] .= ' ' . $this->getDescription($rule); + $parameterData['type'] = 'string'; + $parameterData['setter'] = function () { + return $this->getFaker()->ipv4(); + }; + break; + case 'json': + $parameterData['type'] = 'string'; + $parameterData['description'] .= ' ' . $this->getDescription($rule); + $parameterData['setter'] = function () { + return json_encode([$this->getFaker()->word(), $this->getFaker()->word(),]); + }; + break; + case 'date': + $parameterData['type'] = 'string'; + $parameterData['description'] .= ' ' . $this->getDescription($rule); + $parameterData['setter'] = fn() => date('Y-m-d\TH:i:s', time()); + break; + case 'date_format': + $parameterData['type'] = 'string'; + // Laravel description here is "The value must match the format Y-m-d". Not descriptive enough. + $parameterData['description'] .= " Must be a valid date in the format {$arguments[0]}."; + $parameterData['setter'] = function () use ($arguments) { + return date($arguments[0], time()); + }; + break; + case 'after': + case 'after_or_equal': + $parameterData['type'] = 'string'; + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "{$arguments[0]}"]); + // TODO introduce the concept of "modifiers", like date_format + // The startDate may refer to another field, in which case, we just ignore it for now. + $startDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0]; + $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween($startDate, '+100 years')->format('Y-m-d'); + break; + case 'before': + case 'before_or_equal': + $parameterData['type'] = 'string'; + // The argument can be either another field or a date + // The endDate may refer to another field, in which case, we just ignore it for now. + $endDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0]; + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "{$arguments[0]}"]); + $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween('-30 years', $endDate)->format('Y-m-d'); + break; + case 'starts_with': + $parameterData['description'] .= ' Must start with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments); + $parameterData['setter'] = fn() => $this->getFaker()->lexify("{$arguments[0]}????");; + break; + case 'ends_with': + $parameterData['description'] .= ' Must end with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments); + $parameterData['setter'] = fn() => $this->getFaker()->lexify("????{$arguments[0]}");; + break; + case 'uuid': + $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' '; + $parameterData['setter'] = $this->getFakeFactoryByName('uuid'); + break; + case 'regex': + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':regex' => $arguments[0]]); + $parameterData['setter'] = fn() => $this->getFaker()->regexify($arguments[0]);; + break; + + /** + * Special number types. + */ + case 'digits': + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':digits' => $arguments[0]]); + $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", $arguments[0])); + $parameterData['type'] = 'string'; + break; + case 'digits_between': + $parameterData['description'] .= ' ' . $this->getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]]); + $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", rand($arguments[0], $arguments[1]))); + $parameterData['type'] = 'string'; + break; + + /** + * These rules can apply to numbers, strings, arrays or files + */ + case 'size': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':size' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) + ); + $parameterData['setter'] = $this->getDummyValueGenerator($parameterData['type'], ['size' => $arguments[0]]); + break; + case 'min': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':min' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) + ); + $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), fieldName: $parameterData['name']); + break; + case 'max': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':max' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) + ); + $max = min($arguments[0], 25); + $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], 1, $max, $parameterData['name']); + break; + case 'between': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':min' => $arguments[0], ':max' => $arguments[1]], $this->getLaravelValidationBaseTypeMapping($parameterData['type']) + ); + // Avoid exponentially complex operations by using the minimum length + $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), floatval($arguments[0]) + 1, $parameterData['name']); + break; + + /** + * Special file types. + */ + case 'image': + $parameterData['type'] = 'file'; + $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' '; + $parameterData['setter'] = function () { + // This is fine because the file example generator generates an image + return $this->generateDummyValue('file'); + }; + break; + + /** + * Other rules. + */ + case 'in': + // Not using the rule description here because it only says "The attribute is invalid" + $parameterData['description'] .= ' Must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; + $parameterData['setter'] = function () use ($arguments) { + return Arr::random($arguments); + }; + break; + + /** + * These rules only add a description. Generating valid examples is too complex. + */ + case 'not_in': + $parameterData['description'] .= ' Must not be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; + break; + case 'required_if': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':other' => "{$arguments[0]}", ':value' => w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1))] + ) . ' '; + break; + case 'required_unless': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':other' => "" . array_shift($arguments) . "", ':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] + ) . ' '; + break; + case 'required_with': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] + ) . ' '; + break; + case 'required_without': + $description = $this->getDescription( + $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)] + ) . ' '; + $parameterData['description'] .= str_replace('must be present', 'is not present', $description); + break; + case 'required_with_all': + case 'required_without_all': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments, "and")] + ) . ' '; + break; + case 'accepted_if': + $parameterData['type'] = 'boolean'; + $parameterData['description'] .= " Must be accepted when $arguments[0] is " . w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1)); + $parameterData['setter'] = fn() => true; + break; + case 'same': + case 'different': + $parameterData['description'] .= ' ' . $this->getDescription( + $rule, [':other' => "{$arguments[0]}"] + ) . ' '; + break; + + default: + // Other rules not supported + break; } + } catch (Throwable $e) { + throw CouldntProcessValidationRule::forParam($parameterData['name'], $rule, $e); } return true;