ParsesValidationRules.php 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. <?php
  2. namespace Knuckles\Scribe\Extracting;
  3. use Illuminate\Contracts\Validation\Rule;
  4. use Illuminate\Support\Arr;
  5. use Illuminate\Support\Facades\Validator;
  6. use Illuminate\Support\Str;
  7. use Illuminate\Validation\ClosureValidationRule;
  8. use Knuckles\Scribe\Exceptions\CouldntProcessValidationRule;
  9. use Knuckles\Scribe\Exceptions\ProblemParsingValidationRules;
  10. use Knuckles\Scribe\Exceptions\ScribeException;
  11. use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
  12. use Knuckles\Scribe\Tools\WritingUtils as w;
  13. use ReflectionClass;
  14. use Throwable;
  15. trait ParsesValidationRules
  16. {
  17. use ParamHelpers;
  18. public static \stdClass $MISSING_VALUE;
  19. public function getParametersFromValidationRules(array $validationRules, array $customParameterData = []): array
  20. {
  21. self::$MISSING_VALUE = new \stdClass();
  22. $validationRules = $this->normaliseRules($validationRules);
  23. $parameters = [];
  24. $dependentRules = [];
  25. foreach ($validationRules as $parameter => $ruleset) {
  26. try {
  27. if (count($customParameterData) && !isset($customParameterData[$parameter])) {
  28. c::debug($this->getMissingCustomDataMessage($parameter));
  29. }
  30. $userSpecifiedParameterInfo = $customParameterData[$parameter] ?? [];
  31. // Make sure the user-specified description comes first (and add full stops where needed).
  32. $description = $userSpecifiedParameterInfo['description'] ?? '';
  33. if (!empty($description) && !Str::endsWith($description, '.')) {
  34. $description .= '.';
  35. }
  36. $parameterData = [
  37. 'name' => $parameter,
  38. 'required' => false,
  39. 'type' => null,
  40. 'example' => self::$MISSING_VALUE,
  41. 'description' => $description,
  42. ];
  43. $dependentRules[$parameter] = [];
  44. // First, parse only "independent" rules
  45. foreach ($ruleset as $rule) {
  46. $parsed = $this->parseRule($rule, $parameterData, true);
  47. if (!$parsed) {
  48. $dependentRules[$parameter][] = $rule;
  49. }
  50. }
  51. $parameterData['description'] = trim($parameterData['description']);
  52. // Set a default type
  53. if (is_null($parameterData['type'])) {
  54. $parameterData['type'] = 'string';
  55. }
  56. $parameterData['name'] = $parameter;
  57. $parameters[$parameter] = $parameterData;
  58. } catch (Throwable $e) {
  59. if ($e instanceof ScribeException) {
  60. // This is a lower-level error that we've encountered and wrapped;
  61. // Pass it on to the user.
  62. throw $e;
  63. }
  64. throw ProblemParsingValidationRules::forParam($parameter, $e);
  65. }
  66. }
  67. // Now parse any "dependent" rules and set examples. At this point, we should know all field's types.
  68. foreach ($dependentRules as $parameter => $ruleset) {
  69. try {
  70. $parameterData = $parameters[$parameter];
  71. $userSpecifiedParameterInfo = $customParameterData[$parameter] ?? [];
  72. foreach ($ruleset as $rule) {
  73. $this->parseRule($rule, $parameterData, false, $parameters);
  74. }
  75. // Make sure the user-specified example overwrites others.
  76. if (isset($userSpecifiedParameterInfo['example'])) {
  77. if ($this->shouldCastUserExample()) {
  78. // Examples in comments are strings, we need to cast them properly
  79. $parameterData['example'] = $this->castToType($userSpecifiedParameterInfo['example'], $parameterData['type'] ?? 'string');
  80. } else {
  81. $parameterData['example'] = $userSpecifiedParameterInfo['example'];
  82. }
  83. }
  84. // End descriptions with a full stop
  85. $parameterData['description'] = trim($parameterData['description']);
  86. if (!empty($parameterData['description']) && !Str::endsWith($parameterData['description'], '.')) {
  87. $parameterData['description'] .= '.';
  88. }
  89. $parameters[$parameter] = $parameterData;
  90. } catch (Throwable $e) {
  91. if ($e instanceof ScribeException) {
  92. // This is a lower-level error that we've encountered and wrapped;
  93. // Pass it on to the user.
  94. throw $e;
  95. }
  96. throw ProblemParsingValidationRules::forParam($parameter, $e);
  97. }
  98. }
  99. return $parameters;
  100. }
  101. /**
  102. * Transform validation rules from:
  103. * 'param1' => 'int|required' TO 'param1' => ['int', 'required']
  104. *
  105. * @param array<string,string|string[]> $rules
  106. *
  107. * @return array
  108. */
  109. protected function normaliseRules(array $rules): array
  110. {
  111. // We can simply call Validator::make($data, $rules)->getRules() to get the normalised rules,
  112. // but Laravel will ignore any nested array rules (`ids.*')
  113. // unless the key referenced (`ids`) exists in the dataset and is a non-empty array
  114. // So we'll create a single-item array for each array parameter
  115. $testData = [];
  116. foreach ($rules as $key => $ruleset) {
  117. if (!Str::contains($key, '.*')) continue;
  118. // All we need is for Laravel to see this key exists
  119. Arr::set($testData, str_replace('.*', '.0', $key), Str::random());
  120. }
  121. // Now this will return the complete ruleset.
  122. // Nested array parameters will be present, with '*' replaced by '0'
  123. $newRules = Validator::make($testData, $rules)->getRules();
  124. // Transform the key names back from 'ids.0' to 'ids.*'
  125. return collect($newRules)->mapWithKeys(function ($val, $paramName) use ($rules) {
  126. if (Str::contains($paramName, '.0')) {
  127. $genericArrayKeyName = str_replace('.0', '.*', $paramName);
  128. // But only if that was the original value
  129. if (isset($rules[$genericArrayKeyName])) {
  130. $paramName = $genericArrayKeyName;
  131. }
  132. }
  133. return [$paramName => $val];
  134. })->toArray();
  135. }
  136. /**
  137. * Parse a validation rule and extract a parameter type, description and setter (used to generate an example).
  138. *
  139. * If $independentOnly is true, only independent rules will be parsed.
  140. * If a rule depends on another parameter (eg gt:field) or needs to know the type of the parameter first (eg:
  141. * size:34), it will return false.
  142. */
  143. protected function parseRule($rule, array &$parameterData, bool $independentOnly, array $allParameters = []): bool
  144. {
  145. // Reminders:
  146. // 1. Append to the description (with a leading space); don't overwrite.
  147. // 2. Avoid testing on the value of $parameterData['type'],
  148. // as that may not have been set yet, since the rules can be in any order.
  149. // For this reason, only deterministic rules are supported
  150. // 3. All rules supported must be rules that we can generate a valid dummy value for.
  151. if ($rule instanceof ClosureValidationRule || $rule instanceof \Closure) {
  152. $reflection = new \ReflectionFunction($rule instanceof ClosureValidationRule ? $rule->callback : $rule);
  153. if (is_string($description = $reflection->getDocComment())) {
  154. $finalDescription = '';
  155. // Cleanup comment block and extract just the description
  156. foreach (explode("\n", $description) as $line) {
  157. $cleaned = preg_replace(['/\*+\/$/', '/^\/\*+\s*/', '/^\*+\s*/'], '', trim($line));
  158. if ($cleaned != '') $finalDescription .= ' ' . $cleaned;
  159. }
  160. $parameterData['description'] .= $finalDescription;
  161. }
  162. return true;
  163. }
  164. if ($rule instanceof Rule) {
  165. if (method_exists($rule, 'docs')) {
  166. $customData = call_user_func_array([$rule, 'docs'], []) ?: [];
  167. if (isset($customData['description'])) {
  168. $parameterData['description'] .= ' ' . $customData['description'];
  169. }
  170. if (isset($customData['example'])) {
  171. $parameterData['setter'] = fn() => $customData['example'];
  172. } elseif (isset($customData['setter'])) {
  173. $parameterData['setter'] = $customData['setter'];
  174. }
  175. $parameterData = array_merge($parameterData, Arr::except($customData, [
  176. 'description', 'example', 'setter',
  177. ]));
  178. }
  179. return true;
  180. }
  181. if (is_string($rule)) {
  182. try {
  183. // Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]])
  184. $parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule);
  185. [$rule, $arguments] = $parsedRule;
  186. $dependentRules = ['between', 'max', 'min', 'size', 'gt', 'gte', 'lt', 'lte', 'before', 'after', 'before_or_equal', 'after_or_equal'];
  187. if ($independentOnly && in_array($rule, $dependentRules)) {
  188. return false;
  189. }
  190. switch ($rule) {
  191. case 'required':
  192. $parameterData['required'] = true;
  193. break;
  194. case 'accepted':
  195. $parameterData['required'] = true;
  196. $parameterData['type'] = 'boolean';
  197. $parameterData['description'] .= ' Must be accepted.';
  198. $parameterData['setter'] = fn() => true;
  199. break;
  200. /*
  201. * Primitive types. No description should be added
  202. */
  203. case 'bool':
  204. case 'boolean':
  205. $parameterData['setter'] = function () {
  206. return Arr::random([true, false]);
  207. };
  208. $parameterData['type'] = 'boolean';
  209. break;
  210. case 'string':
  211. $parameterData['setter'] = function () use ($parameterData) {
  212. return $this->generateDummyValue('string', ['name' => $parameterData['name']]);
  213. };
  214. $parameterData['type'] = 'string';
  215. break;
  216. case 'int':
  217. case 'integer':
  218. $parameterData['setter'] = function () {
  219. return $this->generateDummyValue('integer');
  220. };
  221. $parameterData['type'] = 'integer';
  222. break;
  223. case 'numeric':
  224. $parameterData['setter'] = function () {
  225. return $this->generateDummyValue('number');
  226. };
  227. $parameterData['type'] = 'number';
  228. break;
  229. case 'array':
  230. $parameterData['setter'] = function () {
  231. return [$this->generateDummyValue('string')];
  232. };
  233. $parameterData['type'] = 'array'; // The cleanup code in normaliseArrayAndObjectParameters() will set this to a valid type (x[] or object)
  234. break;
  235. case 'file':
  236. $parameterData['type'] = 'file';
  237. $parameterData['description'] .= ' Must be a file.';
  238. $parameterData['setter'] = function () {
  239. return $this->generateDummyValue('file');
  240. };
  241. break;
  242. /**
  243. * Special string types
  244. */
  245. case 'alpha':
  246. $parameterData['description'] .= " Must contain only letters.";
  247. $parameterData['setter'] = function () {
  248. return $this->getFaker()->lexify('??????');
  249. };
  250. break;
  251. case 'alpha_dash':
  252. $parameterData['description'] .= " Must contain only letters, numbers, dashes and underscores.";
  253. $parameterData['setter'] = function () {
  254. return $this->getFaker()->lexify('???-???_?');
  255. };
  256. break;
  257. case 'alpha_num':
  258. $parameterData['description'] .= " Must contain only letters and numbers.";
  259. $parameterData['setter'] = function () {
  260. return $this->getFaker()->bothify('#?#???#');
  261. };
  262. break;
  263. case 'timezone':
  264. // Laravel's message merely says "The value must be a valid zone"
  265. $parameterData['description'] .= " Must be a valid time zone, such as <code>Africa/Accra</code>.";
  266. $parameterData['setter'] = $this->getFakeFactoryByName('timezone');
  267. break;
  268. case 'email':
  269. $parameterData['description'] .= ' ' . $this->getDescription($rule);
  270. $parameterData['setter'] = $this->getFakeFactoryByName('email');
  271. $parameterData['type'] = 'string';
  272. break;
  273. case 'url':
  274. $parameterData['setter'] = $this->getFakeFactoryByName('url');
  275. $parameterData['type'] = 'string';
  276. // Laravel's message is "The value format is invalid". Ugh.🤮
  277. $parameterData['description'] .= " Must be a valid URL.";
  278. break;
  279. case 'ip':
  280. $parameterData['description'] .= ' ' . $this->getDescription($rule);
  281. $parameterData['type'] = 'string';
  282. $parameterData['setter'] = function () {
  283. return $this->getFaker()->ipv4();
  284. };
  285. break;
  286. case 'json':
  287. $parameterData['type'] = 'string';
  288. $parameterData['description'] .= ' ' . $this->getDescription($rule);
  289. $parameterData['setter'] = function () {
  290. return json_encode([$this->getFaker()->word(), $this->getFaker()->word(),]);
  291. };
  292. break;
  293. case 'date':
  294. $parameterData['type'] = 'string';
  295. $parameterData['description'] .= ' ' . $this->getDescription($rule);
  296. $parameterData['setter'] = fn() => date('Y-m-d\TH:i:s', time());
  297. break;
  298. case 'date_format':
  299. $parameterData['type'] = 'string';
  300. // Laravel description here is "The value must match the format Y-m-d". Not descriptive enough.
  301. $parameterData['description'] .= " Must be a valid date in the format <code>{$arguments[0]}</code>.";
  302. $parameterData['setter'] = function () use ($arguments) {
  303. return date($arguments[0], time());
  304. };
  305. break;
  306. case 'after':
  307. case 'after_or_equal':
  308. $parameterData['type'] = 'string';
  309. $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "<code>{$arguments[0]}</code>"]);
  310. // TODO introduce the concept of "modifiers", like date_format
  311. // The startDate may refer to another field, in which case, we just ignore it for now.
  312. $startDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0];
  313. $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween($startDate, '+100 years')->format('Y-m-d');
  314. break;
  315. case 'before':
  316. case 'before_or_equal':
  317. $parameterData['type'] = 'string';
  318. // The argument can be either another field or a date
  319. // The endDate may refer to another field, in which case, we just ignore it for now.
  320. $endDate = isset($allParameters[$arguments[0]]) ? 'today' : $arguments[0];
  321. $parameterData['description'] .= ' ' . $this->getDescription($rule, [':date' => "<code>{$arguments[0]}</code>"]);
  322. $parameterData['setter'] = fn() => $this->getFaker()->dateTimeBetween('-30 years', $endDate)->format('Y-m-d');
  323. break;
  324. case 'starts_with':
  325. $parameterData['description'] .= ' Must start with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments);
  326. $parameterData['setter'] = fn() => $this->getFaker()->lexify("{$arguments[0]}????");;
  327. break;
  328. case 'ends_with':
  329. $parameterData['description'] .= ' Must end with one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments);
  330. $parameterData['setter'] = fn() => $this->getFaker()->lexify("????{$arguments[0]}");;
  331. break;
  332. case 'uuid':
  333. $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' ';
  334. $parameterData['setter'] = $this->getFakeFactoryByName('uuid');
  335. break;
  336. case 'regex':
  337. $parameterData['description'] .= ' ' . $this->getDescription($rule, [':regex' => $arguments[0]]);
  338. $parameterData['setter'] = fn() => $this->getFaker()->regexify($arguments[0]);;
  339. break;
  340. /**
  341. * Special number types.
  342. */
  343. case 'digits':
  344. $parameterData['description'] .= ' ' . $this->getDescription($rule, [':digits' => $arguments[0]]);
  345. $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", $arguments[0]));
  346. $parameterData['type'] = 'string';
  347. break;
  348. case 'digits_between':
  349. $parameterData['description'] .= ' ' . $this->getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]]);
  350. $parameterData['setter'] = fn() => $this->getFaker()->numerify(str_repeat("#", rand($arguments[0], $arguments[1])));
  351. $parameterData['type'] = 'string';
  352. break;
  353. /**
  354. * These rules can apply to numbers, strings, arrays or files
  355. */
  356. case 'size':
  357. $parameterData['description'] .= ' ' . $this->getDescription(
  358. $rule, [':size' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
  359. );
  360. $parameterData['setter'] = $this->getDummyValueGenerator($parameterData['type'], ['size' => $arguments[0]]);
  361. break;
  362. case 'min':
  363. $parameterData['description'] .= ' ' . $this->getDescription(
  364. $rule, [':min' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
  365. );
  366. $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), fieldName: $parameterData['name']);
  367. break;
  368. case 'max':
  369. $parameterData['description'] .= ' ' . $this->getDescription(
  370. $rule, [':max' => $arguments[0]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
  371. );
  372. $max = min($arguments[0], 25);
  373. $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], 1, $max, $parameterData['name']);
  374. break;
  375. case 'between':
  376. $parameterData['description'] .= ' ' . $this->getDescription(
  377. $rule, [':min' => $arguments[0], ':max' => $arguments[1]], $this->getLaravelValidationBaseTypeMapping($parameterData['type'])
  378. );
  379. // Avoid exponentially complex operations by using the minimum length
  380. $parameterData['setter'] = $this->getDummyDataGeneratorBetween($parameterData['type'], floatval($arguments[0]), floatval($arguments[0]) + 1, $parameterData['name']);
  381. break;
  382. /**
  383. * Special file types.
  384. */
  385. case 'image':
  386. $parameterData['type'] = 'file';
  387. $parameterData['description'] .= ' ' . $this->getDescription($rule) . ' ';
  388. $parameterData['setter'] = function () {
  389. // This is fine because the file example generator generates an image
  390. return $this->generateDummyValue('file');
  391. };
  392. break;
  393. /**
  394. * Other rules.
  395. */
  396. case 'in':
  397. // Not using the rule description here because it only says "The attribute is invalid"
  398. $parameterData['description'] .= ' Must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' ';
  399. $parameterData['setter'] = function () use ($arguments) {
  400. return Arr::random($arguments);
  401. };
  402. break;
  403. /**
  404. * These rules only add a description. Generating valid examples is too complex.
  405. */
  406. case 'not_in':
  407. $parameterData['description'] .= ' Must not be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' ';
  408. break;
  409. case 'required_if':
  410. $parameterData['description'] .= ' ' . $this->getDescription(
  411. $rule, [':other' => "<code>{$arguments[0]}</code>", ':value' => w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1))]
  412. ) . ' ';
  413. break;
  414. case 'required_unless':
  415. $parameterData['description'] .= ' ' . $this->getDescription(
  416. $rule, [':other' => "<code>" . array_shift($arguments) . "</code>", ':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)]
  417. ) . ' ';
  418. break;
  419. case 'required_with':
  420. $parameterData['description'] .= ' ' . $this->getDescription(
  421. $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)]
  422. ) . ' ';
  423. break;
  424. case 'required_without':
  425. $description = $this->getDescription(
  426. $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments)]
  427. ) . ' ';
  428. $parameterData['description'] .= str_replace('must be present', 'is not present', $description);
  429. break;
  430. case 'required_with_all':
  431. case 'required_without_all':
  432. $parameterData['description'] .= ' ' . $this->getDescription(
  433. $rule, [':values' => w::getListOfValuesAsFriendlyHtmlString($arguments, "and")]
  434. ) . ' ';
  435. break;
  436. case 'accepted_if':
  437. $parameterData['type'] = 'boolean';
  438. $parameterData['description'] .= " Must be accepted when <code>$arguments[0]</code> is " . w::getListOfValuesAsFriendlyHtmlString(array_slice($arguments, 1));
  439. $parameterData['setter'] = fn() => true;
  440. break;
  441. case 'same':
  442. case 'different':
  443. $parameterData['description'] .= ' ' . $this->getDescription(
  444. $rule, [':other' => "<code>{$arguments[0]}</code>"]
  445. ) . ' ';
  446. break;
  447. default:
  448. // Other rules not supported
  449. break;
  450. }
  451. } catch (Throwable $e) {
  452. throw CouldntProcessValidationRule::forParam($parameterData['name'], $rule, $e);
  453. }
  454. }
  455. return true;
  456. }
  457. /**
  458. * Parse a string rule into the base rule and arguments.
  459. * Laravel validation rules are specified in the format {rule}:{arguments}
  460. * Arguments are separated by commas.
  461. * For instance the rule "max:3" states that the value may only be three letters.
  462. *
  463. * @param string|Rule $rule
  464. *
  465. * @return array
  466. */
  467. protected function parseStringRuleIntoRuleAndArguments($rule): array
  468. {
  469. $ruleArguments = [];
  470. if (strpos($rule, ':') !== false) {
  471. [$rule, $argumentsString] = explode(':', $rule, 2);
  472. // These rules can have commas in their arguments, so we don't split on commas
  473. if (in_array(strtolower($rule), ['regex', 'date', 'date_format'])) {
  474. $ruleArguments = [$argumentsString];
  475. } else {
  476. $ruleArguments = str_getcsv($argumentsString);
  477. }
  478. }
  479. return [strtolower(trim($rule)), $ruleArguments];
  480. }
  481. protected function getParameterExample(array $parameterData)
  482. {
  483. // If no example was given by the user, set an autogenerated example.
  484. // Each parsed rule returns a 'setter' function. We'll evaluate the last one.
  485. if ($parameterData['example'] === self::$MISSING_VALUE) {
  486. if (isset($parameterData['setter'])) {
  487. return $parameterData['setter']();
  488. } else {
  489. return $parameterData['required']
  490. ? $this->generateDummyValue($parameterData['type'])
  491. : null;
  492. }
  493. } else if (!is_null($parameterData['example']) && $parameterData['example'] !== self::$MISSING_VALUE) {
  494. if($parameterData['example'] === 'No-example' && !$parameterData['required']){
  495. return null;
  496. }
  497. // Casting again is important since values may have been cast to string in the validator
  498. return $this->castToType($parameterData['example'], $parameterData['type']);
  499. }
  500. return $parameterData['example'] === self::$MISSING_VALUE ? null : $parameterData['example'];
  501. }
  502. /**
  503. * Laravel uses .* notation for arrays. This PR aims to normalise that into our "new syntax".
  504. *
  505. * 'years.*' with type 'integer' becomes 'years' with type 'integer[]'
  506. * 'cars.*.age' with type 'string' becomes 'cars[].age' with type 'string' and 'cars' with type 'object[]'
  507. * 'cars.*.things.*.*' with type 'string' becomes 'cars[].things' with type 'string[][]' and 'cars' with type
  508. * 'object[]'
  509. *
  510. * Additionally, if the user declared a subfield but not the parent, we create a parameter for the parent.
  511. *
  512. * @param array[] $parametersFromValidationRules
  513. *
  514. * @return array
  515. */
  516. public function normaliseArrayAndObjectParameters(array $parametersFromValidationRules): array
  517. {
  518. // Convert any `array` types into concrete types like `object[]`, object, or `string[]`
  519. $parameters = $this->convertGenericArrayType($parametersFromValidationRules);
  520. // Change cars.*.dogs.things.*.* with type X to cars.*.dogs.things with type X[][]
  521. $parameters = $this->convertArraySubfields($parameters);
  522. // Add the fields `cars.*.dogs` and `cars` if they don't exist
  523. $parameters = $this->addMissingParentFields($parameters);
  524. return $this->setExamples($parameters);
  525. }
  526. public function convertGenericArrayType(array $parameters): array
  527. {
  528. $converted = [];
  529. $allKeys = array_keys($parameters);
  530. foreach (array_reverse($parameters) as $name => $details) {
  531. if ($details['type'] === 'array') {
  532. // This is a parent field, a generic array type. Scribe only supports concrete array types (T[]),
  533. // so we convert this to the correct type (such as object or object[])
  534. // Supposing current key is "users", with type "array". To fix this:
  535. // 1. If `users.*` or `users.*.thing` exists, `users` is an `X[]` (where X is the type of `users.*`
  536. // 2. If `users.<name>` exists, `users` is an `object`
  537. // 3. Otherwise, default to `object`
  538. // Important: We're iterating in reverse, to ensure we set child items before parent items
  539. // (assuming the user specified parents first, which is the more common thing)
  540. if ($childKey = Arr::first($allKeys, fn($key) => Str::startsWith($key, "$name.*"))) {
  541. $childType = ($converted[$childKey] ?? $parameters[$childKey])['type'];
  542. $details['type'] = "{$childType}[]";
  543. } else { // `array` types default to `object` if no subtype is specified
  544. $details['type'] = 'object';
  545. unset($details['setter']);
  546. }
  547. }
  548. $converted[$name] = $details;
  549. }
  550. // Re-add items in the original order, so as to not cause side effects
  551. foreach ($allKeys as $key) {
  552. $parameters[$key] = $converted[$key] ?? $parameters[$key];
  553. }
  554. return $parameters;
  555. }
  556. public function convertArraySubfields(array $parameters): array
  557. {
  558. $results = [];
  559. foreach ($parameters as $name => $details) {
  560. if (Str::endsWith($name, '.*')) {
  561. // The user might have set the example via bodyParameters()
  562. $hasExample = $this->examplePresent($details);
  563. // Change cars.*.dogs.things.*.* with type X to cars.*.dogs.things with type X[][]
  564. while (Str::endsWith($name, '.*')) {
  565. $details['type'] .= '[]';
  566. $name = substr($name, 0, -2);
  567. if ($hasExample) {
  568. $details['example'] = [$details['example']];
  569. } else if (isset($details['setter'])) {
  570. $previousSetter = $details['setter'];
  571. $details['setter'] = fn() => [$previousSetter()];
  572. }
  573. }
  574. }
  575. $results[$name] = $details;
  576. }
  577. return $results;
  578. }
  579. public function setExamples(array $parameters): array
  580. {
  581. $examples = [];
  582. foreach ($parameters as $name => $details) {
  583. if ($this->examplePresent($details)) {
  584. // Record already-present examples (eg from bodyParameters()).
  585. // This allows a user to set 'data' => ['example' => ['title' => 'A title'],
  586. // and we automatically set this as the example for `data.title`
  587. // Note that this approach assumes parent fields are listed before the children; meh.
  588. $examples[$details['name']] = $details['example'];
  589. } elseif (preg_match('/.+\.[^*]+$/', $details['name'])) {
  590. // For object fields (eg 'data.details.title'), set examples from their parents if present as described above.
  591. [$parentName, $fieldName] = preg_split('/\.(?=[\w-]+$)/', $details['name']);
  592. if (array_key_exists($parentName, $examples) && is_array($examples[$parentName])
  593. && array_key_exists($fieldName, $examples[$parentName])) {
  594. $examples[$details['name']] = $details['example'] = $examples[$parentName][$fieldName];
  595. }
  596. }
  597. $details['example'] = $this->getParameterExample($details);
  598. unset($details['setter']);
  599. $parameters[$name] = $details;
  600. }
  601. return $parameters;
  602. }
  603. protected function addMissingParentFields(array $parameters): array
  604. {
  605. $results = [];
  606. foreach ($parameters as $name => $details) {
  607. if (isset($results[$name])) {
  608. continue;
  609. }
  610. $parentPath = $name;
  611. while (Str::contains($parentPath, '.')) {
  612. $parentPath = preg_replace('/\.[^.]+$/', '', $parentPath);
  613. $normalisedParentPath = str_replace('.*.', '[].', $parentPath);
  614. if (empty($results[$normalisedParentPath])) {
  615. // Parent field doesn't exist, create it.
  616. if (Str::endsWith($parentPath, '.*')) {
  617. $parentPath = substr($parentPath, 0, -2);
  618. $normalisedParentPath = str_replace('.*.', '[].', $parentPath);
  619. if (!empty($results[$normalisedParentPath])) {
  620. break;
  621. }
  622. $type = 'object[]';
  623. $example = [[]];
  624. } else {
  625. $type = 'object';
  626. $example = [];
  627. }
  628. $results[$normalisedParentPath] = [
  629. 'name' => $normalisedParentPath,
  630. 'type' => $type,
  631. 'required' => false,
  632. 'description' => '',
  633. 'example' => $example,
  634. ];
  635. }
  636. }
  637. $details['name'] = $name = str_replace('.*.', '[].', $name);
  638. if (isset($parameters[$details['name']]) && $this->examplePresent($parameters[$details['name']])) {
  639. $details['example'] = $parameters[$details['name']]['example'];
  640. }
  641. $results[$name] = $details;
  642. }
  643. return $results;
  644. }
  645. private function examplePresent(array $parameterData)
  646. {
  647. return isset($parameterData['example']) && $parameterData['example'] !== self::$MISSING_VALUE;
  648. }
  649. protected function getDescription(string $rule, array $arguments = [], $baseType = 'string'): string
  650. {
  651. if ($rule == 'regex') {
  652. return "Must match the regex {$arguments[':regex']}.";
  653. }
  654. $description = trans("validation.{$rule}");
  655. // For rules that can apply to multiple types (eg 'max' rule), Laravel returns an array of possible messages
  656. // 'numeric' => 'The :attribute must not be greater than :max'
  657. // 'file' => 'The :attribute must have a size less than :max kilobytes'
  658. if (is_array($description)) {
  659. $description = $description[$baseType];
  660. }
  661. // Convert messages from failure type ("The :attribute is not a valid date.") to info ("The :attribute must be a valid date.")
  662. $description = str_replace(['is not', 'does not'], ['must be', 'must'], $description);
  663. $description = str_replace('may not', 'must not', $description);
  664. foreach ($arguments as $placeholder => $argument) {
  665. $description = str_replace($placeholder, $argument, $description);
  666. }
  667. // For rules that validate subfields
  668. $description = str_replace("The :attribute field ", "This field ", $description);
  669. return str_replace(
  670. [" :attribute ", "The value must ", " 1 characters", " 1 digits", " 1 kilobytes"],
  671. [" value ", "Must ", " 1 character", " 1 digit", " 1 kilobyte"],
  672. $description
  673. );
  674. }
  675. private function getLaravelValidationBaseTypeMapping(string $parameterType): string
  676. {
  677. $mapping = [
  678. 'number' => 'numeric',
  679. 'integer' => 'numeric',
  680. 'file' => 'file',
  681. 'string' => 'string',
  682. 'array' => 'array',
  683. ];
  684. if (Str::endsWith($parameterType, '[]')) {
  685. return 'array';
  686. }
  687. return $mapping[$parameterType] ?? 'string';
  688. }
  689. protected function getMissingCustomDataMessage($parameterName)
  690. {
  691. return "";
  692. }
  693. protected function shouldCastUserExample()
  694. {
  695. return false;
  696. }
  697. }