GetFromFormRequest.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. <?php
  2. namespace Knuckles\Scribe\Extracting\Strategies\BodyParameters;
  3. use Dingo\Api\Http\FormRequest as DingoFormRequest;
  4. use Illuminate\Foundation\Http\FormRequest as LaravelFormRequest;
  5. use Illuminate\Routing\Route;
  6. use Illuminate\Support\Arr;
  7. use Illuminate\Support\Facades\Validator;
  8. use Illuminate\Support\Str;
  9. use Illuminate\Contracts\Validation\Rule;
  10. use Knuckles\Scribe\Extracting\ParamHelpers;
  11. use Knuckles\Scribe\Extracting\Strategies\Strategy;
  12. use Knuckles\Scribe\Extracting\ValidationRuleDescriptionParser as d;
  13. use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
  14. use Knuckles\Scribe\Tools\WritingUtils as w;
  15. use ReflectionClass;
  16. use ReflectionException;
  17. use ReflectionFunctionAbstract;
  18. use Illuminate\Contracts\Validation\Factory as ValidationFactory;
  19. class GetFromFormRequest extends Strategy
  20. {
  21. public $stage = 'bodyParameters';
  22. public static $MISSING_VALUE;
  23. use ParamHelpers;
  24. public function __invoke(Route $route, ReflectionClass $controller, ReflectionFunctionAbstract $method, array $routeRules, array $context = []): array
  25. {
  26. return $this->getBodyParametersFromFormRequest($method);
  27. }
  28. public function getBodyParametersFromFormRequest(ReflectionFunctionAbstract $method): array
  29. {
  30. foreach ($method->getParameters() as $param) {
  31. $paramType = $param->getType();
  32. if ($paramType === null) {
  33. continue;
  34. }
  35. $parameterClassName = $paramType->getName();
  36. try {
  37. $parameterClass = new ReflectionClass($parameterClassName);
  38. } catch (ReflectionException $e) {
  39. dump($e->getMessage());
  40. continue;
  41. }
  42. // If there's a FormRequest, we check there for @bodyParam tags.
  43. if (
  44. (class_exists(LaravelFormRequest::class) && $parameterClass->isSubclassOf(LaravelFormRequest::class))
  45. || (class_exists(DingoFormRequest::class) && $parameterClass->isSubclassOf(DingoFormRequest::class))) {
  46. /** @var LaravelFormRequest|DingoFormRequest\ $formRequest */
  47. $formRequest = new $parameterClassName;
  48. $bodyParametersFromFormRequest = $this->getBodyParametersFromValidationRules(
  49. $this->getRouteValidationRules($formRequest),
  50. $this->getCustomParameterData($formRequest)
  51. );
  52. return $bodyParametersFromFormRequest;
  53. }
  54. }
  55. return [];
  56. }
  57. /**
  58. * @param LaravelFormRequest|DingoFormRequest $formRequest
  59. *
  60. * @return mixed
  61. */
  62. protected function getRouteValidationRules($formRequest)
  63. {
  64. if (method_exists($formRequest, 'validator')) {
  65. $validationFactory = app(ValidationFactory::class);
  66. return call_user_func_array([$formRequest, 'validator'], [$validationFactory])
  67. ->getRules();
  68. } else {
  69. return call_user_func_array([$formRequest, 'rules'], []);
  70. }
  71. }
  72. /**
  73. * @param LaravelFormRequest|DingoFormRequest $formRequest
  74. */
  75. protected function getCustomParameterData($formRequest)
  76. {
  77. if (method_exists($formRequest, 'bodyParameters')) {
  78. return call_user_func_array([$formRequest, 'bodyParameters'], []);
  79. }
  80. c::warn("No bodyParameters() method found in " . get_class($formRequest) . " Scribe will only be able to extract basic information from the rules() method.");
  81. return [];
  82. }
  83. public function getBodyParametersFromValidationRules(array $validationRules, array $customParameterData = [])
  84. {
  85. self::$MISSING_VALUE = new \stdClass();
  86. $rules = $this->normaliseRules($validationRules);
  87. $parameters = [];
  88. foreach ($rules as $parameter => $ruleset) {
  89. if (count($customParameterData) && !isset($customParameterData[$parameter])) {
  90. c::debug("No data found for parameter '$parameter' from your bodyParameters() method. Add an entry for '$parameter' so you can add description and example.");
  91. }
  92. $userSpecifiedParameterInfo = $customParameterData[$parameter] ?? [];
  93. $parameterData = [
  94. 'required' => false,
  95. 'type' => null,
  96. 'value' => self::$MISSING_VALUE,
  97. 'description' => '',
  98. ];
  99. // Make sure the user-specified example overwrites others.
  100. if (isset($userSpecifiedParameterInfo['example'])) {
  101. $parameterData['value'] = $userSpecifiedParameterInfo['example'];
  102. }
  103. foreach ($ruleset as $rule) {
  104. $this->parseRule($rule, $parameterData);
  105. }
  106. // Set autogenerated examples if none was supplied.
  107. // Each rule returns a 'setter' function, so we can lazily evaluate the last one only if we need it.
  108. if ($parameterData['value'] === self::$MISSING_VALUE && isset($parameterData['setter'])) {
  109. $parameterData['value'] = $parameterData['setter']();
  110. }
  111. // Make sure the user-specified description comes first.
  112. $userSpecifiedDescription = $userSpecifiedParameterInfo['description'] ?? '';
  113. $validationDescription = trim($parameterData['description'] ?: '');
  114. $fullDescription = trim($userSpecifiedDescription . ' ' . trim($validationDescription));
  115. // Let's have our sentences end with full stops, like civilized people.🙂
  116. $parameterData['description'] = $fullDescription ? rtrim($fullDescription, '.') . '.' : $fullDescription;
  117. // Set default values for type
  118. if (is_null($parameterData['type'])) {
  119. $parameterData['type'] = 'string';
  120. }
  121. // Set values when parameter is required and has no value
  122. if ($parameterData['required'] === true && $parameterData['value'] === self::$MISSING_VALUE) {
  123. $parameterData['value'] = $this->generateDummyValue($parameterData['type']);
  124. }
  125. if (!is_null($parameterData['value']) && $parameterData['value'] !== self::$MISSING_VALUE) {
  126. // The cast is important since values may have been cast to string in the validator
  127. $parameterData['value'] = $this->castToType($parameterData['value'], $parameterData['type']);
  128. }
  129. $parameters[$parameter] = $parameterData;
  130. }
  131. return $parameters;
  132. }
  133. /**
  134. * This method will transform validation rules from:
  135. * 'param1' => 'int|required' TO 'param1' => ['int', 'required']
  136. *
  137. * @param array<string,string|string[]> $rules
  138. *
  139. * @return mixed
  140. */
  141. protected function normaliseRules(array $rules)
  142. {
  143. // We can simply call Validator::make($data, $rules)->getRules() to get the normalised rules,
  144. // but Laravel will ignore any nested array rules (`ids.*')
  145. // unless the key referenced (`ids`) exists in the dataset and is a non-empty array
  146. // So we'll create a single-item array for each array parameter
  147. $values = collect($rules)
  148. ->filter(function ($value, $key) {
  149. return Str::contains($key, '.*');
  150. })->mapWithKeys(function ($value, $key) {
  151. if (Str::endsWith($key, '.*')) {
  152. // We're dealing with a simple array of primitives
  153. return [Str::substr($key, 0, -2) => [Str::random()]];
  154. } elseif (Str::contains($key, '.*.')) {
  155. // We're dealing with an array of objects
  156. [$key, $property] = explode('.*.', $key);
  157. // Even though this will be overwritten by another property declaration in the rules, we're fine.
  158. // All we need is for Laravel to see this key exists
  159. return [$key => [[$property => Str::random()]]];
  160. }
  161. })->all();
  162. // Now this will return the complete ruleset.
  163. // Nested array parameters will be present, with '*' replaced by '0'
  164. $newRules = Validator::make($values, $rules)->getRules();
  165. // Transform the key names back from 'ids.0' to 'ids.*'
  166. return collect($newRules)->mapWithKeys(function ($val, $paramName) use ($rules) {
  167. if (Str::contains($paramName, '.0')) {
  168. $genericArrayKeyName = str_replace('.0', '.*', $paramName);
  169. // But only if that was the original value
  170. if (isset($rules[$genericArrayKeyName])) {
  171. $paramName = $genericArrayKeyName;
  172. }
  173. }
  174. return [$paramName => $val];
  175. })->toArray();
  176. }
  177. protected function parseRule($rule, &$parameterData)
  178. {
  179. $parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule);
  180. [$rule, $arguments] = $parsedRule;
  181. // Reminders:
  182. // 1. Append to the description (with a leading space); don't overwrite.
  183. // 2. Avoid testing on the value of $parameterData['type'],
  184. // as that may not have been set yet, since the rules can be in any order.
  185. // For this reason, only deterministic rules are supported
  186. // 3. All rules supported must be rules that we can generate a valid dummy value for.
  187. switch ($rule) {
  188. case 'required':
  189. $parameterData['required'] = true;
  190. break;
  191. /*
  192. * Primitive types. No description should be added
  193. */
  194. case 'bool':
  195. case 'boolean':
  196. $parameterData['setter'] = function () {
  197. return Arr::random([true, false]);
  198. };
  199. $parameterData['type'] = 'boolean';
  200. break;
  201. case 'string':
  202. $parameterData['setter'] = function () {
  203. return $this->generateDummyValue('string');
  204. };
  205. $parameterData['type'] = 'string';
  206. break;
  207. case 'int':
  208. case 'integer':
  209. $parameterData['setter'] = function () {
  210. return $this->generateDummyValue('integer');
  211. };
  212. $parameterData['type'] = 'integer';
  213. break;
  214. case 'numeric':
  215. $parameterData['setter'] = function () {
  216. return $this->generateDummyValue('number');
  217. };
  218. $parameterData['type'] = 'number';
  219. break;
  220. case 'array':
  221. $parameterData['setter'] = function () {
  222. return [$this->generateDummyValue('string')];
  223. };
  224. $parameterData['type'] = $rule;
  225. break;
  226. case 'file':
  227. $parameterData['type'] = 'file';
  228. $parameterData['description'] .= 'The value must be a file.';
  229. $parameterData['setter'] = function () {
  230. return $this->generateDummyValue('file');
  231. };
  232. break;
  233. /**
  234. * Special string types
  235. */
  236. case 'timezone':
  237. // Laravel's message merely says "The value must be a valid zone"
  238. $parameterData['description'] .= "The value must be a valid time zone, such as <code>Africa/Accra</code>. ";
  239. $parameterData['setter'] = function () {
  240. return $this->getFaker()->timezone;
  241. };
  242. break;
  243. case 'email':
  244. $parameterData['description'] .= d::getDescription($rule) . ' ';
  245. $parameterData['setter'] = function () {
  246. return $this->getFaker()->safeEmail;
  247. };
  248. $parameterData['type'] = 'string';
  249. break;
  250. case 'url':
  251. $parameterData['setter'] = function () {
  252. return $this->getFaker()->url;
  253. };
  254. $parameterData['type'] = 'string';
  255. // Laravel's message is "The value format is invalid". Ugh.🤮
  256. $parameterData['description'] .= "The value must be a valid URL. ";
  257. break;
  258. case 'ip':
  259. $parameterData['description'] .= d::getDescription($rule) . ' ';
  260. $parameterData['type'] = 'string';
  261. $parameterData['setter'] = function () {
  262. return $this->getFaker()->ipv4;
  263. };
  264. break;
  265. case 'json':
  266. $parameterData['type'] = 'string';
  267. $parameterData['description'] .= d::getDescription($rule) . ' ';
  268. $parameterData['setter'] = function () {
  269. return json_encode([$this->getFaker()->word, $this->getFaker()->word,]);
  270. };
  271. break;
  272. case 'date':
  273. $parameterData['type'] = 'string';
  274. $parameterData['description'] .= d::getDescription($rule) . ' ';
  275. $parameterData['setter'] = function () {
  276. return date(\DateTime::ISO8601, time());
  277. };
  278. break;
  279. case 'date_format':
  280. $parameterData['type'] = 'string';
  281. // Laravel description here is "The value must match the format Y-m-d". Not descriptive enough.
  282. $parameterData['description'] .= "The value must be a valid date in the format {$arguments[0]} ";
  283. $parameterData['setter'] = function () use ($arguments) {
  284. return date($arguments[0], time());
  285. };
  286. break;
  287. /**
  288. * Special number types. Some rules here may apply to other types, but we treat them as being numeric.
  289. *//*
  290. * min, max and between not supported until we can figure out a proper way
  291. * to make them compatible with multiple types (string, number, file)
  292. case 'min':
  293. $parameterData['type'] = $parameterData['type'] ?: 'number';
  294. $parameterData['description'] .= Description::getDescription($rule, [':min' => $arguments[0]], 'numeric').' ';
  295. $parameterData['setter'] = function () { return $this->getFaker()->numberBetween($arguments[0]); };
  296. break;
  297. case 'max':
  298. $parameterData['type'] = $parameterData['type'] ?: 'number';
  299. $parameterData['description'] .= Description::getDescription($rule, [':max' => $arguments[0]], 'numeric').' ';
  300. $parameterData['setter'] = function () { return $this->getFaker()->numberBetween(0, $arguments[0]); };
  301. break;
  302. case 'between':
  303. $parameterData['type'] = $parameterData['type'] ?: 'number';
  304. $parameterData['description'] .= Description::getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]], 'numeric').' ';
  305. $parameterData['setter'] = function () { return $this->getFaker()->numberBetween($arguments[0], $arguments[1]); };
  306. break;*/
  307. /**
  308. * Special file types.
  309. */
  310. case 'image':
  311. $parameterData['type'] = 'file';
  312. $parameterData['description'] .= d::getDescription($rule) . ' ';
  313. $parameterData['setter'] = function () {
  314. // This is fine because the file example generator generates an image
  315. return $this->generateDummyValue('file');
  316. };
  317. break;
  318. /**
  319. * Other rules.
  320. */
  321. case 'in':
  322. // Not using the rule description here because it only says "The attribute is invalid"
  323. $description = 'The value must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments);
  324. $parameterData['description'] .= $description . ' ';
  325. $parameterData['setter'] = function () use ($arguments) {
  326. return Arr::random($arguments);
  327. };
  328. break;
  329. default:
  330. // Other rules not supported
  331. break;
  332. }
  333. }
  334. /**
  335. * Parse a string rule into the base rule and arguments.
  336. * Laravel validation rules are specified in the format {rule}:{arguments}
  337. * Arguments are separated by commas.
  338. * For instance the rule "max:3" states that the value may only be three letters.
  339. *
  340. * @param string|Rule $rule
  341. *
  342. * @return array
  343. */
  344. protected function parseStringRuleIntoRuleAndArguments($rule)
  345. {
  346. $ruleArguments = [];
  347. // Convert any Rule objects to strings
  348. if ($rule instanceof Rule) {
  349. $className = substr(strrchr(get_class($rule), "\\"), 1);
  350. return [$className, []];
  351. }
  352. if (strpos($rule, ':') !== false) {
  353. [$rule, $argumentsString] = explode(':', $rule, 2);
  354. // These rules can have ommas in their arguments, so we don't split on commas
  355. if (in_array(strtolower($rule), ['regex', 'date', 'date_format'])) {
  356. $ruleArguments = [$argumentsString];
  357. } else {
  358. $ruleArguments = str_getcsv($argumentsString);
  359. }
  360. }
  361. return [strtolower(trim($rule)), $ruleArguments];
  362. }
  363. }