Implement inline validator support

This commit is contained in:
shalvah
2021-05-24 15:49:52 +01:00
parent cc0fe0aca2
commit 5433e4ba15
30 changed files with 1137 additions and 728 deletions

View File

@@ -12,6 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Removals
## 2.7.2 (Saturday, 22 May 2021)
### Fixed
- Fix laravel type generation (https://github.com/knuckleswtf/scribe/pull/218)
## 2.7.1 (Friday, 21 May 2021)
### Fixed
- Use correct Laravel public path (https://github.com/knuckleswtf/scribe/pull/216)
## 2.7.0 (Friday, 21 May 2021)
### Modified
- Use Laravel `public_path` rather than `public/` for assets (https://github.com/knuckleswtf/scribe/pull/214)

View File

@@ -24,4 +24,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Support headers in response
- Include responses in Postman collection
- Body parameters array
- Auto detect Dingo
- Auto detect Dingo
- Removed `$stage`

View File

@@ -27,6 +27,7 @@
"illuminate/support": "^6.0|^7.0|^8.0",
"league/flysystem": "^1.0",
"mpociot/reflection-docblock": "^1.0.1",
"nikic/php-parser": "^4.10",
"nunomaduro/collision": "^3.0|^4.0|^5.0",
"ramsey/uuid": "^3.8|^4.0",
"shalvah/clara": "^2.6",

View File

@@ -42,5 +42,8 @@
<file>tests/Unit/OpenAPISpecWriterTest.php</file>
<file>tests/Unit/PostmanCollectionWriterTest.php</file>
</testsuite>
<testsuite name="Unit Tests 4">
<file>tests/Unit/ValidationRuleParsingTest.php</file>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -10,7 +10,7 @@ $response = $client->{{ strtolower($endpoint->methods[0]) }}(
[
@if(!empty($endpoint->headers))@php
// We don't need the Content-Type header because Guzzle sets it automatically when you use json or multipart.
unset($route['headers']['Content-Type']);
unset($endpoint->headers['Content-Type']);
@endphp
'headers' => {!! u::printPhpValue($endpoint->headers, 8) !!},
@endif

View File

@@ -11,35 +11,17 @@ use Knuckles\Scribe\Tools\DocumentationConfig;
*/
class ApiDetails
{
/**
* @var DocumentationConfig
*/
private $config;
private DocumentationConfig $config;
/**
* @var string
*/
private $baseUrl;
private string $baseUrl;
/**
* @var bool
*/
private $preserveUserChanges;
private bool $preserveUserChanges;
/**
* @var string
*/
private $markdownOutputPath = '.scribe';
private string $markdownOutputPath = '.scribe';
/**
* @var string
*/
private $fileModificationTimesFile;
private string $fileModificationTimesFile;
/**
* @var array
*/
private $lastTimesWeModifiedTheseFiles = [];
private array $lastTimesWeModifiedTheseFiles = [];
public function __construct(DocumentationConfig $config = null, bool $preserveUserChanges = true)
{

View File

@@ -0,0 +1,95 @@
<?php
namespace Knuckles\Scribe\Extracting;
use Exception;
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
use ReflectionFunctionAbstract;
use Throwable;
/**
* MethodAstParser
* Utility class to help with retrieving (and caching) ASTs of route methods.
*/
class MethodAstParser
{
protected static array $methodAsts = [];
protected static array $classAsts = [];
public static function getMethodAst(ReflectionFunctionAbstract $method)
{
$methodName = $method->name;
$fileName = $method->getFileName();
$methodAst = self::getCachedMethodAst($fileName, $methodName);
if ($methodAst) {
return $methodAst;
}
$classAst = self::getClassAst($fileName);
$methodAst = self::findMethodInClassAst($classAst, $methodName);
self::cacheMethodAst($fileName, $methodName, $methodAst);
return $methodAst;
}
/**
* @param string $sourceCode
*
* @return \PhpParser\Node\Stmt[]|null
*/
protected static function parseClassSourceCode(string $sourceCode): ?array
{
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($sourceCode);
} catch (Throwable $error) {
throw new Exception("Parse error: {$error->getMessage()}");
}
return $ast;
}
/**
* @param \PhpParser\Node\Stmt[] $ast
* @param string $methodName
*
* @return Node|null
*/
protected static function findMethodInClassAst(array $ast, string $methodName)
{
$nodeFinder = new NodeFinder;
return $nodeFinder->findFirst($ast, function(Node $node) use ($methodName) {
// Todo handle closures
return $node instanceof Node\Stmt\ClassMethod
&& $node->name->toString() === $methodName;
});
}
protected static function getCachedMethodAst(string $fileName, string $methodName)
{
$key = self::getAstCacheId($fileName, $methodName);
return self::$methodAsts[$key] ?? null;
}
protected static function cacheMethodAst(string $fileName, string $methodName, Node $methodAst)
{
$key = self::getAstCacheId($fileName, $methodName);
self::$methodAsts[$key] = $methodAst;
}
private static function getAstCacheId(string $fileName, string $methodName): string
{
return $fileName . "///". $methodName;
}
private static function getClassAst(string $fileName)
{
$classAst = self::$classAsts[$fileName]
?? self::parseClassSourceCode(file_get_contents($fileName));
return self::$classAsts[$fileName] = $classAst;
}
}

View File

@@ -91,6 +91,10 @@ trait ParamHelpers
return null;
}
if ($type === "array") {
$type = "string[]";
}
if (Str::endsWith($type, '[]')) {
$baseType = strtolower(substr($type, 0, strlen($type) - 2));
return is_array($value) ? array_map(function ($v) use ($baseType) {

View File

@@ -0,0 +1,479 @@
<?php
namespace Knuckles\Scribe\Extracting;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\WritingUtils as w;
trait ParsesValidationRules
{
use ParamHelpers;
public static \stdClass $MISSING_VALUE;
public function getBodyParametersFromValidationRules(array $validationRules, array $customParameterData = []): array
{
self::$MISSING_VALUE = new \stdClass();
$validationRules = $this->normaliseRules($validationRules);
$parameters = [];
foreach ($validationRules as $parameter => $ruleset) {
if (count($customParameterData) && !isset($customParameterData[$parameter])) {
c::debug($this->getMissingCustomDataMessage($parameter));
}
$userSpecifiedParameterInfo = $customParameterData[$parameter] ?? [];
$parameterData = [
'name' => $parameter,
'required' => false,
'type' => null,
'example' => self::$MISSING_VALUE,
'description' => '',
];
// Make sure the user-specified example overwrites others.
if (isset($userSpecifiedParameterInfo['example'])) {
$parameterData['example'] = $userSpecifiedParameterInfo['example'];
}
foreach ($ruleset as $rule) {
$this->parseRule($rule, $parameterData);
}
if ($this->shouldCastUserExample() && isset($userSpecifiedParameterInfo['example'])) {
// Examples in comments are strings, we need to cast them properly
$parameterData['example'] = $this->castToType($userSpecifiedParameterInfo['example'], $parameterData['type'] ?? 'string');
}
$parameterData = $this->setParameterExampleAndDescription($parameterData, $userSpecifiedParameterInfo);
$parameterData['name'] = $parameter;
$parameters[$parameter] = $parameterData;
}
return $parameters;
}
/**
* Transform validation rules from:
* 'param1' => 'int|required' TO 'param1' => ['int', 'required']
*
* @param array<string,string|string[]> $rules
*
* @return array
*/
protected function normaliseRules(array $rules): array
{
// We can simply call Validator::make($data, $rules)->getRules() to get the normalised rules,
// but Laravel will ignore any nested array rules (`ids.*')
// unless the key referenced (`ids`) exists in the dataset and is a non-empty array
// So we'll create a single-item array for each array parameter
$testData = [];
foreach ($rules as $key => $ruleset) {
if (!Str::contains($key, '.*')) continue;
// All we need is for Laravel to see this key exists
Arr::set($testData, str_replace('.*', '.0', $key), Str::random());
}
// Now this will return the complete ruleset.
// Nested array parameters will be present, with '*' replaced by '0'
$newRules = Validator::make($testData, $rules)->getRules();
// Transform the key names back from 'ids.0' to 'ids.*'
return collect($newRules)->mapWithKeys(function ($val, $paramName) use ($rules) {
if (Str::contains($paramName, '.0')) {
$genericArrayKeyName = str_replace('.0', '.*', $paramName);
// But only if that was the original value
if (isset($rules[$genericArrayKeyName])) {
$paramName = $genericArrayKeyName;
}
}
return [$paramName => $val];
})->toArray();
}
/**
* Parse a validation rule and extract a parameter type, description and setter (used to generate an example).
*
* @param $rule
* @param $parameterData
*/
protected function parseRule($rule, &$parameterData)
{
// Convert string rules into rule + arguments (eg "in:1,2" becomes ["in", ["1", "2"]])
$parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule);
[$rule, $arguments] = $parsedRule;
// 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;
/*
* 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 () {
return $this->generateDummyValue('string');
};
$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'] .= 'The value must be a file.';
$parameterData['setter'] = function () {
return $this->generateDummyValue('file');
};
break;
/**
* Special string types
*/
case 'timezone':
// Laravel's message merely says "The value must be a valid zone"
$parameterData['description'] .= "The value must be a valid time zone, such as <code>Africa/Accra</code>. ";
$parameterData['setter'] = function () {
return $this->getFaker()->timezone;
};
break;
case 'email':
$parameterData['description'] .= $this->getDescription($rule) . ' ';
$parameterData['setter'] = function () {
return $this->getFaker()->safeEmail;
};
$parameterData['type'] = 'string';
break;
case 'url':
$parameterData['setter'] = function () {
return $this->getFaker()->url;
};
$parameterData['type'] = 'string';
// Laravel's message is "The value format is invalid". Ugh.🤮
$parameterData['description'] .= "The value 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'] = function () {
return 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'] .= "The value must be a valid date in the format {$arguments[0]} ";
$parameterData['setter'] = function () use ($arguments) {
return date($arguments[0], time());
};
break;
/**
* Special number types. Some rules here may apply to other types, but we treat them as being numeric.
*/
/*
* min, max and between not supported until we can figure out a proper way
* to make them compatible with multiple types (string, number, file)
case 'min':
$parameterData['type'] = $parameterData['type'] ?: 'number';
$parameterData['description'] .= Description::getDescription($rule, [':min' => $arguments[0]], 'numeric').' ';
$parameterData['setter'] = function () { return $this->getFaker()->numberBetween($arguments[0]); };
break;
case 'max':
$parameterData['type'] = $parameterData['type'] ?: 'number';
$parameterData['description'] .= Description::getDescription($rule, [':max' => $arguments[0]], 'numeric').' ';
$parameterData['setter'] = function () { return $this->getFaker()->numberBetween(0, $arguments[0]); };
break;
case 'between':
$parameterData['type'] = $parameterData['type'] ?: 'number';
$parameterData['description'] .= Description::getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]], 'numeric').' ';
$parameterData['setter'] = function () { return $this->getFaker()->numberBetween($arguments[0], $arguments[1]); };
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"
$description = 'The value must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments);
$parameterData['description'] .= $description . ' ';
$parameterData['setter'] = function () use ($arguments) {
return Arr::random($arguments);
};
break;
default:
// Other rules not supported
break;
}
}
/**
* Parse a string rule into the base rule and arguments.
* Laravel validation rules are specified in the format {rule}:{arguments}
* Arguments are separated by commas.
* For instance the rule "max:3" states that the value may only be three letters.
*
* @param string|Rule $rule
*
* @return array
*/
protected function parseStringRuleIntoRuleAndArguments($rule): array
{
$ruleArguments = [];
// Convert any 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);
// These rules can have ommas in their arguments, so we don't split on commas
if (in_array(strtolower($rule), ['regex', 'date', 'date_format'])) {
$ruleArguments = [$argumentsString];
} else {
$ruleArguments = str_getcsv($argumentsString);
}
}
return [strtolower(trim($rule)), $ruleArguments];
}
protected function setParameterExampleAndDescription(array $parameterData, array $userSpecifiedParameterInfo): array
{
// If no example was given by the user, set an autogenerated example.
// Each parsed rule returns a 'setter' function. We'll evaluate the last one.
if ($parameterData['example'] === self::$MISSING_VALUE && isset($parameterData['setter'])) {
$parameterData['example'] = $parameterData['setter']();
}
// Make sure the user-specified description comes first (and add full stops where needed).
$userSpecifiedDescription = $userSpecifiedParameterInfo['description'] ?? '';
if (!empty($userSpecifiedDescription) && !Str::endsWith($userSpecifiedDescription, '.')) {
$userSpecifiedDescription .= '.';
}
$validationDescription = trim($parameterData['description'] ?: '');
$fullDescription = trim($userSpecifiedDescription . ' ' . trim($validationDescription));
$parameterData['description'] = $fullDescription ? rtrim($fullDescription, '.') . '.' : $fullDescription;
// Set a default type
if (is_null($parameterData['type'])) {
$parameterData['type'] = 'string';
}
// If the parameter is required and has no example, generate one.
if ($parameterData['required'] === true && $parameterData['example'] === self::$MISSING_VALUE) {
$parameterData['example'] = $this->generateDummyValue($parameterData['type']);
}
if (!is_null($parameterData['example']) && $parameterData['example'] !== self::$MISSING_VALUE) {
// Casting again is important since values may have been cast to string in the validator
$parameterData['example'] = $this->castToType($parameterData['example'], $parameterData['type']);
}
return $parameterData;
}
/**
* Laravel uses .* notation for arrays. This PR aims to normalise that into our "new syntax".
*
* 'years.*' with type 'integer' becomes 'years' with type 'integer[]'
* 'cars.*.age' with type 'string' becomes 'cars[].age' with type 'string' and 'cars' with type 'object[]'
* 'cars.*.things.*.*' with type 'string' becomes 'cars[].things' with type 'string[][]' and 'cars' with type
* 'object[]'
*
* @param array[] $bodyParametersFromValidationRules
*
* @return array
*/
public function normaliseArrayAndObjectParameters(array $bodyParametersFromValidationRules): array
{
$results = [];
foreach ($bodyParametersFromValidationRules as $name => $details) {
if (isset($results[$name])) {
continue;
}
if ($details['type'] === 'array') {
// Generic array type. If a child item exists,
// this will be overwritten with the correct type (such as object or object[]) by the code below
$details['type'] = 'string[]';
}
if (Str::endsWith($name, '.*')) {
// Wrap array example properly
$needsWrapping = !is_array($details['example']);
$nestingLevel = 0;
// Change cars.*.dogs.things.*.* with type X to cars.*.dogs.things with type X[][]
while (Str::endsWith($name, '.*')) {
$details['type'] .= '[]';
if ($needsWrapping) {
// Make it two items in each array
$secondItem = $secondValue = $details['setter']();
for ($i = 0; $i < $nestingLevel; $i++) {
$secondItem = [$secondValue];
}
$details['example'] = [$details['example'], $secondItem];
}
$name = substr($name, 0, -2);
$nestingLevel++;
}
}
// Now make sure the field cars.*.dogs exists
$parentPath = $name;
while (Str::contains($parentPath, '.')) {
$parentPath = preg_replace('/\.[^.]+$/', '', $parentPath);
if (empty($bodyParametersFromValidationRules[$parentPath])) {
if (Str::endsWith($parentPath, '.*')) {
$parentPath = substr($parentPath, 0, -2);
$type = 'object[]';
$example = [[]];
} else {
$type = 'object';
$example = [];
}
$normalisedPath = str_replace('.*.', '[].', $parentPath);
$results[$normalisedPath] = [
'name' => $normalisedPath,
'type' => $type,
'required' => false,
'description' => '',
'example' => $example,
];
} else {
// if the parent field already exists with a type 'array'
$parentDetails = $bodyParametersFromValidationRules[$parentPath];
unset($bodyParametersFromValidationRules[$parentPath]);
if (Str::endsWith($parentPath, '.*')) {
$parentPath = substr($parentPath, 0, -2);
$parentDetails['type'] = 'object[]';
// Set the example too. Very likely the example array was an array of strings or an empty array
if (empty($parentDetails['example']) || is_string($parentDetails['example'][0]) || is_string($parentDetails['example'][0][0])) {
$parentDetails['example'] = [[]];
}
} else {
$parentDetails['type'] = 'object';
if (empty($parentDetails['example']) || is_string($parentDetails['example'][0])) {
$parentDetails['example'] = [];
}
}
$normalisedPath = str_replace('.*.', '[].', $parentPath);
$parentDetails['name'] = $normalisedPath;
$results[$normalisedPath] = $parentDetails;
}
}
$details['name'] = $name = str_replace('.*.', '[].', $name);
unset($details['setter']);
// Change type 'array' to 'object' if there are subfields
if (
$details['type'] === 'array'
&& Arr::first(array_keys($bodyParametersFromValidationRules), function ($key) use ($name) {
return preg_match("/{$name}\\.[^*]/", $key);
})
) {
$details['type'] = 'object';
}
$results[$name] = $details;
}
return $results;
}
protected function getDescription(string $rule, array $arguments = [], $baseType = 'string'): string
{
$description = trans("validation.{$rule}");
// For rules that can apply to multiple types (eg 'max' rule), Laravel returns an array of possible messages
// 'numeric' => 'The :attribute must not be greater than :max'
// 'file' => 'The :attribute must have a size less than :max kilobytes'
if (is_array($description)) {
$description = $description[$baseType];
}
// Convert messages from failure type ("The value is not a valid date.") to info ("The value must be a valid date.")
$description = str_replace(['is not', 'does not'], ['must be', 'must'], $description);
foreach ($arguments as $placeholder => $argument) {
$description = str_replace($placeholder, $argument, $description);
}
return str_replace(":attribute", "value", $description);
}
protected function getMissingCustomDataMessage($parameterName)
{
return "";
}
protected function shouldCastUserExample()
{
return false;
}
}

View File

@@ -17,9 +17,6 @@ use ReflectionUnionType;
class GetFromBodyParamTag extends Strategy
{
/** @var string */
public $stage = 'bodyParameters';
use ParamHelpers;
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)

View File

@@ -5,15 +5,9 @@ namespace Knuckles\Scribe\Extracting\Strategies\BodyParameters;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Dingo\Api\Http\FormRequest as DingoFormRequest;
use Illuminate\Foundation\Http\FormRequest as LaravelFormRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Contracts\Validation\Rule;
use Knuckles\Scribe\Extracting\ParamHelpers;
use Knuckles\Scribe\Extracting\ParsesValidationRules;
use Knuckles\Scribe\Extracting\Strategies\Strategy;
use Knuckles\Scribe\Extracting\ValidationRuleDescriptionParser as d;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\WritingUtils as w;
use ReflectionClass;
use ReflectionException;
use ReflectionFunctionAbstract;
@@ -22,12 +16,7 @@ use ReflectionUnionType;
class GetFromFormRequest extends Strategy
{
/** @var string */
public $stage = 'bodyParameters';
public static $MISSING_VALUE;
use ParamHelpers;
use ParsesValidationRules;
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules): array
{
@@ -115,417 +104,10 @@ class GetFromFormRequest extends Strategy
return [];
}
public function getBodyParametersFromValidationRules(array $validationRules, array $customParameterData = [])
protected function getMissingCustomDataMessage($parameterName)
{
self::$MISSING_VALUE = new \stdClass();
$rules = $this->normaliseRules($validationRules);
$parameters = [];
foreach ($rules as $parameter => $ruleset) {
if (count($customParameterData) && !isset($customParameterData[$parameter])) {
c::debug("No data found for parameter '$parameter' from your bodyParameters() method. Add an entry for '$parameter' so you can add description and example.");
}
$userSpecifiedParameterInfo = $customParameterData[$parameter] ?? [];
$parameterData = [
'name' => $parameter,
'required' => false,
'type' => null,
'example' => self::$MISSING_VALUE,
'description' => '',
];
// Make sure the user-specified example overwrites others.
if (isset($userSpecifiedParameterInfo['example'])) {
$parameterData['example'] = $userSpecifiedParameterInfo['example'];
}
foreach ($ruleset as $rule) {
$this->parseRule($rule, $parameterData);
}
// Set autogenerated examples if none was supplied.
// Each rule returns a 'setter' function, so we can lazily evaluate the last one only if we need it.
if ($parameterData['example'] === self::$MISSING_VALUE && isset($parameterData['setter'])) {
$parameterData['example'] = $parameterData['setter']();
}
// Make sure the user-specified description comes first.
$userSpecifiedDescription = $userSpecifiedParameterInfo['description'] ?? '';
$validationDescription = trim($parameterData['description'] ?: '');
$fullDescription = trim($userSpecifiedDescription . ' ' . trim($validationDescription));
// Let's have our sentences end with full stops, like civilized people.🙂
$parameterData['description'] = $fullDescription ? rtrim($fullDescription, '.') . '.' : $fullDescription;
// Set default values for type
if (is_null($parameterData['type'])) {
$parameterData['type'] = 'string';
}
// Set values when parameter is required and has no value
if ($parameterData['required'] === true && $parameterData['example'] === self::$MISSING_VALUE) {
$parameterData['example'] = $this->generateDummyValue($parameterData['type']);
}
if (!is_null($parameterData['example']) && $parameterData['example'] !== self::$MISSING_VALUE) {
// The cast is important since values may have been cast to string in the validator
$parameterData['example'] = $this->castToType($parameterData['example'], $parameterData['type']);
}
$parameterData['name'] = $parameter;
$parameters[$parameter] = $parameterData;
}
return $parameters;
return "No data found for parameter '$parameterName' in your bodyParameters() method. Add an entry for '$parameterName' so you can add a description and example.";
}
/**
* This method will transform validation rules from:
* 'param1' => 'int|required' TO 'param1' => ['int', 'required']
*
* @param array<string,string|string[]> $rules
*
* @return mixed
*/
protected function normaliseRules(array $rules)
{
// We can simply call Validator::make($data, $rules)->getRules() to get the normalised rules,
// but Laravel will ignore any nested array rules (`ids.*')
// unless the key referenced (`ids`) exists in the dataset and is a non-empty array
// So we'll create a single-item array for each array parameter
$testData = [];
foreach ($rules as $key => $ruleset) {
if (!Str::contains($key, '.*')) continue;
// All we need is for Laravel to see this key exists
Arr::set($testData, str_replace('.*', '.0', $key), Str::random());
}
// Now this will return the complete ruleset.
// Nested array parameters will be present, with '*' replaced by '0'
$newRules = Validator::make($testData, $rules)->getRules();
// Transform the key names back from 'ids.0' to 'ids.*'
return collect($newRules)->mapWithKeys(function ($val, $paramName) use ($rules) {
if (Str::contains($paramName, '.0')) {
$genericArrayKeyName = str_replace('.0', '.*', $paramName);
// But only if that was the original value
if (isset($rules[$genericArrayKeyName])) {
$paramName = $genericArrayKeyName;
}
}
return [$paramName => $val];
})->toArray();
}
protected function parseRule($rule, &$parameterData)
{
$parsedRule = $this->parseStringRuleIntoRuleAndArguments($rule);
[$rule, $arguments] = $parsedRule;
// 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;
/*
* 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 () {
return $this->generateDummyValue('string');
};
$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'] .= 'The value must be a file.';
$parameterData['setter'] = function () {
return $this->generateDummyValue('file');
};
break;
/**
* Special string types
*/
case 'timezone':
// Laravel's message merely says "The value must be a valid zone"
$parameterData['description'] .= "The value must be a valid time zone, such as <code>Africa/Accra</code>. ";
$parameterData['setter'] = function () {
return $this->getFaker()->timezone;
};
break;
case 'email':
$parameterData['description'] .= d::getDescription($rule) . ' ';
$parameterData['setter'] = function () {
return $this->getFaker()->safeEmail;
};
$parameterData['type'] = 'string';
break;
case 'url':
$parameterData['setter'] = function () {
return $this->getFaker()->url;
};
$parameterData['type'] = 'string';
// Laravel's message is "The value format is invalid". Ugh.🤮
$parameterData['description'] .= "The value must be a valid URL. ";
break;
case 'ip':
$parameterData['description'] .= d::getDescription($rule) . ' ';
$parameterData['type'] = 'string';
$parameterData['setter'] = function () {
return $this->getFaker()->ipv4;
};
break;
case 'json':
$parameterData['type'] = 'string';
$parameterData['description'] .= d::getDescription($rule) . ' ';
$parameterData['setter'] = function () {
return json_encode([$this->getFaker()->word, $this->getFaker()->word,]);
};
break;
case 'date':
$parameterData['type'] = 'string';
$parameterData['description'] .= d::getDescription($rule) . ' ';
$parameterData['setter'] = function () {
return date(\DateTime::ISO8601, 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'] .= "The value must be a valid date in the format {$arguments[0]} ";
$parameterData['setter'] = function () use ($arguments) {
return date($arguments[0], time());
};
break;
/**
* Special number types. Some rules here may apply to other types, but we treat them as being numeric.
*//*
* min, max and between not supported until we can figure out a proper way
* to make them compatible with multiple types (string, number, file)
case 'min':
$parameterData['type'] = $parameterData['type'] ?: 'number';
$parameterData['description'] .= Description::getDescription($rule, [':min' => $arguments[0]], 'numeric').' ';
$parameterData['setter'] = function () { return $this->getFaker()->numberBetween($arguments[0]); };
break;
case 'max':
$parameterData['type'] = $parameterData['type'] ?: 'number';
$parameterData['description'] .= Description::getDescription($rule, [':max' => $arguments[0]], 'numeric').' ';
$parameterData['setter'] = function () { return $this->getFaker()->numberBetween(0, $arguments[0]); };
break;
case 'between':
$parameterData['type'] = $parameterData['type'] ?: 'number';
$parameterData['description'] .= Description::getDescription($rule, [':min' => $arguments[0], ':max' => $arguments[1]], 'numeric').' ';
$parameterData['setter'] = function () { return $this->getFaker()->numberBetween($arguments[0], $arguments[1]); };
break;*/
/**
* Special file types.
*/
case 'image':
$parameterData['type'] = 'file';
$parameterData['description'] .= d::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"
$description = 'The value must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments);
$parameterData['description'] .= $description . ' ';
$parameterData['setter'] = function () use ($arguments) {
return Arr::random($arguments);
};
break;
default:
// Other rules not supported
break;
}
}
/**
* Parse a string rule into the base rule and arguments.
* Laravel validation rules are specified in the format {rule}:{arguments}
* Arguments are separated by commas.
* For instance the rule "max:3" states that the value may only be three letters.
*
* @param string|Rule $rule
*
* @return array
*/
protected function parseStringRuleIntoRuleAndArguments($rule)
{
$ruleArguments = [];
// Convert any 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);
// These rules can have ommas in their arguments, so we don't split on commas
if (in_array(strtolower($rule), ['regex', 'date', 'date_format'])) {
$ruleArguments = [$argumentsString];
} else {
$ruleArguments = str_getcsv($argumentsString);
}
}
return [strtolower(trim($rule)), $ruleArguments];
}
/**
* Laravel uses .* notation for arrays. This PR aims to normalise that into our "new syntax".
*
* 'years.*' with type 'integer' becomes 'years' with type 'integer[]'
* 'cars.*.age' with type 'string' becomes 'cars[].age' with type 'string' and 'cars' with type 'object[]'
* 'cars.*.things.*.*' with type 'string' becomes 'cars[].things' with type 'string[][]' and 'cars' with type
* 'object[]'
*
* @param array[] $bodyParametersFromValidationRules
*
* @return array
*/
public function normaliseArrayAndObjectParameters(array $bodyParametersFromValidationRules): array
{
$results = [];
foreach ($bodyParametersFromValidationRules as $name => $details) {
if (isset($results[$name])) {
continue;
}
if ($details['type'] === 'array') {
// Generic array type. If a child item exists,
// this will be overwritten with the correct type (such as object or object[]) by the code below
$details['type'] = 'string[]';
}
if (Str::endsWith($name, '.*')) {
// Wrap array example properly
$needsWrapping = !is_array($details['example']);
$nestingLevel = 0;
// Change cars.*.dogs.things.*.* with type X to cars.*.dogs.things with type X[][]
while (Str::endsWith($name, '.*')) {
$details['type'] .= '[]';
if ($needsWrapping) {
// Make it two items in each array
$secondItem = $secondValue = $details['setter']();
for ($i = 0; $i < $nestingLevel; $i++) {
$secondItem = [$secondValue];
}
$details['example'] = [$details['example'], $secondItem];
}
$name = substr($name, 0, -2);
$nestingLevel++;
}
}
// Now make sure the field cars.*.dogs exists
$parentPath = $name;
while (Str::contains($parentPath, '.')) {
$parentPath = preg_replace('/\.[^.]+$/', '', $parentPath);
if (empty($bodyParametersFromValidationRules[$parentPath])) {
if (Str::endsWith($parentPath, '.*')) {
$parentPath = substr($parentPath, 0, -2);
$type = 'object[]';
$example = [[]];
} else {
$type = 'object';
$example = [];
}
$normalisedPath = str_replace('.*.', '[].', $parentPath);
$results[$normalisedPath] = [
'name' => $normalisedPath,
'type' => $type,
'required' => false,
'description' => '',
'example' => $example,
];
} else {
// if the parent field already exists with a type 'array'
$parentDetails = $bodyParametersFromValidationRules[$parentPath];
unset($bodyParametersFromValidationRules[$parentPath]);
if (Str::endsWith($parentPath, '.*')) {
$parentPath = substr($parentPath, 0, -2);
$parentDetails['type'] = 'object[]';
// Set the example too. Very likely the example array was an array of strings or an empty array
if (empty($parentDetails['example']) || is_string($parentDetails['example'][0]) || is_string($parentDetails['example'][0][0])) {
$parentDetails['example'] = [[]];
}
} else {
$parentDetails['type'] = 'object';
if (empty($parentDetails['example']) || is_string($parentDetails['example'][0])) {
$parentDetails['example'] = [];
}
}
$normalisedPath = str_replace('.*.', '[].', $parentPath);
$parentDetails['name'] = $normalisedPath;
$results[$normalisedPath] = $parentDetails;
}
}
$details['name'] = $name = str_replace('.*.', '[].', $name);
unset($details['setter']);
// Change type 'array' to 'object' if there are subfields
if (
$details['type'] === 'array'
&& Arr::first(array_keys($bodyParametersFromValidationRules), function ($key) use ($name) {
return preg_match("/{$name}\\.[^*]/", $key);
})
) {
$details['type'] = 'object';
}
$results[$name] = $details;
}
return $results;
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Knuckles\Scribe\Extracting\Strategies\BodyParameters;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Scribe\Extracting\MethodAstParser;
use Knuckles\Scribe\Extracting\ParsesValidationRules;
use Knuckles\Scribe\Extracting\Strategies\Strategy;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
class GetFromInlineValidator extends Strategy
{
use ParsesValidationRules;
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules): array
{
$methodAst = MethodAstParser::getMethodAst($endpointData->method);
[$validationRules, $customParameterData] = $this->lookForInlineValidationRules($methodAst);
$bodyParametersFromValidationRules = $this->getBodyParametersFromValidationRules($validationRules, $customParameterData);
return $this->normaliseArrayAndObjectParameters($bodyParametersFromValidationRules);
}
public function lookForInlineValidationRules(ClassMethod $methodAst): array
{
// Validation usually happens early on, so let's assume it's in the first 6 statements
$statements = array_slice($methodAst->stmts, 0, 6);
$validationRules = null;
foreach ($statements as $index => $node) {
// Filter to only assignment expressions
if (!($node instanceof Node\Stmt\Expression) || !($node->expr instanceof Node\Expr\Assign)) {
continue;
}
$assignment = $node->expr;
$rvalue = $assignment->expr;
// Look for $validated = $request->validate(...)
if (
$rvalue instanceof Node\Expr\MethodCall && $rvalue->var instanceof Node\Expr\Variable
&& in_array($rvalue->var->name, ["request", "req"]) && $rvalue->name->name == "validate"
) {
$validationRules = $rvalue->args[0]->value;
break;
} else if (
// Try $validator = Validator::make(...)
$rvalue instanceof Node\Expr\StaticCall && end($rvalue->class->parts) == "Validator"
&& $rvalue->name->name == "make"
) {
$validationRules = $rvalue->args[1]->value;
break;
}
}
// If validation rules were saved in a variable (like $rules),
// find the var and expand the value
if ($validationRules instanceof Node\Expr\Variable) {
foreach (array_reverse(array_slice($statements, 0, $index)) as $earlierStatement) {
if (
$earlierStatement instanceof Node\Stmt\Expression
&& $earlierStatement->expr instanceof Node\Expr\Assign
&& $earlierStatement->expr->var instanceof Node\Expr\Variable
&& $earlierStatement->expr->var->name == $validationRules->name
) {
$validationRules = $earlierStatement->expr->expr;
break;
}
}
}
if (!$validationRules instanceof Node\Expr\Array_) {
return [[], []];
}
$rules = [];
$customParameterData = [];
foreach ($validationRules->items as $item) {
$paramName = $item->key->value;
// Might be an expression or concatenated string, etc.
// For now, let's focus on simple strings and arrays of strings
if ($item->value instanceof Node\Scalar\String_) {
$rules[$paramName] = $item->value->value;
} else if ($item->value instanceof Node\Expr\Array_) {
$rulesList = [];
foreach ($item->value->items as $arrayItem) {
/** @var Node\Expr\ArrayItem $arrayItem */
if ($arrayItem->value instanceof Node\Scalar\String_) {
$rulesList[] = $arrayItem->value->value;
}
}
$rules[$paramName] = join('|', $rulesList);
} else {
continue;
}
$description = $example = null;
$comments = join("\n", array_map(
fn($comment) => ltrim(ltrim($comment->getReformattedText(), "/")),
$item->getComments()
)
);
if ($comments) {
$description = trim(str_replace(['No-example.', 'No-example'], '', $comments));
$example = null;
if (preg_match('/(.*\s+|^)Example:\s*([\s\S]+)\s*/m', $description, $matches)) {
$description = trim($matches[1]);
$example = $matches[2];
}
}
$customParameterData[$paramName] = compact('description', 'example');
}
return [$rules, $customParameterData];
}
protected function getMissingCustomDataMessage($parameterName)
{
return "No extra data found for parameter '$parameterName' from your inline validator. You can add a comment above '$parameterName' with a description and example.";
}
protected function shouldCastUserExample()
{
return true;
}
}

View File

@@ -18,10 +18,9 @@ class GetFromHeaderTag extends Strategy
{
use ParamHelpers;
/** @var string */
public $stage = 'headers';
public string $stage = 'headers';
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules): array
{
foreach ($endpointData->method->getParameters() as $param) {
$paramType = $param->getType();

View File

@@ -7,10 +7,9 @@ use Knuckles\Scribe\Extracting\Strategies\Strategy;
class GetFromRouteRules extends Strategy
{
/** @var string */
public $stage = 'headers';
public string $stage = 'headers';
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules): array
{
return $routeRules['headers'] ?? [];
}

View File

@@ -9,10 +9,9 @@ use Mpociot\Reflection\DocBlock;
class GetFromDocBlocks extends Strategy
{
/** @var string */
public $stage = 'metadata';
public string $stage = 'metadata';
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules): array
{
$docBlocks = RouteDocBlocker::getDocBlocksFromRoute($endpointData->route);
$methodDocBlock = $docBlocks['method'];

View File

@@ -18,8 +18,7 @@ use ReflectionUnionType;
class GetFromQueryParamTag extends Strategy
{
/** @var string */
public $stage = 'queryParameters';
public string $stage = 'queryParameters';
use ParamHelpers;

View File

@@ -12,8 +12,7 @@ use Mpociot\Reflection\DocBlock\Tag;
class GetFromResponseFieldTag extends Strategy
{
/** @var string */
public $stage = 'responseFields';
public string $stage = 'responseFields';
use ParamHelpers;

View File

@@ -27,6 +27,8 @@ use Mpociot\Reflection\DocBlock\Tag;
*/
class UseApiResourceTags extends Strategy
{
public string $stage = 'responses';
use DatabaseTransactionHelpers;
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)

View File

@@ -14,6 +14,8 @@ use Mpociot\Reflection\DocBlock\Tag;
*/
class UseResponseFileTag extends Strategy
{
public string $stage = 'responses';
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)
{
$docBlocks = RouteDocBlocker::getDocBlocksFromRoute($endpointData->route);

View File

@@ -13,6 +13,8 @@ use Mpociot\Reflection\DocBlock\Tag;
*/
class UseResponseTag extends Strategy
{
public string $stage = 'responses';
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)
{
$docBlocks = RouteDocBlocker::getDocBlocksFromRoute($endpointData->route);

View File

@@ -26,6 +26,8 @@ use ReflectionFunctionAbstract;
*/
class UseTransformerTags extends Strategy
{
public string $stage = 'responses';
use DatabaseTransactionHelpers;
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)

View File

@@ -9,15 +9,13 @@ abstract class Strategy
{
/**
* The Scribe config
* @var \Knuckles\Scribe\Tools\DocumentationConfig
*/
protected $config;
protected DocumentationConfig $config;
/**
* The current stage of route processing
*/
/** @var string */
public $stage ;
public string $stage;
public function __construct(DocumentationConfig $config)
{

View File

@@ -12,8 +12,7 @@ class GetFromLaravelAPI extends Strategy
{
use ParamHelpers;
/** @var string */
public $stage = 'urlParameters';
public string $stage = 'urlParameters';
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)
{

View File

@@ -13,8 +13,7 @@ class GetFromLumenAPI extends Strategy
{
use ParamHelpers;
/** @var string */
public $stage = 'urlParameters';
public string $stage = 'urlParameters';
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)
{

View File

@@ -19,8 +19,7 @@ class GetFromUrlParamTag extends Strategy
{
use ParamHelpers;
/** @var string */
public $stage = 'urlParameters';
public string $stage = 'urlParameters';
public function __invoke(ExtractedEndpointData $endpointData, array $routeRules)
{

View File

@@ -1,54 +0,0 @@
<?php
namespace Knuckles\Scribe\Extracting;
class ValidationRuleDescriptionParser
{
private $rule;
private $arguments = [];
/**
* @param string $rule
*/
public function __construct(string $rule = null)
{
$this->rule = $rule;
}
public static function getDescription(string $rule, array $arguments = [], $type = 'string'): string
{
$instance = new self($rule);
$instance->arguments = $arguments;
return $instance->makeDescription($type);
}
protected function makeDescription($baseType = 'string'): string
{
$description = trans("validation.{$this->rule}");
// For rules that can apply to multiple types (eg 'max' rule), Laravel returns an array of possible messages
// 'numeric' => 'The :attribute must not be greater than :max'
// 'file' => 'The :attribute must have a size less than :max kilobytes'
if (is_array($description)) {
$description = $description[$baseType];
}
// Convert messages from failure type ("The value is not a valid date.") to info ("The value must be a valid date.")
$description = str_replace(['is not', 'does not'], ['must be', 'must'], $description);
return $this->replaceArguments($description);
}
protected function replaceArguments(string $description): string
{
foreach ($this->arguments as $placeholder => $argument) {
$description = str_replace($placeholder, $argument, $description);
}
$description = str_replace(":attribute", "value", $description);
return $description;
}
}

View File

@@ -409,4 +409,59 @@ class TestController extends Controller
{
return '';
}
public function withInlineRequestValidate(Request $request)
{
// Some stuff
$validated = $request->validate([
// The id of the user. Example: 9
'user_id' => 'int|required',
// The id of the room.
'room_id' => ['string', 'in:3,5,6'],
// Whether to ban the user forever. Example: false
'forever' => 'boolean',
// Just need something here
'another_one' => 'numeric',
'even_more_param' => 'array',
'book.name' => 'string',
'book.author_id' => 'integer',
'book.pages_count' => 'integer',
'ids.*' => 'integer',
// The first name of the user. Example: John
'users.*.first_name' => ['string'],
// The last name of the user. Example: Doe
'users.*.last_name' => 'string',
]);
// Do stuff
}
public function withInlineValidatorMake(Request $request)
{
// Some stuff
$validator = Validator::make($request, [
// The id of the user. Example: 9
'user_id' => 'int|required',
// The id of the room.
'room_id' => ['string', 'in:3,5,6'],
// Whether to ban the user forever. Example: false
'forever' => 'boolean',
// Just need something here
'another_one' => 'numeric',
'even_more_param' => 'array',
'book.name' => 'string',
'book.author_id' => 'integer',
'book.pages_count' => 'integer',
'ids.*' => 'integer',
// The first name of the user. Example: John
'users.*.first_name' => ['string'],
// The last name of the user. Example: Doe
'users.*.last_name' => 'string',
]);
// Do stuff
if ($validator->fails()) {
}
}
}

View File

@@ -18,8 +18,7 @@ class GetFromFormRequestTest extends BaseLaravelTest
/** @test */
public function can_fetch_from_form_request()
{
$methodName = 'withFormRequestParameter';
$method = new \ReflectionMethod(TestController::class, $methodName);
$method = new \ReflectionMethod(TestController::class, 'withFormRequestParameter');
$strategy = new GetFromFormRequest(new DocumentationConfig([]));
$results = $strategy->getBodyParametersFromFormRequest($method);
@@ -100,202 +99,4 @@ class GetFromFormRequestTest extends BaseLaravelTest
$this->assertIsArray($results['ids']['example']);
}
/**
* @test
* @dataProvider supportedRules
*/
public function can_handle_specific_rules($ruleset, $customInfo, $expected)
{
$strategy = new GetFromFormRequest(new DocumentationConfig([]));
$results = $strategy->getBodyParametersFromValidationRules($ruleset, $customInfo);
$parameterName = array_keys($ruleset)[0];
if (isset($expected['required'])) {
$this->assertEquals($expected['required'], $results[$parameterName]['required']);
}
if (!empty($expected['type'])) {
$this->assertEquals($expected['type'], $results[$parameterName]['type']);
}
if (!empty($expected['description'])) {
$this->assertStringEndsWith($expected['description'], $results[$parameterName]['description']);
}
// Validate that the generated values actually pass
$validator = Validator::make([$parameterName => $results[$parameterName]['example']], $ruleset);
try {
$validator->validate();
} catch (ValidationException $e) {
dump('Value: ', $results[$parameterName]['example']);
dump($e->errors());
throw $e;
}
}
/** @test */
public function can_transform_arrays_and_objects()
{
$strategy = new GetFromFormRequest(new DocumentationConfig([]));
$ruleset = [
'array_param' => 'array|required',
'array_param.*' => 'string',
];
$results = $strategy->normaliseArrayAndObjectParameters($strategy->getBodyParametersFromValidationRules($ruleset));
$this->assertCount(1, $results);
$this->assertEquals('string[]', $results['array_param']['type']);
$ruleset = [
'object_param' => 'array|required',
'object_param.field1.*' => 'string',
'object_param.field2' => 'integer|required',
];
$results = $strategy->normaliseArrayAndObjectParameters($strategy->getBodyParametersFromValidationRules($ruleset));
$this->assertCount(3, $results);
$this->assertEquals('object', $results['object_param']['type']);
$this->assertEquals('string[]', $results['object_param.field1']['type']);
$this->assertEquals('integer', $results['object_param.field2']['type']);
$ruleset = [
'array_of_objects_with_array.*.another.*.one.field1.*' => 'string|required',
'array_of_objects_with_array.*.another.*.one.field2' => 'integer',
'array_of_objects_with_array.*.another.*.two.field2' => 'numeric',
];
$results = $strategy->normaliseArrayAndObjectParameters($strategy->getBodyParametersFromValidationRules($ruleset));
$this->assertCount(7, $results);
$this->assertEquals('object[]', $results['array_of_objects_with_array']['type']);
$this->assertEquals('object[]', $results['array_of_objects_with_array[].another']['type']);
$this->assertEquals('object', $results['array_of_objects_with_array[].another[].one']['type']);
$this->assertEquals('object', $results['array_of_objects_with_array[].another[].two']['type']);
$this->assertEquals('string[]', $results['array_of_objects_with_array[].another[].one.field1']['type']);
$this->assertEquals('integer', $results['array_of_objects_with_array[].another[].one.field2']['type']);
$this->assertEquals('number', $results['array_of_objects_with_array[].another[].two.field2']['type']);
}
public function supportedRules()
{
$description = 'A description';
// Key is just an identifier
// First array in each key is the validation ruleset,
// Second is custom information from bodyParameters()
// Third is expected result
return [
'required' => [
['required_param' => 'required'],
['required_param' => ['description' => $description]],
[
'required' => true,
],
],
'string' => [
['string_param' => 'string|required'],
['string_param' => ['description' => $description]],
[
'type' => 'string',
],
],
'boolean' => [
['boolean_param' => 'boolean|required'],
['boolean_param' => ['description' => $description]],
[
'type' => 'boolean',
],
],
'integer' => [
['integer_param' => 'integer|required'],
['integer_param' => ['description' => $description]],
[
'type' => 'integer',
],
],
'numeric' => [
['numeric_param' => 'numeric|required'],
['numeric_param' => ['description' => $description]],
[
'type' => 'number',
],
],
'array' => [
['array_param' => 'array|required'],
['array_param' => ['description' => $description]],
[
'type' => 'array',
],
],
'file' => [
['file_param' => 'file|required'],
['file_param' => ['description' => $description]],
[
'description' => 'The value must be a file.',
'type' => 'file',
],
],
'timezone' => [
['timezone_param' => 'timezone|required'],
['timezone_param' => ['description' => $description]],
[
'description' => 'The value must be a valid time zone, such as <code>Africa/Accra</code>.',
'type' => 'string',
],
],
'email' => [
['email_param' => 'email|required'],
['email_param' => ['description' => $description]],
[
'description' => 'The value must be a valid email address.',
'type' => 'string',
],
],
'url' => [
['url_param' => 'url|required'],
['url_param' => ['description' => $description]],
[
'description' => 'The value must be a valid URL.',
'type' => 'string',
],
],
'ip' => [
['ip_param' => 'ip|required'],
['ip_param' => ['description' => $description]],
[
'description' => 'The value must be a valid IP address.',
'type' => 'string',
],
],
'json' => [
['json_param' => 'json|required'],
['json_param' => ['description' => $description]],
[
'description' => 'The value must be a valid JSON string.',
'type' => 'string',
],
],
'date' => [
['date_param' => 'date|required'],
['date_param' => ['description' => $description]],
[
'description' => 'The value must be a valid date.',
'type' => 'string',
],
],
'date_format' => [
['date_format_param' => 'date_format:Y-m-d|required'],
['date_format_param' => ['description' => $description]],
[
'description' => 'The value must be a valid date in the format Y-m-d.',
'type' => 'string',
],
],
'in' => [
['in_param' => 'in:3,5,6|required'],
['in_param' => ['description' => $description]],
[
'description' => 'The value must be one of <code>3</code>, <code>5</code>, or <code>6</code>.',
'type' => 'string',
],
],
];
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Knuckles\Scribe\Tests\Strategies\BodyParameters;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromInlineValidator;
use Knuckles\Scribe\Tests\BaseLaravelTest;
use Knuckles\Scribe\Tests\Fixtures\TestController;
use Knuckles\Scribe\Tools\DocumentationConfig;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
class GetFromInlineValidatorTest extends BaseLaravelTest
{
use ArraySubsetAsserts;
private static $expected = [
'user_id' => [
'type' => 'integer',
'required' => true,
'description' => 'The id of the user.',
'example' => 9,
],
'room_id' => [
'type' => 'string',
'required' => false,
'description' => 'The id of the room. The value must be one of <code>3</code>, <code>5</code>, or <code>6</code>.',
],
'forever' => [
'type' => 'boolean',
'required' => false,
'description' => 'Whether to ban the user forever.',
'example' => false,
],
'another_one' => [
'type' => 'number',
'required' => false,
'description' => 'Just need something here.',
],
'even_more_param' => [
'type' => 'string[]',
'required' => false,
'description' => '',
],
'book' => [
'type' => 'object',
'description' => '',
'required' => false,
'example' => [],
],
'book.name' => [
'type' => 'string',
'description' => '',
'required' => false,
],
'book.author_id' => [
'type' => 'integer',
'description' => '',
'required' => false,
],
'book.pages_count' => [
'type' => 'integer',
'description' => '',
'required' => false,
],
'ids' => [
'type' => 'integer[]',
'description' => '',
'required' => false,
],
'users' => [
'type' => 'object[]',
'description' => '',
'required' => false,
'example' => [[]],
],
'users[].first_name' => [
'type' => 'string',
'description' => 'The first name of the user.',
'required' => false,
'example' => 'John',
],
'users[].last_name' => [
'type' => 'string',
'description' => 'The last name of the user.',
'required' => false,
'example' => 'Doe',
],
];
/** @test */
public function can_fetch_from_request_validate()
{
$endpoint = new class extends ExtractedEndpointData {
public function __construct(array $parameters = [])
{
$this->method = new \ReflectionMethod(TestController::class, 'withInlineRequestValidate');
}
};
$strategy = new GetFromInlineValidator(new DocumentationConfig([]));
$results = $strategy($endpoint, []);
$this->assertArraySubset(self::$expected, $results);
$this->assertIsArray($results['ids']['example']);
}
/** @test */
public function can_fetch_from_validator_make()
{
$endpoint = new class extends ExtractedEndpointData {
public function __construct(array $parameters = [])
{
$this->method = new \ReflectionMethod(TestController::class, 'withInlineValidatorMake');
}
};
$strategy = new GetFromInlineValidator(new DocumentationConfig([]));
$results = $strategy($endpoint, []);
$this->assertArraySubset(self::$expected, $results);
$this->assertIsArray($results['ids']['example']);
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace Knuckles\Scribe\Tests\Unit;
use Knuckles\Scribe\Extracting\ParsesValidationRules;
use Knuckles\Scribe\Tests\BaseLaravelTest;
use Knuckles\Scribe\Tools\DocumentationConfig;
class ValidationRuleParsingTest extends BaseLaravelTest
{
private $strategy;
public function __construct(?string $name = null, array $data = [], $dataName = '')
{
parent::__construct($name, $data, $dataName);
$this->strategy = new class {
use ParsesValidationRules;
public function parse($validationRules, $customParameterData = []): array
{
$this->config = new DocumentationConfig([]);
$bodyParametersFromValidationRules = $this->getBodyParametersFromValidationRules($validationRules, $customParameterData);
return $this->normaliseArrayAndObjectParameters($bodyParametersFromValidationRules);
}
};
}
/**
* @test
* @dataProvider supportedRules
*/
public function can_parse_supported_rules(array $ruleset, array $customInfo, array $expected)
{
$results = $this->strategy->parse($ruleset, $customInfo);
$parameterName = array_keys($ruleset)[0];
$this->assertEquals($expected['type'], $results[$parameterName]['type']);
$this->assertStringEndsWith($expected['description'], $results[$parameterName]['description']);
}
/** @test */
public function can_transform_arrays_and_objects()
{
$ruleset = [
'array_param' => 'array|required',
'array_param.*' => 'string',
];
$results = $this->strategy->parse($ruleset);
$this->assertCount(1, $results);
$this->assertEquals('string[]', $results['array_param']['type']);
$ruleset = [
'object_param' => 'array|required',
'object_param.field1.*' => 'string',
'object_param.field2' => 'integer|required',
];
$results = $this->strategy->parse($ruleset);
$this->assertCount(3, $results);
$this->assertEquals('object', $results['object_param']['type']);
$this->assertEquals('string[]', $results['object_param.field1']['type']);
$this->assertEquals('integer', $results['object_param.field2']['type']);
$ruleset = [
'array_of_objects_with_array.*.another.*.one.field1.*' => 'string|required',
'array_of_objects_with_array.*.another.*.one.field2' => 'integer',
'array_of_objects_with_array.*.another.*.two.field2' => 'numeric',
];
$results = $this->strategy->parse($ruleset);
$this->assertCount(7, $results);
$this->assertEquals('object[]', $results['array_of_objects_with_array']['type']);
$this->assertEquals('object[]', $results['array_of_objects_with_array[].another']['type']);
$this->assertEquals('object', $results['array_of_objects_with_array[].another[].one']['type']);
$this->assertEquals('object', $results['array_of_objects_with_array[].another[].two']['type']);
$this->assertEquals('string[]', $results['array_of_objects_with_array[].another[].one.field1']['type']);
$this->assertEquals('integer', $results['array_of_objects_with_array[].another[].one.field2']['type']);
$this->assertEquals('number', $results['array_of_objects_with_array[].another[].two.field2']['type']);
}
public function supportedRules()
{
$description = 'A description';
// Key is just an identifier
// First array in each key is the validation ruleset,
// Second is custom information (from bodyParameters() or comments)
// Third is expected result
yield 'string' => [
['string_param' => 'string'],
['string_param' => ['description' => $description]],
[
'type' => 'string',
'description' => $description . ".",
],
];
yield 'boolean' => [
['boolean_param' => 'boolean'],
[],
[
'type' => 'boolean',
'description' => "",
],
];
yield 'integer' => [
['integer_param' => 'integer'],
[],
[
'type' => 'integer',
'description' => "",
],
];
yield 'numeric' => [
['numeric_param' => 'numeric'],
['numeric_param' => ['description' => $description]],
[
'type' => 'number',
'description' => $description . ".",
],
];
yield 'array' => [
['array_param' => 'array'],
[],
[
'type' => 'string[]',
'description' => '',
],
];
yield 'file' => [
['file_param' => 'file|required'],
['file_param' => ['description' => $description]],
[
'description' => "$description. The value must be a file.",
'type' => 'file',
],
];
yield 'timezone' => [
['timezone_param' => 'timezone|required'],
[],
[
'description' => 'The value must be a valid time zone, such as <code>Africa/Accra</code>.',
'type' => 'string',
],
];
yield 'email' => [
['email_param' => 'email|required'],
[],
[
'description' => 'The value must be a valid email address.',
'type' => 'string',
],
];
yield 'url' => [
['url_param' => 'url|required'],
['url_param' => ['description' => $description]],
[
'description' => "$description. The value must be a valid URL.",
'type' => 'string',
],
];
yield 'ip' => [
['ip_param' => 'ip|required'],
['ip_param' => ['description' => $description]],
[
'description' => "$description. The value must be a valid IP address.",
'type' => 'string',
],
];
yield 'json' => [
['json_param' => 'json|required'],
['json_param' => []],
[
'description' => 'The value must be a valid JSON string.',
'type' => 'string',
],
];
yield 'date' => [
['date_param' => 'date|required'],
[],
[
'description' => 'The value must be a valid date.',
'type' => 'string',
],
];
yield 'date_format' => [
['date_format_param' => 'date_format:Y-m-d|required'],
['date_format_param' => ['description' => $description]],
[
'description' => "$description. The value must be a valid date in the format Y-m-d.",
'type' => 'string',
],
];
yield 'in' => [
['in_param' => 'in:3,5,6'],
['in_param' => ['description' => $description]],
[
'description' => "$description. The value must be one of <code>3</code>, <code>5</code>, or <code>6</code>.",
'type' => 'string',
],
];
}
}