diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2371de9d5..1a4635f3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: composer update --prefer-stable --prefer-dist --no-interaction - name: Execute tests - run: ./vendor/bin/testbench package:test --no-coverage + run: composer test - name: Get next version id: get_version diff --git a/src/Actions/Action.php b/src/Actions/Action.php index b0a80f86e..14658e0fd 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -2,9 +2,10 @@ namespace Binaryk\LaravelRestify\Actions; +use Binaryk\LaravelRestify\Actions\Concerns\HasSchemaResolver; use Binaryk\LaravelRestify\Http\Requests\ActionRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Binaryk\LaravelRestify\Models\Concerns\HasActionLogs; +use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Traits\AuthorizedToSee; use Binaryk\LaravelRestify\Traits\Make; @@ -16,6 +17,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Collection; use Illuminate\Support\Str; use JsonSerializable; @@ -29,6 +31,7 @@ abstract class Action implements JsonSerializable { use AuthorizedToSee; + use HasSchemaResolver; use Make; use ProxiesCanSeeToGate; use Visibility; @@ -58,11 +61,21 @@ public static function indexQuery(RestifyRequest $request, $query) */ public ?Closure $runCallback = null; + /** + * Action description, usually used in the UI or MCP. + */ + public string $description = ''; + public function name() { return Restify::humanize($this); } + public function description(RestifyRequest $request): string + { + return $this->description; + } + /** * Get the URI key for the action. */ @@ -191,11 +204,17 @@ public function skipFieldFill(RestifyRequest $request): bool return $this->skipFieldFill; } + public function toolSchema(JsonSchema $schema): array + { + return app(JsonSchemaFromRulesAction::class)($schema, $this->rules()); + } + #[ReturnTypeWillChange] public function jsonSerialize() { return array_merge([ 'name' => $this->name(), + 'description' => $this->description(app(RestifyRequest::class)), 'destructive' => $this instanceof DestructiveAction, 'uriKey' => $this->uriKey(), 'payload' => $this->payload(), diff --git a/src/Actions/Concerns/HasSchemaResolver.php b/src/Actions/Concerns/HasSchemaResolver.php new file mode 100644 index 000000000..2f26d5762 --- /dev/null +++ b/src/Actions/Concerns/HasSchemaResolver.php @@ -0,0 +1,59 @@ +rules(); + + foreach ($allRules as $field => $rules) { + if (str_contains($field, '.*') || str_contains($field, '.*.')) { + continue; + } + + // Check if this field has nested rules (e.g., employee.* exists) + $fieldType = $this->guessTypeFromValidationRules($rules, $field, $allRules); + + $schemaField = match ($fieldType) { + 'boolean' => $schema->boolean(), + 'number' => $schema->number(), + 'array' => $schema->array(), + default => $schema->string() + }; + + if ($this->isRequired($rules)) { + $schemaField->required(); + } + + $fields[$field] = $schemaField; + } + + if ($this->isStandalone()) { + return $fields; + } + + if ($this->isShownOnIndex(app(McpActionRequest::class), $this->repository)) { + $fields['repositories'] = $schema->array() + ->items( + $schema->string() + ) + ->required() + ->description("Array of {$modelName} IDs to run the {$actionName} action on."); + } else { + $fields['id'] = $schema->string() + ->description("The ID of the {$modelName} to run the {$actionName} action on.") + ->required(); + } + } +} diff --git a/src/Commands/GraphqlGenerateCommand.php b/src/Commands/GraphqlGenerateCommand.php index 3c9fe14be..cd8236953 100644 --- a/src/Commands/GraphqlGenerateCommand.php +++ b/src/Commands/GraphqlGenerateCommand.php @@ -2,6 +2,8 @@ namespace Binaryk\LaravelRestify\Commands; +use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; use Binaryk\LaravelRestify\Restify; use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; @@ -334,6 +336,9 @@ protected function generateInputType(string $repositoryClass, string $typeName): return "input {$typeName}Input {\n{$fieldsString}\n}"; } + /** + * @param Field $field + */ protected function mapFieldToGraphQLType($field, bool $isInput = false): string { $fieldClass = get_class($field); @@ -341,7 +346,7 @@ protected function mapFieldToGraphQLType($field, bool $isInput = false): string // Use the field's built-in type guessing if available if (method_exists($field, 'guessFieldType')) { - $fieldType = $field->guessFieldType(); + $fieldType = $field->guessFieldType(app(RepositoryStoreRequest::class)); switch ($fieldType) { case 'boolean': diff --git a/src/Fields/Concerns/CanMatch.php b/src/Fields/Concerns/CanMatch.php index 6b46ef68a..0e24c70dd 100644 --- a/src/Fields/Concerns/CanMatch.php +++ b/src/Fields/Concerns/CanMatch.php @@ -4,7 +4,12 @@ use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Filters\MatchFilter; +use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Illuminate\JsonSchema\Types\ArrayType; +use Illuminate\JsonSchema\Types\BooleanType; +use Illuminate\JsonSchema\Types\IntegerType; +use Illuminate\JsonSchema\Types\NumberType; trait CanMatch { @@ -36,7 +41,10 @@ public function matchable(mixed $column = null, ?string $type = null): self } $this->matchableColumn = $column ?? $this->getAttribute(); - $this->matchableType = $type ?? $this->guessMatchType(); + $this->matchableType = $type ?? $this->guessMatchType( + // we'll use the store request to identify rules and guess types + app(RepositoryStoreRequest::class) + ); return $this; } @@ -118,56 +126,15 @@ public function getMatchType(RestifyRequest $request = null): ?string return $this->matchableType; } - protected function guessMatchType(): string + protected function guessMatchType(RestifyRequest $request): string { - // Use field type detection from Field class if available - if (method_exists($this, 'guessFieldType')) { - $fieldType = $this->guessFieldType(); - - return match ($fieldType) { - 'boolean' => RestifySearchable::MATCH_BOOL, - 'number' => RestifySearchable::MATCH_INTEGER, - 'array' => RestifySearchable::MATCH_ARRAY, - default => RestifySearchable::MATCH_TEXT, - }; - } - - // Fallback to attribute name patterns - $attribute = $this->getAttribute(); - - if (! is_string($attribute)) { - return RestifySearchable::MATCH_TEXT; - } - - $attribute = strtolower($attribute); - - // Boolean patterns - if (preg_match('/^(is_|has_|can_|should_|will_|was_|were_)/', $attribute) || - in_array($attribute, - ['active', 'enabled', 'disabled', 'verified', 'published', 'featured', 'public', 'private'])) { - return RestifySearchable::MATCH_BOOL; - } - - // Number patterns - if (preg_match('/_(id|count|number|amount|price|cost|total|sum|quantity|qty)$/', $attribute) || - in_array($attribute, - ['id', 'age', 'year', 'month', 'day', 'hour', 'minute', 'second', 'weight', 'height', 'size'])) { - return RestifySearchable::MATCH_INTEGER; - } - - // Date patterns - if (preg_match('/_(at|date|time)$/', $attribute) || - in_array($attribute, - ['created_at', 'updated_at', 'deleted_at', 'published_at', 'birthday', 'date_of_birth'])) { - return RestifySearchable::MATCH_DATETIME; - } - - // Array patterns (JSON fields) - if (preg_match('/_(json|data|metadata|config|settings|options|tags)$/', $attribute)) { - return RestifySearchable::MATCH_ARRAY; - } - - // Default to text matching - return RestifySearchable::MATCH_TEXT; + $fieldType = $this->guessFieldType($request); + + return match (get_class($fieldType)) { + ArrayType::class => RestifySearchable::MATCH_ARRAY, + BooleanType::class => RestifySearchable::MATCH_BOOL, + IntegerType::class, NumberType::class => RestifySearchable::MATCH_INTEGER, + default => 'string', + }; } } diff --git a/src/Fields/Field.php b/src/Fields/Field.php index 71ea70925..aff28d3f8 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -10,13 +10,24 @@ use Binaryk\LaravelRestify\Fields\Concerns\ValidationMethods; use Binaryk\LaravelRestify\Fields\Contracts\Matchable; use Binaryk\LaravelRestify\Fields\Contracts\Sortable; +use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreBulkRequest; +use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; +use Binaryk\LaravelRestify\Http\Requests\RepositoryUpdateBulkRequest; +use Binaryk\LaravelRestify\Http\Requests\RepositoryUpdateRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\MCP\Concerns\FieldMcpSchemaDetection; +use Binaryk\LaravelRestify\MCP\Requests\McpRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpStoreBulkRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpStoreRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpUpdateBulkRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpUpdateRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Traits\Make; use Closure; use Illuminate\Contracts\Validation\Rule; use Illuminate\Database\Eloquent\Model; +use Illuminate\JsonSchema\JsonSchema; +use Illuminate\JsonSchema\Types\Type; use Illuminate\Support\Str; use Illuminate\Validation\Rules\Unique; use JsonSerializable; @@ -148,7 +159,12 @@ class Field extends OrganicField implements JsonSerializable, Matchable, Sortabl /** * Closure to modify the generated field description. */ - public $descriptionCallback = null; + public ?Closure $descriptionCallback = null; + + /** + * This is the resolved JsonSchema for the field during the MCP requests. + */ + public ?JsonSchema $jsonSchema = null; /** * Create a new field. @@ -484,6 +500,29 @@ public function getUpdatingBulkRules(): array return array_merge($this->rules, $this->updateBulkRules); } + public function getRulesForRequest(RestifyRequest $request): array + { + $rules = []; + + if ($request instanceof McpStoreRequest || $request instanceof RepositoryStoreRequest) { + $rules = $this->getStoringRules(); + } + + if ($request instanceof McpUpdateRequest || $request instanceof RepositoryUpdateRequest) { + $rules = $this->getUpdatingRules(); + } + + if ($request instanceof McpUpdateBulkRequest || $request instanceof RepositoryUpdateBulkRequest) { + $rules = $this->getUpdatingBulkRules(); + } + + if ($request instanceof McpStoreBulkRequest || $request instanceof RepositoryStoreBulkRequest) { + $rules = $this->getStoringBulkRules(); + } + + return $rules; + } + /** * Determine if the attribute is computed. * @@ -769,135 +808,6 @@ public function image(): Image return Image::make($this->attribute); } - /** - * Guess the field type based on validation rules, field class, and attribute patterns. - */ - public function guessFieldType(): string - { - $ruleType = $this->guessTypeFromValidationRules(); - if ($ruleType) { - return $ruleType; - } - - // Check attribute name patterns - $attributeType = $this->guessTypeFromAttributeName(); - if ($attributeType) { - return $attributeType; - } - - // Default to string - return 'string'; - } - - /** - * Guess type from validation rules. - */ - protected function guessTypeFromValidationRules(): ?string - { - $allRules = array_merge($this->rules, $this->storingRules, $this->updatingRules); - - // Convert rule objects to strings for checking - $ruleStrings = collect($allRules)->map(function ($rule) { - if (is_string($rule)) { - return $rule; - } - if (is_object($rule)) { - return get_class($rule); - } - - return (string) $rule; - })->toArray(); - - // Check for specific types - if ($this->hasAnyRule($ruleStrings, ['boolean', 'bool'])) { - return 'boolean'; - } - - if ($this->hasAnyRule($ruleStrings, ['email', 'url', 'ip', 'uuid', 'string', 'regex', 'array'])) { - return 'string'; - } - - if ($this->hasAnyRule($ruleStrings, ['date', 'date_format:', 'before:', 'after:', 'before_or_equal:', 'after_or_equal:'])) { - return 'string'; // Dates are typically handled as strings in schemas - } - - if ($this->hasAnyRule($ruleStrings, ['file', 'image', 'mimes:', 'mimetypes:'])) { - return 'string'; // Files are typically handled as strings (paths/URLs) - } - - if ($this->hasAnyRule($ruleStrings, ['integer', 'int', 'numeric', 'between:'])) { - return 'number'; - } - - return null; - } - - /** - * Guess type from attribute name patterns. - */ - protected function guessTypeFromAttributeName(): ?string - { - $attribute = $this->attribute; - - if (! is_string($attribute)) { - return null; - } - - $attribute = strtolower($attribute); - - // Boolean patterns - if (preg_match('/^(is_|has_|can_|should_|will_|was_|were_)/', $attribute) || - in_array($attribute, ['active', 'enabled', 'disabled', 'verified', 'published', 'featured', 'public', 'private'])) { - return 'boolean'; - } - - // Number patterns - if (preg_match('/_(id|count|number|amount|price|cost|total|sum|quantity|qty)$/', $attribute) || - in_array($attribute, ['id', 'age', 'year', 'month', 'day', 'hour', 'minute', 'second', 'weight', 'height', 'size'])) { - return 'number'; - } - - // Date patterns - if (preg_match('/_(at|date|time)$/', $attribute) || - in_array($attribute, ['created_at', 'updated_at', 'deleted_at', 'published_at', 'birthday', 'date_of_birth'])) { - return 'string'; - } - - // Email pattern - if (str_contains($attribute, 'email')) { - return 'string'; - } - - // Password pattern - if (str_contains($attribute, 'password')) { - return 'string'; - } - - // Array patterns (JSON fields) - if (preg_match('/_(json|data|metadata|config|settings|options)$/', $attribute) || - str_contains($attribute, 'tags')) { - return 'array'; - } - - return null; - } - - /** - * Check if any of the given rules exist in the rule strings. - */ - protected function hasAnyRule(array $ruleStrings, array $rulesToCheck): bool - { - foreach ($ruleStrings as $rule) { - foreach ($rulesToCheck as $check) { - if ($rule === $check || str_starts_with($rule, $check)) { - return true; - } - } - } - - return false; - } - /** * Set a custom callback for defining the tool schema. * @@ -927,4 +837,40 @@ public function description(string|callable|Closure $callback): self return $this; } + + public function jsonSchema(): ?JsonSchema + { + return $this->jsonSchema; + } + + public function resolveJsonSchema(JsonSchema $parentSchema, McpRequest $request, Repository $repository): self + { + if (is_callable($this->toolInputSchemaCallback)) { + $result = call_user_func($this->toolInputSchemaCallback, $parentSchema, $repository, $this); + if ($result instanceof Type) { + $this->jsonSchema = $result; + + return $this; + } + } + + // For MCP tools, we include computed fields that have resolve callbacks + // since they represent storable fields in MCP contexts + // Only skip truly computed fields without resolve callbacks + if ($this->computed() && ! $this->resolveCallback) { + return $this; + } + + if ($this->isReadonly(app(McpRequest::class))) { + return $this; + } + + $this->jsonSchema = $this->guessFieldType($request); + + $description = $this->getDescription($request, $repository); + + $this->jsonSchema->description($description); + + return $this; + } } diff --git a/src/Getters/Getter.php b/src/Getters/Getter.php index c9b9f3420..72003fa64 100644 --- a/src/Getters/Getter.php +++ b/src/Getters/Getter.php @@ -4,6 +4,7 @@ use Binaryk\LaravelRestify\Http\Requests\GetterRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Traits\AuthorizedToRun; use Binaryk\LaravelRestify\Traits\AuthorizedToSee; @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Arr; use Illuminate\Support\Str; use JsonSerializable; @@ -44,11 +46,34 @@ abstract class Getter implements JsonSerializable */ public $action; + /** + * Getter description, usually used in the UI or MCP. + */ + public string $description = ''; + public static function indexQuery(RestifyRequest $request, $query): void { // } + public function description(RestifyRequest $request): string + { + return $this->description; + } + + /** + * Validation rules to be applied to the getter parameters. + */ + public function rules(): array + { + return []; + } + + public function toolSchema(JsonSchema $schema): array + { + return app(JsonSchemaFromRulesAction::class)($schema, $this->rules()); + } + public function name(): string { return Restify::humanize($this); diff --git a/src/Http/Requests/GetterRequest.php b/src/Http/Requests/GetterRequest.php index 5fd5a98aa..aee74c88a 100644 --- a/src/Http/Requests/GetterRequest.php +++ b/src/Http/Requests/GetterRequest.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Http\Requests; use Binaryk\LaravelRestify\Getters\Getter; +use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; use Binaryk\LaravelRestify\Services\Search\RepositorySearchService; use Closure; use Illuminate\Database\Eloquent\Builder; @@ -69,6 +70,10 @@ public function collectRepositories(Getter $getter, $count, Closure $callback): public function isForRepositoryRequest(): bool { + if ($this instanceof McpGetterRequest) { + return $this->input('id') != null; + } + return $this instanceof RepositoryGetterRequest; } } diff --git a/src/MCP/Actions/JsonSchemaFromRulesAction.php b/src/MCP/Actions/JsonSchemaFromRulesAction.php new file mode 100644 index 000000000..324a43141 --- /dev/null +++ b/src/MCP/Actions/JsonSchemaFromRulesAction.php @@ -0,0 +1,163 @@ +> $allRules Associative array where keys are attribute names and values are arrays of validation rules + * @return array Array of JSON Schema types keyed by attribute name + */ + public function __invoke(JsonSchema $schema, array $allRules): array + { + $formatedRules = validator() + ->make([], $allRules) + ->getRules(); + + foreach ($formatedRules as $attribute => $rules) { + $this->buildTypeFromRules($schema, $attribute, $rules); + } + + $this->processWildcardRules($schema, $allRules); + + return $this->rulesSchema; + } + + public function buildTypeFromRules(JsonSchema $schema, string $attribute, array $rules): ?Type + { + foreach ($rules as $rule) { + $type = $this->buildTypeFromRule($schema, $attribute, $rule); + + if ($this->isAttributeRequired($attribute)) { + $type?->required(); + } + + if ($type) { + $this->rulesSchema[$attribute] = $type; + } + } + + return data_get($this->rulesSchema, $attribute); + } + + public function buildTypeFromRule(JsonSchema $schema, string $attribute, $rule): ?Type + { + $existingType = $this->rulesSchema[$attribute] ?? null; + + if ($rule instanceof Rule || $rule instanceof ValidationRule) { + $class = get_class($rule); + + if ($existingType) { + return $existingType; + } + + $schemaType = match (true) { + $rule instanceof Email => $schema->string() + ->description('This field must be a valid email address.'), + $rule instanceof File => $schema->string() + ->description('This field must be a valid file path.'), + $rule instanceof Password => $schema->string() + ->description('This field must be a valid password.'), + default => $schema->string() + ->description("This field uses a custom validation rule: {$class}."), + }; + + return $schemaType; + } + + if ($rule instanceof Closure) { + return $existingType ?? $schema->string() + ->description('This field uses a custom validation closure.'); + } + + [$rule, $parameters] = ValidationRuleParser::parse($rule); + + if ($rule === '') { + return null; + } + + $method = 'validate'.$rule; + + if (! method_exists($this, $method)) { + return null; + } + + return $this->$method($attribute, $schema, $parameters); + } + + protected function processWildcardRules(JsonSchema $schema, array $allRules): void + { + foreach ($allRules as $attribute => $rules) { + if (! str_ends_with($attribute, '.*')) { + continue; + } + + $parentField = substr($attribute, 0, -2); + + $itemType = null; + + foreach ($rules as $rule) { + // Use a unique attribute name to prevent circular references + // when the same type (e.g., 'array') is used for both parent and items + $uniqueAttribute = '_item_'.$parentField; + $type = $this->buildTypeFromRule($schema, $uniqueAttribute, $rule); + + if ($type) { + $itemType = $type; + } + } + + if ($itemType && isset($this->rulesSchema[$parentField]) && $this->rulesSchema[$parentField] instanceof ArrayType) { + $this->rulesSchema[$parentField]->items($itemType); + } + } + } + + public static function getPrimitiveTypeFromSchemaType(JsonSchema $schema): string + { + return match (get_class($schema)) { + ArrayType::class => 'array', + BooleanType::class => 'boolean', + IntegerType::class => 'integer', + NumberType::class => 'number', + ObjectType::class => 'object', + default => 'string', + }; + } + + protected function isAttributeRequired(string $attribute): bool + { + return in_array($attribute, $this->requiredAttributes, true); + } + + protected function markAttributeAsRequired(string $attribute): void + { + if (! in_array($attribute, $this->requiredAttributes, true)) { + $this->requiredAttributes[] = $attribute; + } + } +} diff --git a/src/MCP/Actions/SchemaAttributes.php b/src/MCP/Actions/SchemaAttributes.php new file mode 100644 index 000000000..bfbb244c9 --- /dev/null +++ b/src/MCP/Actions/SchemaAttributes.php @@ -0,0 +1,2822 @@ +rulesSchema[$attribute] ?? $schema->string(); + } + + /** + * Validate that an attribute was "accepted" when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateAcceptedIf($attribute, $value, $parameters) + { + $acceptable = ['yes', 'on', '1', 1, true, 'true']; + + $this->requireParameterCount(2, $parameters, 'accepted_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validateRequired($attribute, $value) && in_array($value, $acceptable, true); + } + + return true; + } + + /** + * Validate that an attribute was "declined". + * + * This validation rule implies the attribute is "required". + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateDeclined($attribute, $value) + { + $acceptable = ['no', 'off', '0', 0, false, 'false']; + + return $this->validateRequired($attribute, $value) && in_array($value, $acceptable, true); + } + + /** + * Validate that an attribute was "declined" when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateDeclinedIf($attribute, $value, $parameters) + { + $acceptable = ['no', 'off', '0', 0, false, 'false']; + + $this->requireParameterCount(2, $parameters, 'declined_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validateRequired($attribute, $value) && in_array($value, $acceptable, true); + } + + return true; + } + + /** + * Validate that an attribute is an active URL. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateActiveUrl($attribute, $value) + { + if (! is_string($value)) { + return false; + } + + if ($url = parse_url($value, PHP_URL_HOST)) { + try { + $records = $this->getDnsRecords($url.'.', DNS_A | DNS_AAAA); + + if (is_array($records) && count($records) > 0) { + return true; + } + } catch (Exception) { + return false; + } + } + + return false; + } + + /** + * Get the DNS records for the given hostname. + * + * @param string $hostname + * @param int $type + * @return array|false + */ + protected function getDnsRecords($hostname, $type) + { + return dns_get_record($hostname, $type); + } + + /** + * Validate that an attribute is 7 bit ASCII. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateAscii($attribute, $value) + { + return Str::isAscii($value); + } + + /** + * "Break" on first validation fail. + * + * Always returns true, just lets us put "bail" in rules. + * + * @return bool + */ + public function validateBail() + { + return true; + } + + /** + * Validate the date is before a given date. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateBefore(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + return $type->description("This is a date attribute. Must be before: {$parameters[0]}"); + } + + return $type; + } + + /** + * Validate the date is before or equal a given date. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateBeforeOrEqual(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + return $type->description("Must be before or equal to: {$parameters[0]}"); + } + + return $type; + } + + /** + * Validate the date is after a given date. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateAfter(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + return $type->description("Date attribute, must be after: {$parameters[0]}"); + } + + return $type; + } + + /** + * Validate the date is equal or after a given date. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateAfterOrEqual(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + return $type->description("Must be after or equal to: {$parameters[0]}"); + } + + return $type; + } + + /** + * Compare a given date against another using an operator. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @param string $operator + * @return bool + */ + protected function compareDates($attribute, $value, $parameters, $operator) + { + if (! is_string($value) && ! is_numeric($value) && ! $value instanceof DateTimeInterface) { + return false; + } + + if ($format = $this->getDateFormat($attribute)) { + return $this->checkDateTimeOrder($format, $value, $parameters[0], $operator); + } + + if (is_null($date = $this->getDateTimestamp($parameters[0]))) { + $date = $this->getDateTimestamp($this->getValue($parameters[0])); + } + + return $this->compare($this->getDateTimestamp($value), $date, $operator); + } + + /** + * Get the date format for an attribute if it has one. + * + * @param string $attribute + * @return string|null + */ + protected function getDateFormat($attribute) + { + if ($result = $this->getRule($attribute, 'DateFormat')) { + return $result[1][0]; + } + } + + /** + * Get the date timestamp. + * + * @param mixed $value + * @return int + */ + protected function getDateTimestamp($value) + { + $date = is_null($value) ? null : $this->getDateTime($value); + + return $date ? $date->getTimestamp() : null; + } + + /** + * Given two date/time strings, check that one is after the other. + * + * @param string $format + * @param string $first + * @param string $second + * @param string $operator + * @return bool + */ + protected function checkDateTimeOrder($format, $first, $second, $operator) + { + $firstDate = $this->getDateTimeWithOptionalFormat($format, $first); + + $format = $this->getDateFormat($second) ?: $format; + + if (! $secondDate = $this->getDateTimeWithOptionalFormat($format, $second)) { + if (is_null($second = $this->getValue($second))) { + return true; + } + + $secondDate = $this->getDateTimeWithOptionalFormat($format, $second); + } + + return ($firstDate && $secondDate) && $this->compare($firstDate, $secondDate, $operator); + } + + /** + * Get a DateTime instance from a string. + * + * @param string $format + * @param string $value + * @return \DateTime|null + */ + protected function getDateTimeWithOptionalFormat($format, $value) + { + if ($date = DateTime::createFromFormat('!'.$format, $value)) { + return $date; + } + + return $this->getDateTime($value); + } + + /** + * Get a DateTime instance from a string with no format. + * + * @param string $value + * @return \DateTime|null + */ + protected function getDateTime($value) + { + try { + return @Date::parse($value) ?: null; + } catch (Exception) { + // + } + } + + /** + * Validate that an attribute contains only alphabetic characters. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateAlpha(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must contain only alphabetic characters'); + } + + /** + * Validate that an attribute contains only alpha-numeric characters, dashes, and underscores. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateAlphaDash(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must contain only alpha-numeric characters, dashes, and underscores'); + } + + /** + * Validate that an attribute contains only alpha-numeric characters. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateAlphaNum(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must contain only alpha-numeric characters'); + } + + /** + * Validate that an attribute is an array. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateArray(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof ArrayType) { + return $existing; + } + + return $schema->array()->description('Must be an array'); + } + + /** + * Validate that an attribute is a list. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateList($attribute, $value) + { + return is_array($value) && array_is_list($value); + } + + /** + * Validate that an array has all of the given keys. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateRequiredArrayKeys($attribute, $value, $parameters) + { + if (! is_array($value)) { + return false; + } + + foreach ($parameters as $param) { + if (! Arr::exists($value, $param)) { + return false; + } + } + + return true; + } + + /** + * Validate the size of an attribute is between a set of values. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateBetween(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + $description = match (true) { + $type instanceof StringType => "Must be between {$parameters[0]} and {$parameters[1]} characters", + $type instanceof ArrayType => "Must have between {$parameters[0]} and {$parameters[1]} items", + default => "Must be between {$parameters[0]} and {$parameters[1]}", + }; + + return $type->min((int) $parameters[0])->max((int) $parameters[1])->description($description); + } + + /** + * Validate that an attribute is a boolean. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateBoolean(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof \Illuminate\JsonSchema\Types\BooleanType) { + return $existing; + } + + return $schema->boolean()->description('Must be a boolean (true/false)'); + } + + /** + * Validate that an attribute has a matching confirmation. + * + * @param string $attribute + * @param mixed $value + * @param array{0: string} $parameters + * @return bool + */ + public function validateConfirmed($attribute, $value, $parameters) + { + return $this->validateSame($attribute, $value, [$parameters[0] ?? $attribute.'_confirmation']); + } + + /** + * Validate an attribute contains a list of values. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateContains(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->array(); + + if (! empty($parameters)) { + $values = implode(', ', $parameters); + + return $type->description("Must contain: {$values}"); + } + + return $type; + } + + /** + * Validate an attribute does not contain a list of values. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateDoesntContain(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->array(); + + if (! empty($parameters)) { + $values = implode(', ', $parameters); + + return $type->description("Must not contain: {$values}"); + } + + return $type; + } + + /** + * Validate that the password of the currently authenticated user matches the given value. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + protected function validateCurrentPassword($attribute, $value, $parameters) + { + $auth = $this->container->make('auth'); + $hasher = $this->container->make('hash'); + + $guard = $auth->guard(Arr::first($parameters)); + + if ($guard->guest()) { + return false; + } + + return $hasher->check($value, $guard->user()->getAuthPassword()); + } + + /** + * Validate that an attribute is a valid date. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateDate(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid date format.'); + } + + /** + * Validate that an attribute matches a date format. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateDateFormat(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + $formats = implode(', ', $parameters); + $description = count($parameters) > 1 + ? "Must match one of date formats: {$formats}" + : "Must match date format: {$formats}"; + + return $type->description($description); + } + + return $type; + } + + /** + * Validate that an attribute is equal to another date. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateDateEquals(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + return $type->description("Must be equal to date: {$parameters[0]}"); + } + + return $type; + } + + /** + * Validate that an attribute has a given number of decimal places. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateDecimal($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'decimal'); + + if (! $this->validateNumeric($attribute, $value, [])) { + return false; + } + + $matches = []; + + if (preg_match('/^[+-]?\d*\.?(\d*)$/', $value, $matches) !== 1) { + return false; + } + + $decimals = strlen(end($matches)); + + if (! isset($parameters[1])) { + return $decimals == $parameters[0]; + } + + return $decimals >= $parameters[0] && + $decimals <= $parameters[1]; + } + + /** + * Validate that an attribute is different from another attribute. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateDifferent($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'different'); + + foreach ($parameters as $parameter) { + if (Arr::has($this->data, $parameter)) { + $other = Arr::get($this->data, $parameter); + + if ($value === $other) { + return false; + } + } + } + + return true; + } + + /** + * Validate that an attribute has a given number of digits. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateDigits($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'digits'); + + return ! preg_match('/[^0-9]/', $value) + && strlen((string) $value) == $parameters[0]; + } + + /** + * Validate that an attribute is between a given number of digits. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateDigitsBetween($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'digits_between'); + + $length = strlen((string) $value); + + return ! preg_match('/[^0-9]/', $value) + && $length >= $parameters[0] && $length <= $parameters[1]; + } + + /** + * Validate the dimensions of an image matches the given values. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateDimensions($attribute, $value, $parameters) + { + if ($this->isValidFileInstance($value) && in_array($value->getMimeType(), ['image/svg+xml', 'image/svg'])) { + return true; + } + + if (! $this->isValidFileInstance($value)) { + return false; + } + + $dimensions = method_exists($value, 'dimensions') + ? $value->dimensions() + : @getimagesize($value->getRealPath()); + + if (! $dimensions) { + return false; + } + + $this->requireParameterCount(1, $parameters, 'dimensions'); + + [$width, $height] = $dimensions; + + $parameters = $this->parseNamedParameters($parameters); + + return ! ( + $this->failsBasicDimensionChecks($parameters, $width, $height) || + $this->failsRatioCheck($parameters, $width, $height) || + $this->failsMinRatioCheck($parameters, $width, $height) || + $this->failsMaxRatioCheck($parameters, $width, $height) + ); + } + + /** + * Test if the given width and height fail any conditions. + * + * @param array $parameters + * @param int $width + * @param int $height + * @return bool + */ + protected function failsBasicDimensionChecks($parameters, $width, $height) + { + return (isset($parameters['width']) && $parameters['width'] != $width) || + (isset($parameters['min_width']) && $parameters['min_width'] > $width) || + (isset($parameters['max_width']) && $parameters['max_width'] < $width) || + (isset($parameters['height']) && $parameters['height'] != $height) || + (isset($parameters['min_height']) && $parameters['min_height'] > $height) || + (isset($parameters['max_height']) && $parameters['max_height'] < $height); + } + + /** + * Determine if the given parameters fail a dimension ratio check. + * + * @param array $parameters + * @param int $width + * @param int $height + * @return bool + */ + protected function failsRatioCheck($parameters, $width, $height) + { + if (! isset($parameters['ratio'])) { + return false; + } + + [$numerator, $denominator] = array_replace( + [1, 1], array_filter(sscanf($parameters['ratio'], '%f/%d')) + ); + + $precision = 1 / (max(($width + $height) / 2, $height) + 1); + + return abs($numerator / $denominator - $width / $height) > $precision; + } + + /** + * Determine if the given parameters fail a dimension minimum ratio check. + * + * @param array $parameters + * @param int $width + * @param int $height + * @return bool + */ + private function failsMinRatioCheck($parameters, $width, $height) + { + if (! isset($parameters['min_ratio'])) { + return false; + } + + [$minNumerator, $minDenominator] = array_replace( + [1, 1], array_filter(sscanf($parameters['min_ratio'], '%f/%d')) + ); + + return ($width / $height) > ($minNumerator / $minDenominator); + } + + /** + * Determine if the given parameters fail a dimension maximum ratio check. + * + * @param array $parameters + * @param int $width + * @param int $height + * @return bool + */ + private function failsMaxRatioCheck($parameters, $width, $height) + { + if (! isset($parameters['max_ratio'])) { + return false; + } + + [$maxNumerator, $maxDenominator] = array_replace( + [1, 1], array_filter(sscanf($parameters['max_ratio'], '%f/%d')) + ); + + return ($width / $height) < ($maxNumerator / $maxDenominator); + } + + /** + * Validate an attribute is unique among other values. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateDistinct($attribute, $value, $parameters) + { + $data = Arr::except($this->getDistinctValues($attribute), $attribute); + + if (in_array('ignore_case', $parameters)) { + return empty(preg_grep('/^'.preg_quote($value, '/').'$/iu', $data)); + } + + return ! in_array($value, array_values($data), in_array('strict', $parameters)); + } + + /** + * Get the values to distinct between. + * + * @param string $attribute + * @return array + */ + protected function getDistinctValues($attribute) + { + $attributeName = $this->getPrimaryAttribute($attribute); + + if (! property_exists($this, 'distinctValues')) { + return $this->extractDistinctValues($attributeName); + } + + if (! array_key_exists($attributeName, $this->distinctValues)) { + $this->distinctValues[$attributeName] = $this->extractDistinctValues($attributeName); + } + + return $this->distinctValues[$attributeName]; + } + + /** + * Extract the distinct values from the data. + * + * @param string $attribute + * @return array + */ + protected function extractDistinctValues($attribute) + { + $attributeData = ValidationData::extractDataFromPath( + ValidationData::getLeadingExplicitAttributePath($attribute), $this->data + ); + + $pattern = str_replace('\*', '[^.]+', preg_quote($attribute, '#')); + + return Arr::where(Arr::dot($attributeData), function ($value, $key) use ($pattern) { + return (bool) preg_match('#^'.$pattern.'\z#u', $key); + }); + } + + /** + * Validate that an attribute is a valid e-mail address. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateEmail(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid email address.'); + } + + /** + * Validate the existence of an attribute value in a database table. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateExists(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + return $type->description('Must exist in the database'); + } + + /** + * Get the number of records that exist in storage. + * + * @param mixed $connection + * @param string $table + * @param string $column + * @param mixed $value + * @param array $parameters + * @return int + */ + protected function getExistCount($connection, $table, $column, $value, $parameters) + { + $verifier = $this->getPresenceVerifier($connection); + + $extra = $this->getExtraConditions( + array_values(array_slice($parameters, 2)) + ); + + if ($this->currentRule instanceof Exists) { + $extra = array_merge($extra, $this->currentRule->queryCallbacks()); + } + + return is_array($value) + ? $verifier->getMultiCount($table, $column, $value, $extra) + : $verifier->getCount($table, $column, $value, null, null, $extra); + } + + /** + * Validate the uniqueness of an attribute value on a given database table. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateUnique(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + return $type->description('Must be unique in the database.'); + } + + /** + * Get the excluded ID column and value for the unique rule. + * + * @param string|null $idColumn + * @param array $parameters + * @return array + */ + protected function getUniqueIds($idColumn, $parameters) + { + $idColumn ??= $parameters[3] ?? 'id'; + + return [$idColumn, $this->prepareUniqueId($parameters[2])]; + } + + /** + * Prepare the given ID for querying. + * + * @param mixed $id + * @return int + */ + protected function prepareUniqueId($id) + { + if (preg_match('/\[(.*)\]/', $id, $matches)) { + $id = $this->getValue($matches[1]); + } + + if (strtolower($id) === 'null') { + $id = null; + } + + if (filter_var($id, FILTER_VALIDATE_INT) !== false) { + $id = (int) $id; + } + + return $id; + } + + /** + * Get the extra conditions for a unique rule. + * + * @param array $parameters + * @return array + */ + protected function getUniqueExtra($parameters) + { + if (isset($parameters[4])) { + return $this->getExtraConditions(array_slice($parameters, 4)); + } + + return []; + } + + /** + * Parse the connection / table for the unique / exists rules. + * + * @param string $table + * @return array + */ + public function parseTable($table) + { + [$connection, $table] = str_contains($table, '.') ? explode('.', $table, 2) : [null, $table]; + + if (str_contains($table, '\\') && class_exists($table) && is_a($table, Model::class, true)) { + $model = new $table; + + $table = $model->getTable(); + $connection ??= $model->getConnectionName(); + + if (str_contains($table, '.') && Str::startsWith($table, $connection)) { + $connection = null; + } + + $idColumn = $model->getKeyName(); + } + + return [$connection, $table, $idColumn ?? null]; + } + + /** + * Get the column name for an exists / unique query. + * + * @param array $parameters + * @param string $attribute + * @return int|string + */ + public function getQueryColumn($parameters, $attribute) + { + return isset($parameters[1]) && $parameters[1] !== 'NULL' + ? $parameters[1] + : $this->guessColumnForQuery($attribute); + } + + /** + * Guess the database column from the given attribute name. + * + * @param string $attribute + * @return string + */ + public function guessColumnForQuery($attribute) + { + if (in_array($attribute, Arr::collapse($this->implicitAttributes)) + && ! is_numeric($last = last(explode('.', $attribute)))) { + return $last; + } + + return $attribute; + } + + /** + * Get the extra conditions for a unique / exists rule. + * + * @return array + */ + protected function getExtraConditions(array $segments) + { + $extra = []; + + $count = count($segments); + + for ($i = 0; $i < $count; $i += 2) { + $extra[$segments[$i]] = $segments[$i + 1]; + } + + return $extra; + } + + /** + * Validate the extension of a file upload attribute is in a set of defined extensions. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateExtensions($attribute, $value, $parameters) + { + if (! $this->isValidFileInstance($value)) { + return false; + } + + if ($this->shouldBlockPhpUpload($value, $parameters)) { + return false; + } + + return in_array(strtolower($value->getClientOriginalExtension()), $parameters); + } + + /** + * Validate the given value is a valid file. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateFile(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid file'); + } + + /** + * Validate the given attribute is filled if it is present. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateFilled($attribute, $value) + { + if (Arr::has($this->data, $attribute)) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute is greater than another attribute. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateGt($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'gt'); + + $comparedToValue = $this->getValue($parameters[0]); + + $this->shouldBeNumeric($attribute, 'Gt'); + + if (is_null($comparedToValue) && (is_numeric($value) && is_numeric($parameters[0]))) { + try { + return BigNumber::of($this->getSize($attribute, $value))->isGreaterThan($this->trim($parameters[0])); + } catch (MathException) { + return false; + } + } + + if (is_numeric($parameters[0])) { + return false; + } + + if ($this->hasRule($attribute, $this->numericRules) && is_numeric($value) && is_numeric($comparedToValue)) { + try { + return BigNumber::of($this->trim($value))->isGreaterThan($this->trim($comparedToValue)); + } catch (MathException) { + return false; + } + } + + if (! $this->isSameType($value, $comparedToValue)) { + return false; + } + + try { + return $this->getSize($attribute, $value) > $this->getSize($attribute, $comparedToValue); + } catch (MathException) { + return false; + } + } + + /** + * Validate that an attribute is less than another attribute. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateLt($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'lt'); + + $comparedToValue = $this->getValue($parameters[0]); + + $this->shouldBeNumeric($attribute, 'Lt'); + + if (is_null($comparedToValue) && (is_numeric($value) && is_numeric($parameters[0]))) { + try { + return BigNumber::of($this->getSize($attribute, $value))->isLessThan($this->trim($parameters[0])); + } catch (MathException) { + return false; + } + } + + if (is_numeric($parameters[0])) { + return false; + } + + if ($this->hasRule($attribute, $this->numericRules) && is_numeric($value) && is_numeric($comparedToValue)) { + return BigNumber::of($this->trim($value))->isLessThan($this->trim($comparedToValue)); + } + + if (! $this->isSameType($value, $comparedToValue)) { + return false; + } + + try { + return $this->getSize($attribute, $value) < $this->getSize($attribute, $comparedToValue); + } catch (MathException) { + return false; + } + } + + /** + * Validate that an attribute is greater than or equal another attribute. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateGte($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'gte'); + + $comparedToValue = $this->getValue($parameters[0]); + + $this->shouldBeNumeric($attribute, 'Gte'); + + if (is_null($comparedToValue) && (is_numeric($value) && is_numeric($parameters[0]))) { + try { + return BigNumber::of($this->getSize($attribute, + $value))->isGreaterThanOrEqualTo($this->trim($parameters[0])); + } catch (MathException) { + return false; + } + } + + if (is_numeric($parameters[0])) { + return false; + } + + if ($this->hasRule($attribute, $this->numericRules) && is_numeric($value) && is_numeric($comparedToValue)) { + try { + return BigNumber::of($this->trim($value))->isGreaterThanOrEqualTo($this->trim($comparedToValue)); + } catch (MathException) { + return false; + } + } + + if (! $this->isSameType($value, $comparedToValue)) { + return false; + } + + try { + return $this->getSize($attribute, $value) >= $this->getSize($attribute, $comparedToValue); + } catch (MathException) { + return false; + } + } + + /** + * Validate that an attribute is less than or equal another attribute. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateLte($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'lte'); + + $comparedToValue = $this->getValue($parameters[0]); + + $this->shouldBeNumeric($attribute, 'Lte'); + + if (is_null($comparedToValue) && (is_numeric($value) && is_numeric($parameters[0]))) { + try { + return BigNumber::of($this->getSize($attribute, + $value))->isLessThanOrEqualTo($this->trim($parameters[0])); + } catch (MathException) { + return false; + } + } + + if (is_numeric($parameters[0])) { + return false; + } + + if ($this->hasRule($attribute, $this->numericRules) && is_numeric($value) && is_numeric($comparedToValue)) { + return BigNumber::of($this->trim($value))->isLessThanOrEqualTo($this->trim($comparedToValue)); + } + + if (! $this->isSameType($value, $comparedToValue)) { + return false; + } + + try { + return $this->getSize($attribute, $value) <= $this->getSize($attribute, $comparedToValue); + } catch (MathException) { + return false; + } + } + + /** + * Validate that an attribute is lowercase. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateLowercase($attribute, $value, $parameters) + { + return Str::lower($value) === $value; + } + + /** + * Validate that an attribute is uppercase. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateUppercase($attribute, $value, $parameters) + { + return Str::upper($value) === $value; + } + + /** + * Validate that an attribute is a valid HEX color. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateHexColor($attribute, $value) + { + return preg_match('/^#(?:(?:[0-9a-f]{3}){1,2}|(?:[0-9a-f]{4}){1,2})$/i', $value) === 1; + } + + /** + * Validate the MIME type of a file is an image MIME type. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateImage(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid image file'); + } + + /** + * Validate an attribute is contained within a list of values. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateIn(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + $values = implode(', ', $parameters); + + return $type->enum($parameters)->description("Must be one of: {$values}"); + } + + return $type->enum($parameters); + } + + /** + * Validate that the values of an attribute are in another attribute. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateInArray(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + return $type->description("Must be a value from {$parameters[0]}"); + } + + return $type; + } + + /** + * Validate that an array has at least one of the given keys. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateInArrayKeys($attribute, $value, $parameters) + { + if (! is_array($value)) { + return false; + } + + if (empty($parameters)) { + return false; + } + + foreach ($parameters as $param) { + if (Arr::exists($value, $param)) { + return true; + } + } + + return false; + } + + /** + * Validate that an attribute is an integer. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateInteger(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof \Illuminate\JsonSchema\Types\IntegerType) { + return $existing; + } + + return $schema->integer()->description('Must be an integer'); + } + + /** + * Validate that an attribute is a valid IP. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateIp(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid IP address'); + } + + /** + * Validate that an attribute is a valid IPv4. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateIpv4(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid IPv4 address'); + } + + /** + * Validate that an attribute is a valid IPv6. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateIpv6(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid IPv6 address'); + } + + /** + * Validate that an attribute is a valid MAC address. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateMacAddress(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid MAC address'); + } + + /** + * Validate the attribute is a valid JSON string. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateJson(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be valid JSON'); + } + + /** + * Validate the size of an attribute is less than or equal to a maximum value. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateMax(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + $description = match (true) { + $type instanceof StringType => "Maximum length: {$parameters[0]} characters", + $type instanceof ArrayType => "Maximum items: {$parameters[0]}", + default => "Maximum value: {$parameters[0]}", + }; + + return $type->max((int) $parameters[0])->description($description); + } + + return $type->max((int) $parameters[0]); + } + + /** + * Validate that an attribute has a maximum number of digits. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMaxDigits($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'max_digits'); + + $length = strlen((string) $value); + + return ! preg_match('/[^0-9]/', $value) && $length <= $parameters[0]; + } + + /** + * Validate the guessed extension of a file upload is in a set of file extensions. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateMimes(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + if (! empty($parameters)) { + $extensions = implode(', ', $parameters); + + return $schema->string()->description("Allowed file extensions: {$extensions}"); + } + + return $schema->string()->description('Must be a valid file with allowed extension'); + } + + /** + * Validate the MIME type of a file upload attribute is in a set of MIME types. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateMimetypes(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + if (! empty($parameters)) { + $types = implode(', ', $parameters); + + return $schema->string()->description("Allowed MIME types: {$types}"); + } + + return $schema->string()->description('Must be a valid file with allowed MIME type'); + } + + /** + * Check if PHP uploads are explicitly allowed. + * + * @param mixed $value + * @param array $parameters + * @return bool + */ + protected function shouldBlockPhpUpload($value, $parameters) + { + if (in_array('php', $parameters)) { + return false; + } + + $phpExtensions = [ + 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', + ]; + + return ($value instanceof UploadedFile) + ? in_array(trim(strtolower($value->getClientOriginalExtension())), $phpExtensions) + : in_array(trim(strtolower($value->getExtension())), $phpExtensions); + } + + /** + * Validate the size of an attribute is greater than or equal to a minimum value. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateMin(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + $description = match (true) { + $type instanceof StringType => "Minimum length: {$parameters[0]} characters", + $type instanceof ArrayType => "Minimum items: {$parameters[0]}", + default => "Minimum value: {$parameters[0]}", + }; + + return $type->min((int) $parameters[0])->description($description); + } + + return $type->min((int) $parameters[0]); + } + + /** + * Validate that an attribute has a minimum number of digits. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMinDigits($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'min_digits'); + + $length = strlen((string) $value); + + return ! preg_match('/[^0-9]/', $value) && $length >= $parameters[0]; + } + + /** + * Validate that an attribute is missing. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMissing($attribute, $value, $parameters) + { + return ! Arr::has($this->data, $attribute); + } + + /** + * Validate that an attribute is missing when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMissingIf($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'missing_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validateMissing($attribute, $value, $parameters); + } + + return true; + } + + /** + * Validate that an attribute is missing unless another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMissingUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'missing_unless'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (! in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validateMissing($attribute, $value, $parameters); + } + + return true; + } + + /** + * Validate that an attribute is missing when any given attribute is present. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMissingWith($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'missing_with'); + + if (Arr::hasAny($this->data, $parameters)) { + return $this->validateMissing($attribute, $value, $parameters); + } + + return true; + } + + /** + * Validate that an attribute is missing when all given attributes are present. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMissingWithAll($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'missing_with_all'); + + if (Arr::has($this->data, $parameters)) { + return $this->validateMissing($attribute, $value, $parameters); + } + + return true; + } + + /** + * Validate the value of an attribute is a multiple of a given value. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMultipleOf($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'multiple_of'); + + if (! $this->validateNumeric($attribute, $value, []) || ! $this->validateNumeric($attribute, $parameters[0], + [])) { + return false; + } + + try { + $numerator = BigDecimal::of($this->trim($value)); + $denominator = BigDecimal::of($this->trim($parameters[0])); + + if ($numerator->isZero() && $denominator->isZero()) { + return false; + } + + if ($numerator->isZero()) { + return true; + } + + if ($denominator->isZero()) { + return false; + } + + return $numerator->remainder($denominator)->isZero(); + } catch (BrickMathException $e) { + throw new MathException('An error occurred while handling the multiple_of input values.', previous: $e); + } + } + + /** + * "Indicate" validation should pass if value is null. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateNullable(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + return $type->description('This field is optional'); + } + + /** + * Validate an attribute is not contained within a list of values. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateNotIn($attribute, $value, $parameters) + { + return ! $this->validateIn($attribute, $value, $parameters); + } + + /** + * Validate that an attribute is numeric. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateNumeric(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof \Illuminate\JsonSchema\Types\NumberType) { + return $existing; + } + + $newType = $schema->number()->description('Must be a numeric value'); + + if ($existing && property_exists($existing, 'isRequired') && $existing->isRequired) { + $newType->required(); + } + + return $newType; + } + + /** + * Validate that an attribute exists even if not filled. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validatePresent($attribute, $value) + { + return Arr::has($this->data, $attribute); + } + + /** + * Validate that an attribute is present when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validatePresentIf($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'present_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validatePresent($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute is present unless another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validatePresentUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'present_unless'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (! in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validatePresent($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute is present when any given attribute is present. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validatePresentWith($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'present_with'); + + if (Arr::hasAny($this->data, $parameters)) { + return $this->validatePresent($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute is present when all given attributes are present. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validatePresentWithAll($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'present_with_all'); + + if (Arr::has($this->data, $parameters)) { + return $this->validatePresent($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute passes a regular expression check. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateRegex($attribute, $value, $parameters) + { + if (! is_string($value) && ! is_numeric($value)) { + return false; + } + + $this->requireParameterCount(1, $parameters, 'regex'); + + return preg_match($parameters[0], $value) > 0; + } + + /** + * Validate that an attribute does not pass a regular expression check. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateNotRegex($attribute, $value, $parameters) + { + if (! is_string($value) && ! is_numeric($value)) { + return false; + } + + $this->requireParameterCount(1, $parameters, 'not_regex'); + + return preg_match($parameters[0], $value) < 1; + } + + /** + * Validate that a required attribute exists. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateRequired(string $attribute, $schema, array $parameters) + { + $this->markAttributeAsRequired($attribute); + + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + return $type->required()->description('This field is required'); + } + + /** + * Validate that an attribute exists when another attribute has a given value. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateRequiredIf(string $attribute, $schema, array $parameters) + { + return $this->rulesSchema[$attribute] ?? $schema->string(); + } + + /** + * Validate that an attribute exists when another attribute was "accepted". + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateRequiredIfAccepted($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'required_if_accepted'); + + if ($this->validateAccepted($parameters[0], $this->getValue($parameters[0]))) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute exists when another attribute was "declined". + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateRequiredIfDeclined($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'required_if_declined'); + + if ($this->validateDeclined($parameters[0], $this->getValue($parameters[0]))) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute does not exist or is an empty string. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateProhibited($attribute, $value) + { + return ! $this->validateRequired($attribute, $value); + } + + /** + * Validate that an attribute does not exist when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibitedIf($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'prohibited_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other) || is_null($other))) { + return ! $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute does not exist when another attribute was "accepted". + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibitedIfAccepted($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'prohibited_if_accepted'); + + if ($this->validateAccepted($parameters[0], $this->getValue($parameters[0]))) { + return $this->validateProhibited($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute does not exist when another attribute was "declined". + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibitedIfDeclined($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'prohibited_if_declined'); + + if ($this->validateDeclined($parameters[0], $this->getValue($parameters[0]))) { + return $this->validateProhibited($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute does not exist unless another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibitedUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'prohibited_unless'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (! in_array($other, $values, is_bool($other) || is_null($other))) { + return ! $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that other attributes do not exist when this attribute exists. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibits($attribute, $value, $parameters) + { + if ($this->validateRequired($attribute, $value)) { + foreach ($parameters as $parameter) { + if ($this->validateRequired($parameter, Arr::get($this->data, $parameter))) { + return false; + } + } + } + + return true; + } + + /** + * Indicate that an attribute is excluded. + * + * @return bool + */ + public function validateExclude() + { + return false; + } + + /** + * Indicate that an attribute should be excluded when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateExcludeIf($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'exclude_if'); + + if (! Arr::has($this->data, $parameters[0])) { + return true; + } + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + return ! in_array($other, $values, is_bool($other) || is_null($other)); + } + + /** + * Indicate that an attribute should be excluded when another attribute does not have a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateExcludeUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'exclude_unless'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + return in_array($other, $values, is_bool($other) || is_null($other)); + } + + /** + * Validate that an attribute exists when another attribute does not have a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateRequiredUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'required_unless'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (! in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Indicate that an attribute should be excluded when another attribute presents. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateExcludeWith($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'exclude_with'); + + if (! Arr::has($this->data, $parameters[0])) { + return true; + } + + return false; + } + + /** + * Indicate that an attribute should be excluded when another attribute is missing. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateExcludeWithout($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'exclude_without'); + + if ($this->anyFailingRequired($parameters)) { + return false; + } + + return true; + } + + /** + * Prepare the values and the other value for validation. + * + * @param array $parameters + * @return array + */ + public function parseDependentRuleParameters($parameters) + { + $other = Arr::get($this->data, $parameters[0]); + + $values = array_slice($parameters, 1); + + if ($this->shouldConvertToBoolean($parameters[0]) || is_bool($other)) { + $values = $this->convertValuesToBoolean($values); + } + + if (is_null($other)) { + $values = $this->convertValuesToNull($values); + } + + return [$values, $other]; + } + + /** + * Check if parameter should be converted to boolean. + * + * @param string $parameter + * @return bool + */ + protected function shouldConvertToBoolean($parameter) + { + return in_array('boolean', $this->rules[$parameter] ?? []); + } + + /** + * Convert the given values to boolean if they are string "true" / "false". + * + * @param array $values + * @return array + */ + protected function convertValuesToBoolean($values) + { + return array_map(function ($value) { + if ($value === 'true') { + return true; + } elseif ($value === 'false') { + return false; + } + + return $value; + }, $values); + } + + /** + * Convert the given values to null if they are string "null". + * + * @param array $values + * @return array + */ + protected function convertValuesToNull($values) + { + return array_map(function ($value) { + return Str::lower($value) === 'null' ? null : $value; + }, $values); + } + + /** + * Validate that an attribute exists when any other attribute exists. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateRequiredWith($attribute, $value, $parameters) + { + if (! $this->allFailingRequired($parameters)) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute exists when all other attributes exist. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateRequiredWithAll($attribute, $value, $parameters) + { + if (! $this->anyFailingRequired($parameters)) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute exists when another attribute does not. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateRequiredWithout($attribute, $value, $parameters) + { + if ($this->anyFailingRequired($parameters)) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute exists when all other attributes do not. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateRequiredWithoutAll($attribute, $value, $parameters) + { + if ($this->allFailingRequired($parameters)) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Determine if any of the given attributes fail the required test. + * + * @return bool + */ + protected function anyFailingRequired(array $attributes) + { + foreach ($attributes as $key) { + if (! $this->validateRequired($key, $this->getValue($key))) { + return true; + } + } + + return false; + } + + /** + * Determine if all of the given attributes fail the required test. + * + * @return bool + */ + protected function allFailingRequired(array $attributes) + { + foreach ($attributes as $key) { + if ($this->validateRequired($key, $this->getValue($key))) { + return false; + } + } + + return true; + } + + /** + * Validate that two attributes match. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateSame(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + return $type->description("Must match: {$parameters[0]}"); + } + + return $type; + } + + /** + * Validate the size of an attribute. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateSize(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + $description = match (true) { + $type instanceof StringType => "Must be exactly {$parameters[0]} characters", + $type instanceof ArrayType => "Must contain exactly {$parameters[0]} items", + default => "Must be exactly {$parameters[0]}", + }; + + return $type->min((int) $parameters[0])->max((int) $parameters[0])->description($description); + } + + /** + * "Validate" optional attributes. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateSometimes(string $attribute, $schema, array $parameters) + { + return $this->rulesSchema[$attribute] ?? $schema->string(); + } + + /** + * Validate the attribute starts with a given substring (schema version). + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateStartsWith(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + $values = implode(', ', $parameters); + + return $type->description("Must start with: {$values}"); + } + + return $type; + } + + /** + * Validate the attribute does not start with a given substring (schema version). + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateDoesntStartWith(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + $values = implode(', ', $parameters); + + return $type->description("Must not start with: {$values}"); + } + + return $type; + } + + /** + * Validate the attribute ends with a given substring (schema version). + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateEndsWith(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + $values = implode(', ', $parameters); + + return $type->description("Must end with: {$values}"); + } + + return $type; + } + + /** + * Validate the attribute does not end with a given substring (schema version). + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateDoesntEndWith(string $attribute, $schema, array $parameters) + { + $type = $this->rulesSchema[$attribute] ?? $schema->string(); + + if (! empty($parameters)) { + $values = implode(', ', $parameters); + + return $type->description("Must not end with: {$values}"); + } + + return $type; + } + + /** + * Validate that an attribute is a string. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateString(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + $newType = $schema->string()->description('Must be a string'); + + if ($existing && property_exists($existing, 'isRequired') && $existing->isRequired) { + $newType->required(); + } + + return $newType; + } + + /** + * Validate that an attribute is a valid timezone. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateTimezone(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid timezone'); + } + + /** + * Validate that an attribute is a valid URL. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateUrl(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid URL'); + } + + /** + * Validate that an attribute is a valid ULID. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateUlid(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid ULID'); + } + + /** + * Validate that an attribute is a valid UUID. + * + * @param \Illuminate\JsonSchema\JsonSchema $schema + * @return \Illuminate\JsonSchema\Types\Type + */ + public function validateUuid(string $attribute, $schema, array $parameters) + { + $existing = $this->rulesSchema[$attribute] ?? null; + + if ($existing instanceof StringType) { + return $existing; + } + + return $schema->string()->description('Must be a valid UUID'); + } + + /** + * Get the size of an attribute. + * + * @param string $attribute + * @param mixed $value + * @return int|float|string + */ + protected function getSize($attribute, $value) + { + $hasNumeric = $this->hasRule($attribute, $this->numericRules); + + // This method will determine if the attribute is a number, string, or file and + // return the proper size accordingly. If it is a number, then number itself + // is the size. If it is a file, we take kilobytes, and for a string the + // entire length of the string will be considered the attribute size. + if (is_numeric($value) && $hasNumeric) { + return $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value)); + } elseif (is_array($value)) { + return count($value); + } elseif ($value instanceof File) { + return $value->getSize() / 1024; + } + + return mb_strlen($value ?? ''); + } + + /** + * Check that the given value is a valid file instance. + * + * @param mixed $value + * @return bool + */ + public function isValidFileInstance($value) + { + if ($value instanceof UploadedFile && ! $value->isValid()) { + return false; + } + + return $value instanceof File; + } + + /** + * Determine if a comparison passes between the given values. + * + * @param mixed $first + * @param mixed $second + * @param string $operator + * @return bool + * + * @throws \InvalidArgumentException + */ + protected function compare($first, $second, $operator) + { + return match ($operator) { + '<' => $first < $second, + '>' => $first > $second, + '<=' => $first <= $second, + '>=' => $first >= $second, + '=' => $first == $second, + default => throw new InvalidArgumentException, + }; + } + + /** + * Parse named parameters to $key => $value items. + * + * @param array $parameters + * @return array + */ + public function parseNamedParameters($parameters) + { + return array_reduce($parameters, function ($result, $item) { + [$key, $value] = array_pad(explode('=', $item, 2), 2, null); + + $result[$key] = $value; + + return $result; + }); + } + + /** + * Require a certain number of parameters to be present. + * + * @param int $count + * @param array $parameters + * @param string $rule + * @return void + * + * @throws \InvalidArgumentException + */ + public function requireParameterCount($count, $parameters, $rule) + { + if (count($parameters) < $count) { + throw new InvalidArgumentException("Validation rule $rule requires at least $count parameters."); + } + } + + /** + * Check if the parameters are of the same type. + * + * @param mixed $first + * @param mixed $second + * @return bool + */ + protected function isSameType($first, $second) + { + return gettype($first) == gettype($second); + } + + /** + * Adds the existing rule to the numericRules array if the attribute's value is numeric. + * + * @param string $attribute + * @param string $rule + * @return void + */ + protected function shouldBeNumeric($attribute, $rule) + { + if (is_numeric($this->getValue($attribute))) { + $this->numericRules[] = $rule; + } + } + + /** + * Trim the value if it is a string. + * + * @param mixed $value + * @return mixed + */ + protected function trim($value) + { + return is_string($value) ? trim($value) : $value; + } + + /** + * Ensure the exponent is within the allowed range. + * + * @param string $attribute + * @param mixed $value + * @return mixed + * + * @throws \Illuminate\Support\Exceptions\MathException + */ + protected function ensureExponentWithinAllowedRange($attribute, $value) + { + $stringValue = (string) $value; + + if (! is_numeric($value) || ! Str::contains($stringValue, 'e', ignoreCase: true)) { + return $value; + } + + $scale = (int) (Str::contains($stringValue, 'e') + ? Str::after($stringValue, 'e') + : Str::after($stringValue, 'E')); + + $withinRange = ( + $this->ensureExponentWithinAllowedRangeUsing ?? fn ($scale) => $scale <= 1000 && $scale >= -1000 + )($scale, $attribute, $value); + + if (! $withinRange) { + throw new MathException('Scientific notation exponent outside of allowed range.'); + } + + return $value; + } +} diff --git a/src/MCP/Concerns/FieldMcpSchemaDetection.php b/src/MCP/Concerns/FieldMcpSchemaDetection.php index 845125a66..d0d1edeeb 100644 --- a/src/MCP/Concerns/FieldMcpSchemaDetection.php +++ b/src/MCP/Concerns/FieldMcpSchemaDetection.php @@ -3,92 +3,93 @@ namespace Binaryk\LaravelRestify\MCP\Concerns; use Binaryk\LaravelRestify\Fields\File; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\JsonSchema\JsonSchema; +use Illuminate\JsonSchema\JsonSchemaTypeFactory; +use Illuminate\JsonSchema\Types\ArrayType; +use Illuminate\JsonSchema\Types\BooleanType; +use Illuminate\JsonSchema\Types\NumberType; use Illuminate\JsonSchema\Types\Type; +/** + * @mixin \Binaryk\LaravelRestify\Fields\Field + */ trait FieldMcpSchemaDetection { /** - * Resolve the JSON schema for this field to be used in MCP tools. + * Guess the field type based on validation rules, field class, and attribute patterns. */ - public function resolveJsonSchema(JsonSchema $schema, Repository $repository): ?Type + public function guessFieldType(RestifyRequest $request): Type { - // Check if there's a custom callback defined - if (is_callable($this->toolInputSchemaCallback)) { - $result = call_user_func($this->toolInputSchemaCallback, $schema, $repository, $this); - if ($result instanceof Type) { - return $result; - } - } + $schema = new JsonSchemaTypeFactory; - // For MCP tools, we include computed fields that have resolve callbacks - // since they represent storable fields in MCP contexts - // Only skip truly computed fields without resolve callbacks - if ($this->computed() && ! $this->resolveCallback) { - return null; - } + $rules = $this->getRulesForRequest($request); - $fieldType = $this->guessFieldType(); + $ruleType = app(JsonSchemaFromRulesAction::class)->buildTypeFromRules( + $schema, + $this->attribute, + $rules, + ); - // Create the field schema based on its type - $schemaField = match ($fieldType) { - 'boolean' => $schema->boolean(), - 'number' => $schema->number(), - 'array' => $schema->string(), // Arrays are typically sent as JSON strings - default => $schema->string() - }; + if ($ruleType) { + return $ruleType; + } - // Add description - $description = $this->generateFieldDescription($repository); - $schemaField->description($description); + // Check attribute name patterns + $attributeType = $this->guessTypeFromAttributeName($schema); - // Mark as required if field has required validation - if ($this->isRequired()) { - $schemaField->required(); + if ($attributeType) { + return $attributeType; } - return $schemaField; + return $schema->string(); } - /** - * Generate a comprehensive description for the field. - */ - protected function generateFieldDescription(Repository $repository): string + public function getDescription(RestifyRequest $request, Repository $repository): string { + if (is_callable($this->descriptionCallback)) { + $description = call_user_func($this->descriptionCallback, $this, $repository); + + if (is_string($description)) { + return $description; + } + } + + if ($description = data_get($this->jsonSchema()?->toArray(), 'description')) { + if (is_string($description)) { + return $description; + } + } + $attribute = $this->label ?? $this->attribute; - $fieldType = $this->guessFieldType(); - $description = "Field: {$attribute} (type: {$fieldType})"; + $description = "Field: {$attribute}."; // Add validation rules information - $rules = $this->getStoringRules(); + $rules = $this->getRulesForRequest($request); + if (! empty($rules)) { $ruleDescriptions = $this->formatValidationRules($rules); + if (! empty($ruleDescriptions)) { $description .= '. Validation: '.implode(', ', $ruleDescriptions); } } - // Add relationship information for relationship fields - if ($this->isRelationshipField()) { - $description .= '. This is a relationship field'; - } - // Add file information for file fields if ($this instanceof File) { $description .= '. Upload a file'; } // Add examples based on field type and name - $examples = $this->generateFieldExamples(); - if (! empty($examples)) { - $description .= '. Examples: '.implode(', ', $examples); - } + if ($this->jsonSchema instanceof Type) { + $examples = $this->generateFieldExamples($this->jsonSchema); - // Apply custom description callback if provided - if (is_callable($this->descriptionCallback)) { - $description = call_user_func($this->descriptionCallback, $description, $this, $repository); + if (! empty($examples)) { + $description .= '. Examples: '.implode(', ', $examples); + } } return $description; @@ -107,17 +108,6 @@ protected function isRequired(): bool }); } - /** - * Check if field is a relationship field. - */ - protected function isRelationshipField(): bool - { - return $this instanceof \Binaryk\LaravelRestify\Fields\BelongsTo || - $this instanceof \Binaryk\LaravelRestify\Fields\HasOne || - $this instanceof \Binaryk\LaravelRestify\Fields\HasMany || - $this instanceof \Binaryk\LaravelRestify\Fields\BelongsToMany; - } - /** * Format validation rules for display. */ @@ -150,17 +140,23 @@ protected function formatValidationRules(array $rules): array /** * Generate examples for the field. */ - protected function generateFieldExamples(): array + protected function generateFieldExamples(JsonSchema $fieldType): array { $attribute = strtolower($this->attribute); - $fieldType = $this->guessFieldType(); - - return match ($fieldType) { - 'boolean' => ['true', 'false'], - 'number' => $this->getNumberExamples($attribute), - 'array' => ['["item1", "item2"]', '{"key": "value"}'], - default => $this->getStringExamples($attribute) - }; + + if ($fieldType instanceof BooleanType) { + return ['true', 'false']; + } + + if ($fieldType instanceof NumberType) { + return $this->getNumberExamples($attribute); + } + + if ($fieldType instanceof ArrayType) { + return ['["item1", "item2"]', '{"key": "value"}']; + } + + return $this->getStringExamples($attribute); } /** @@ -168,7 +164,8 @@ protected function generateFieldExamples(): array */ protected function getNumberExamples(string $attribute): array { - if (str_contains($attribute, 'price') || str_contains($attribute, 'cost') || str_contains($attribute, 'amount')) { + if (str_contains($attribute, 'price') || str_contains($attribute, 'cost') || str_contains($attribute, + 'amount')) { return ['99.99', '29.95']; } if (str_contains($attribute, 'age')) { @@ -213,4 +210,123 @@ protected function getStringExamples(string $attribute): array return ['sample text', 'example value']; } + + /** + * Guess type from validation rules. + */ + protected function guessTypeFromValidationRules(): ?string + { + $allRules = array_merge($this->rules, $this->storingRules, $this->updatingRules); + + // Convert rule objects to strings for checking + $ruleStrings = collect($allRules)->map(function ($rule) { + if (is_string($rule)) { + return $rule; + } + if (is_object($rule)) { + return get_class($rule); + } + + return (string) $rule; + })->toArray(); + + // Check for specific types + if ($this->hasAnyRule($ruleStrings, ['boolean', 'bool'])) { + return 'boolean'; + } + + if ($this->hasAnyRule($ruleStrings, ['array'])) { + return 'array'; + } + + if ($this->hasAnyRule($ruleStrings, ['email', 'url', 'ip', 'uuid', 'string', 'regex'])) { + return 'string'; + } + + if ($this->hasAnyRule($ruleStrings, + ['date', 'date_format:', 'before:', 'after:', 'before_or_equal:', 'after_or_equal:'])) { + return 'string'; // Dates are typically handled as strings in schemas + } + + if ($this->hasAnyRule($ruleStrings, ['file', 'image', 'mimes:', 'mimetypes:'])) { + return 'string'; // Files are typically handled as strings (paths/URLs) + } + + if ($this->hasAnyRule($ruleStrings, ['integer', 'int', 'numeric', 'between:'])) { + return 'number'; + } + + return null; + } + + /** + * Check if any of the given rules exist in the rule strings. + */ + protected function hasAnyRule(array $ruleStrings, array $rulesToCheck): bool + { + foreach ($ruleStrings as $rule) { + foreach ($rulesToCheck as $check) { + if ($rule === $check || str_starts_with($rule, $check)) { + return true; + } + } + } + + return false; + } + + /** + * Guess type from attribute name patterns. + */ + protected function guessTypeFromAttributeName(JsonSchema $schema): ?Type + { + $attribute = $this->attribute; + + if (! is_string($attribute)) { + return null; + } + + $attribute = strtolower($attribute); + + // Boolean patterns + if (preg_match('/^(is_|has_|can_|should_|will_|was_|were_)/', $attribute) || + in_array($attribute, + ['active', 'enabled', 'disabled', 'verified', 'published', 'featured', 'public', 'private'])) { + return $schema->boolean(); + } + + // Number patterns + if (preg_match('/_(id|count|number|amount|price|cost|total|sum|quantity|qty)$/', $attribute) || + in_array($attribute, + ['id', 'age', 'year', 'month', 'day', 'hour', 'minute', 'second', 'weight', 'height', 'size'])) { + return $schema->number(); + } + + // Date patterns + if (preg_match('/_(at|date|time)$/', $attribute) || + in_array($attribute, + ['created_at', 'updated_at', 'deleted_at', 'published_at', 'birthday', 'date_of_birth'])) { + return $schema->string()->description('The attribute should be a date string in ISO 8601 format (e.g., "2024-01-01T00:00:00Z")'); + } + + // Email pattern + if (str_contains($attribute, 'email')) { + return $schema->string(); + } + + // Password pattern + if (str_contains($attribute, 'password')) { + return $schema->string(); + } + + // Array patterns (JSON fields) + if (preg_match('/_(json|data|metadata|config|settings|options)$/', $attribute) || + str_contains($attribute, 'tags')) { + return $schema->array()->items( + $schema->string() + ); + } + + return null; + } } diff --git a/src/MCP/Concerns/JsonSchemaFromRulesResolver.php b/src/MCP/Concerns/JsonSchemaFromRulesResolver.php new file mode 100644 index 000000000..9f331d472 --- /dev/null +++ b/src/MCP/Concerns/JsonSchemaFromRulesResolver.php @@ -0,0 +1,5 @@ +findOrFail($id); - return static::resolveWith($model)->destroy($request, $id); + static::resolveWith($model)->destroy($request, $id); + + return [ + 'id' => $id, + 'deleted' => true, + ]; } public static function destroyToolSchema(JsonSchema $schema): array { - $key = static::uriKey(); $modelName = class_basename(static::guessModelClassName()); return [ diff --git a/src/MCP/Concerns/McpStoreTool.php b/src/MCP/Concerns/McpStoreTool.php index 543884a92..d113e9c97 100644 --- a/src/MCP/Concerns/McpStoreTool.php +++ b/src/MCP/Concerns/McpStoreTool.php @@ -33,8 +33,9 @@ public static function storeToolSchema(JsonSchema $schema): array ->forStore($request, $repository) ->withoutActions($request, $repository); - $fields->each(function (Field $field) use ($schema, $repository, &$properties) { - $fieldSchema = $field->resolveJsonSchema($schema, $repository); + $fields->each(function (Field $field) use ($schema, $repository, &$properties, $request) { + $fieldSchema = $field->resolveJsonSchema($schema, $request, $repository)->jsonSchema(); + if ($fieldSchema !== null) { $properties[$field->attribute] = $fieldSchema; } diff --git a/src/MCP/Concerns/McpUpdateTool.php b/src/MCP/Concerns/McpUpdateTool.php index 1793bea88..2e09c8c78 100644 --- a/src/MCP/Concerns/McpUpdateTool.php +++ b/src/MCP/Concerns/McpUpdateTool.php @@ -52,8 +52,9 @@ public static function updateToolSchema(JsonSchema $schema): array ->forUpdate($request, $repository) ->withoutActions($request, $repository); - $fields->each(function (Field $field) use ($schema, $repository, &$properties) { - $fieldSchema = $field->resolveJsonSchema($schema, $repository); + $fields->each(function (Field $field) use ($schema, $repository, &$properties, $request) { + $fieldSchema = $field->resolveJsonSchema($schema, $request, $repository)->jsonSchema(); + if ($fieldSchema !== null) { $properties[$field->attribute] = $fieldSchema; } diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index 02fa0dcdb..f65ddd30e 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -39,10 +39,15 @@ class RestifyServer extends Server */ public string $instructions = 'Laravel Restify MCP server providing access to RESTful API resources, repository operations, field management, action execution, and filter/search capabilities. Restify helps build and interact with REST APIs efficiently.'; + /** + * The maximum pagination length for resources that support pagination. + */ + public int $maxPaginationLength = 200; + /** * The default pagination length for resources that support pagination. */ - public int $defaultPaginationLength = 50; + public int $defaultPaginationLength = 150; /** * The tools registered with this MCP server. @@ -165,9 +170,6 @@ protected function discoverRepositoryTools(): void protected function discoverActionsForRepository(string $repositoryClass, Repository $repositoryInstance): void { $actionRequest = app(McpActionRequest::class); - $actionRequest->merge([ - 'mcp_repository_key' => $repositoryInstance::uriKey(), - ]); $repositoryInstance->resolveActions($actionRequest) ->filter(fn ($action) => $action instanceof Action) @@ -180,9 +182,6 @@ protected function discoverActionsForRepository(string $repositoryClass, Reposit protected function discoverGettersForRepository(string $repositoryClass, Repository $repositoryInstance): void { $getterRequest = app(McpGetterRequest::class); - $getterRequest->merge([ - 'mcp_repository_key' => $repositoryInstance::uriKey(), - ]); $repositoryInstance->resolveGetters($getterRequest) ->filter(fn ($getter) => $getter instanceof Getter) @@ -204,7 +203,8 @@ protected function discoverResources(): array foreach ($resourceDir as $resourceFile) { if ($resourceFile->isFile() && $resourceFile->getExtension() === 'php') { $fqdn = 'Binaryk\\LaravelRestify\\MCP\\Resources\\'.$resourceFile->getBasename('.php'); - if (class_exists($fqdn) && ! in_array($fqdn, $excludedResources, true) && $fqdn !== ApplicationInfo::class) { + if (class_exists($fqdn) && ! in_array($fqdn, $excludedResources, + true) && $fqdn !== ApplicationInfo::class) { $resources[] = $fqdn; } } diff --git a/src/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 46045b613..d885eadfd 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\MCP\Tools\Operations; use Binaryk\LaravelRestify\Actions\Action; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; use Binaryk\LaravelRestify\Repositories\Repository; @@ -26,6 +27,11 @@ public function __construct(string $repositoryClass, Action $action) $this->action = $action; } + public function title(): string + { + return $this->action->name(); + } + public function name(): string { $repositoryUriKey = $this->repository->uriKey(); @@ -36,8 +42,13 @@ public function name(): string public function description(): string { + if ($description = $this->action->description(app(McpActionRequest::class))) { + return $description; + } + $repositoryUriKey = $this->repository->uriKey(); $actionName = $this->action->name(); + $modelName = class_basename($this->repository::guessModelClassName()); if ($this->action->isStandalone()) { @@ -59,33 +70,29 @@ public function description(): string public function schema(JsonSchema $schema): array { - $repositoryClass = get_class($this->repository); - $modelName = class_basename($repositoryClass::guessModelClassName()); - $actionName = $this->action->name(); + $validationSchema = []; - $fields = []; + $modelName = class_basename($this->repository::guessModelClassName()); - if ($this->action->isStandalone()) { - // Standalone actions don't need ID or repositories - $fields['include'] = $schema->string()->description('Comma-separated list of relationships to include'); - } else { - // Check if it's primarily a show action or index action - $mcpRequest = app(McpActionRequest::class); - $shownOnShow = $this->action->isShownOnShow($mcpRequest, $this->repository); - $shownOnIndex = $this->action->isShownOnIndex($mcpRequest, $this->repository); - - if ($shownOnShow && ! $shownOnIndex) { - // Show action - requires single ID - $fields['id'] = $schema->string()->description("The ID of the $modelName to perform the action on")->required(); - $fields['include'] = $schema->string()->description('Comma-separated list of relationships to include'); - } else { - // Index action - requires repositories array - $fields['repositories'] = $schema->string()->description("Array of $modelName IDs to perform the action on. e.g. repositories=[1,2,3]")->required(); - $fields['include'] = $schema->string()->description('Comma-separated list of relationships to include'); - } + if ($this->action->isShownOnIndex(app(RestifyRequest::class), $this->repository)) { + $validationSchema['resources'] = $schema->array() + ->items( + $schema->string() + ->description("The ID of the resource {$modelName} to perform the action on.") + ->required()) + ->title('resources') + ->description("The ids of the resources {$modelName} to perform the action on. Use string 'all' to select all resources.") + ->required(); + } elseif ($this->action->isShownOnShow(app(RestifyRequest::class), $this->repository)) { + $validationSchema['id'] = $schema->string() + ->title('id') + ->description('The ID of the resource to perform the action on.') + ->required(); } - return $fields; + $rulesSchema = $this->action->toolSchema($schema); + + return array_merge($rulesSchema, $validationSchema); } public function handle(Request $request): Response diff --git a/src/MCP/Tools/Operations/DeleteTool.php b/src/MCP/Tools/Operations/DeleteTool.php index 424c5ddd0..7abba2a42 100644 --- a/src/MCP/Tools/Operations/DeleteTool.php +++ b/src/MCP/Tools/Operations/DeleteTool.php @@ -22,6 +22,11 @@ public function __construct(string $repositoryClass) $this->repository = app($repositoryClass); } + public function title(): string + { + return $this->repository::label().' Delete'; + } + public function name(): string { $uriKey = $this->repository->uriKey(); diff --git a/src/MCP/Tools/Operations/GetterTool.php b/src/MCP/Tools/Operations/GetterTool.php index 91aba82d1..a39cb4a7e 100644 --- a/src/MCP/Tools/Operations/GetterTool.php +++ b/src/MCP/Tools/Operations/GetterTool.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\MCP\Tools\Operations; use Binaryk\LaravelRestify\Getters\Getter; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\JsonSchema\JsonSchema; @@ -12,6 +13,9 @@ class GetterTool extends Tool { + /** + * @var Repository|\Illuminate\Foundation\Application|mixed|object|string + */ protected Repository $repository; protected Getter $getter; @@ -22,6 +26,11 @@ public function __construct(string $repositoryClass, Getter $getter) $this->getter = $getter; } + public function title(): string + { + return $this->getter->name(); + } + public function name(): string { $repositoryUriKey = $this->repository->uriKey(); @@ -32,6 +41,10 @@ public function name(): string public function description(): string { + if ($description = $this->getter->description(app(McpGetterRequest::class))) { + return $description; + } + $repositoryUriKey = $this->repository->uriKey(); $getterName = $this->getter->name(); $modelName = class_basename($this->repository::guessModelClassName()); @@ -51,35 +64,59 @@ public function description(): string public function schema(JsonSchema $schema): array { - $repositoryClass = get_class($this->repository); - $modelName = class_basename($repositoryClass::guessModelClassName()); - $getterName = $this->getter->name(); - - $fields = []; + $validationSchema = []; - // Check if it's primarily a show getter or index getter - $mcpRequest = app(McpGetterRequest::class); - $shownOnShow = $this->getter->isShownOnShow($mcpRequest, $this->repository); - $shownOnIndex = $this->getter->isShownOnIndex($mcpRequest, $this->repository); + $modelName = class_basename($this->repository::guessModelClassName()); - if ($shownOnShow && ! $shownOnIndex) { - // Show getter - requires single ID - $fields['id'] = $schema->string()->description("The ID of the $modelName to execute the getter on")->required(); - $fields['include'] = $schema->string()->description('Comma-separated list of relationships to include'); - } else { - // Index getters typically don't require specific IDs - $fields['include'] = $schema->string()->description('Comma-separated list of relationships to include'); + if ($this->getter->isShownOnIndex(app(RestifyRequest::class), $this->repository)) { + $validationSchema['resources'] = $schema->array() + ->items( + $schema->string() + ->description('The ID of the resource to perform the getter on.') + ->required()) + ->title('resources') + ->description("The ids of the resources {$modelName} to perform the getter on. Use string 'all' to select all resources.") + ->required(); + } elseif ($this->getter->isShownOnShow(app(RestifyRequest::class), $this->repository)) { + $validationSchema['id'] = $schema->string() + ->title('id') + ->description("The ID of the resource ({$modelName}) to perform the getter on.") + ->required(); } - return $fields; + $rulesSchema = $this->getter->toolSchema($schema); + + return array_merge($rulesSchema, $validationSchema); } public function handle(Request $request): Response { $mcpRequest = app(McpGetterRequest::class); $mcpRequest->replace($request->all()); + $mcpRequest->merge([ + 'mcp_repository_key' => $this->repository->uriKey(), + ]); + + // Parse repositories string to array if provided + if ($mcpRequest->has('repositories') && is_string($mcpRequest->input('repositories'))) { + $repositories = json_decode($mcpRequest->input('repositories'), true) ?? []; + $mcpRequest->merge(['repositories' => $repositories]); + } - $this->repository->request = $mcpRequest; + // For show actions with single ID, set the route parameter + if ($id = $mcpRequest->input('id')) { + $mcpRequest->setRouteResolver(function () use ($id) { + return new class($id) + { + public function __construct(private $id) {} + + public function parameter($key, $default = null) + { + return $key === 'repositoryId' ? $this->id : $default; + } + }; + }); + } $result = $this->repository->getterTool($this->getter, $mcpRequest); diff --git a/src/MCP/Tools/Operations/IndexTool.php b/src/MCP/Tools/Operations/IndexTool.php index 543c455b9..d901a1f5e 100644 --- a/src/MCP/Tools/Operations/IndexTool.php +++ b/src/MCP/Tools/Operations/IndexTool.php @@ -22,6 +22,11 @@ public function __construct(string $repositoryClass) $this->repository = app($repositoryClass); } + public function title(): string + { + return $this->repository::label().' Index'; + } + public function name(): string { $uriKey = $this->repository->uriKey(); @@ -31,10 +36,7 @@ public function name(): string public function description(): string { - $uriKey = $this->repository->uriKey(); - $modelName = class_basename($this->repository::guessModelClassName()); - - return "Retrieve a paginated list of {$modelName} records from the {$uriKey} repository with filtering, sorting, and search capabilities."; + return $this->repository::description(app(McpIndexRequest::class)); } public function schema(JsonSchema $schema): array diff --git a/src/MCP/Tools/Operations/ProfileTool.php b/src/MCP/Tools/Operations/ProfileTool.php index 52b273d2b..4885dd486 100644 --- a/src/MCP/Tools/Operations/ProfileTool.php +++ b/src/MCP/Tools/Operations/ProfileTool.php @@ -18,6 +18,11 @@ public function __construct(string $repositoryClass) $this->repository = app($repositoryClass); } + public function title(): string + { + return 'Profile Tool'; + } + public function name(): string { $uriKey = $this->repository->uriKey(); diff --git a/src/MCP/Tools/Operations/ShowTool.php b/src/MCP/Tools/Operations/ShowTool.php index 1e4471924..efb97aa4c 100644 --- a/src/MCP/Tools/Operations/ShowTool.php +++ b/src/MCP/Tools/Operations/ShowTool.php @@ -22,6 +22,11 @@ public function __construct(string $repositoryClass) $this->repository = app($repositoryClass); } + public function title(): string + { + return $this->repository::label().' Show'; + } + public function name(): string { $uriKey = $this->repository->uriKey(); diff --git a/src/MCP/Tools/Operations/StoreTool.php b/src/MCP/Tools/Operations/StoreTool.php index ba92f9d28..ff929d6cc 100644 --- a/src/MCP/Tools/Operations/StoreTool.php +++ b/src/MCP/Tools/Operations/StoreTool.php @@ -22,6 +22,11 @@ public function __construct(string $repositoryClass) $this->repository = app($repositoryClass); } + public function title(): string + { + return $this->repository::label().' Create'; + } + public function name(): string { $uriKey = $this->repository->uriKey(); diff --git a/src/MCP/Tools/Operations/UpdateTool.php b/src/MCP/Tools/Operations/UpdateTool.php index 4cd90ff86..000f2975c 100644 --- a/src/MCP/Tools/Operations/UpdateTool.php +++ b/src/MCP/Tools/Operations/UpdateTool.php @@ -22,6 +22,11 @@ public function __construct(string $repositoryClass) $this->repository = app($repositoryClass); } + public function title(): string + { + return $this->repository::label().' Update'; + } + public function name(): string { $uriKey = $this->repository->uriKey(); diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index e3694daac..86f09cf33 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -149,6 +149,11 @@ class Repository implements JsonSerializable, RestifySearchable */ public static string $title = 'id'; + /** + * Action description, usually used in the UI or MCP. + */ + public static string $description = ''; + /** * Attribute that should be used for displaying the `id` in the json:format. */ @@ -278,6 +283,35 @@ public function subtitle(): ?string return null; } + /** + * This is the description used for the IndexTool MCP. + */ + public static function description(RestifyRequest $request): string + { + $modelName = class_basename(self::guessModelClassName()); + $table = self::newModel()->getTable(); + + // Ai Agent description + $description = "This repository manages the [{$modelName}] model, which corresponds to the [{$table}] table in the database. " + .'It provides functionalities such as listing, searching, sorting, filtering, and relationship management. '; + + $potentialAttributesFromTable = implode(', ', self::newModel()->getFillable()); + + if (empty($potentialAttributesFromTable)) { + $potentialAttributesFromTable = implode(', ', self::newModel()->getConnection() + ->getSchemaBuilder() + ->getColumnListing($table)); + } + + if (! empty($potentialAttributesFromTable)) { + $description .= " The model/table has the following attributes: {$potentialAttributesFromTable}."; + } + + return static::$description !== '' + ? static::$description + : $description; + } + public function filters(RestifyRequest $request): array { return []; @@ -1143,8 +1177,6 @@ public function serializeForShow(RestifyRequest $request): array public function serializeForIndex(RestifyRequest $request): array { - $this->request = $request; - $data = $this->filter([ 'id' => $this->when($id = $this->getId($request), $id), 'type' => $this->when($type = $this->getType($request), $type), diff --git a/src/Repositories/ValidatingTrait.php b/src/Repositories/ValidatingTrait.php index 213c40d92..61ba17a81 100644 --- a/src/Repositories/ValidatingTrait.php +++ b/src/Repositories/ValidatingTrait.php @@ -26,6 +26,9 @@ abstract public static function newModel(); */ public static function validatorForStoring(RestifyRequest $request, ?array $plainPayload = null) { + /** + * @var Repository $on + */ $on = static::resolveWith(static::newModel()); $messages = $on->collectFields($request)->flatMap(function ($k) { @@ -80,7 +83,9 @@ public static function validateForUpdate(RestifyRequest $request, $resource = nu public static function validatorForUpdate(RestifyRequest $request, $resource = null, ?array $plainPayload = null) { - /** * @var Repository $on */ + /** + * @var Repository $on + */ $on = $resource ?? static::resolveWith(static::newModel()); $messages = $on->collectFields($request)->flatMap(function ($k) { diff --git a/src/Traits/AuthorizedToRun.php b/src/Traits/AuthorizedToRun.php index 5bb00916a..d4bddd348 100644 --- a/src/Traits/AuthorizedToRun.php +++ b/src/Traits/AuthorizedToRun.php @@ -11,7 +11,7 @@ trait AuthorizedToRun /** * The callback used to authorize running the action. */ - public ?Closure $runCallback; + public ?Closure $runCallback = null; /** * Determine if the action is executable for the given request. diff --git a/tests/Actions/FieldActionTest.php b/tests/Actions/FieldActionTest.php index e9d63b7fa..f3553fd63 100644 --- a/tests/Actions/FieldActionTest.php +++ b/tests/Actions/FieldActionTest.php @@ -32,7 +32,7 @@ public function handle(RestifyRequest $request, Post $post) PostRepository::partialMock() ->shouldReceive('fieldsForStore') - ->andreturn([ + ->andReturn([ Field::new('title'), Field::new('description')->action($action), @@ -50,7 +50,6 @@ public function handle(RestifyRequest $request, Post $post) ->where('data.attributes.description', 'Actionable Description') ->etc() ); - } #[Test] @@ -72,7 +71,7 @@ public function handle(RestifyRequest $request, Post $post, int $row) PostRepository::partialMock() ->shouldReceive('fieldsForStoreBulk') - ->andreturn([ + ->andReturn([ Field::new('title'), Field::new('description')->action($action), @@ -117,7 +116,7 @@ public function handle(RestifyRequest $request, Post $post, int $row) PostRepository::partialMock() ->shouldReceive('fieldsForUpdateBulk') - ->andreturn([ + ->andReturn([ Field::new('title'), Field::new('description')->action($action), diff --git a/tests/MCP/FieldSchemaValidationTest.php b/tests/MCP/FieldSchemaValidationTest.php index 9e6b39639..8e6f93b5b 100644 --- a/tests/MCP/FieldSchemaValidationTest.php +++ b/tests/MCP/FieldSchemaValidationTest.php @@ -3,62 +3,63 @@ namespace Binaryk\LaravelRestify\Tests\MCP; use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\MCP\Requests\McpStoreRequest; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\IntegrationTestCase; +use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\JsonSchema\JsonSchemaTypeFactory; +use Illuminate\JsonSchema\Types\ArrayType; +use Illuminate\JsonSchema\Types\BooleanType; +use Illuminate\JsonSchema\Types\IntegerType; +use Illuminate\JsonSchema\Types\NumberType; +use Illuminate\JsonSchema\Types\StringType; class FieldSchemaValidationTest extends IntegrationTestCase { public function test_field_rules_convert_to_correct_schema_types(): void { - // // Test string field type detection + // Test string field type detection $titleField = Field::make('title')->rules(['required', 'string', 'max:255']); - $this->assertEquals('string', $titleField->guessFieldType()); + $this->assertInstanceOf(StringType::class, $titleField->guessFieldType(new McpStoreRequest)); - // Test integer field type detection - debug first + // Test integer field type detection $priorityField = Field::make('priority')->rules(['required', 'integer', 'min:1']); - - // Debug what rules are actually set - $reflection = new \ReflectionProperty($priorityField, 'rules'); - $reflection->setAccessible(true); - $actualRules = $reflection->getValue($priorityField); - - // This should help us understand what's happening - $this->assertContains('integer', $actualRules, 'Integer rule not found in: '.json_encode($actualRules)); - $this->assertEquals('number', $priorityField->guessFieldType()); + $this->assertInstanceOf(IntegerType::class, $priorityField->guessFieldType(new McpStoreRequest)); // Test boolean field type detection $publishedField = Field::make('is_published')->rules(['boolean']); - $this->assertEquals('boolean', $publishedField->guessFieldType()); + $this->assertInstanceOf(BooleanType::class, $publishedField->guessFieldType(new McpStoreRequest)); // Test numeric field type detection $ratingField = Field::make('rating')->rules(['numeric', 'between:0,5']); - $this->assertEquals('number', $ratingField->guessFieldType()); + $this->assertInstanceOf(NumberType::class, $ratingField->guessFieldType(new McpStoreRequest)); // Test email field (should be string type) $emailField = Field::make('author_email')->rules(['required', 'email']); - $this->assertEquals('string', $emailField->guessFieldType()); + $this->assertInstanceOf(StringType::class, $emailField->guessFieldType(new McpStoreRequest)); // Test array field type detection $tagsField = Field::make('tags')->rules(['array']); - $this->assertEquals('string', $tagsField->guessFieldType()); // Arrays converted to strings + $this->assertInstanceOf(ArrayType::class, $tagsField->guessFieldType(new McpStoreRequest)); // Test default type for custom validation $slugField = Field::make('slug')->rules(['required', 'unique:posts,slug']); - $this->assertEquals('string', $slugField->guessFieldType()); + $this->assertInstanceOf(StringType::class, $slugField->guessFieldType(new McpStoreRequest)); } public function test_field_validation_rules_format(): void { // Test field with 'in' validation $statusField = Field::make('status')->rules(['required', 'string', 'in:draft,published,archived']); - $this->assertEquals('string', $statusField->guessFieldType()); + $this->assertInstanceOf(StringType::class, $statusField->guessFieldType(new McpStoreRequest)); // Test field with min/max rules $wordCountField = Field::make('word_count')->rules(['integer', 'min:100', 'max:5000']); - $this->assertEquals('number', $wordCountField->guessFieldType()); + $this->assertInstanceOf(IntegerType::class, $wordCountField->guessFieldType(new McpStoreRequest)); // Test numeric field with between rule $ratingField = Field::make('rating')->rules(['numeric', 'between:1,10']); - $this->assertEquals('number', $ratingField->guessFieldType()); + $this->assertInstanceOf(NumberType::class, $ratingField->guessFieldType(new McpStoreRequest)); // Test required field detection $requiredField = Field::make('name')->rules(['required', 'string']); @@ -70,4 +71,48 @@ public function test_field_validation_rules_format(): void $optionalField = Field::make('description')->rules(['sometimes', 'string']); $this->assertFalse($reflectionMethod->invoke($optionalField)); } + + public function test_field_json_schema_has_description(): void + { + $request = new McpStoreRequest; + $schemaFactory = new JsonSchemaTypeFactory; + $repository = PostRepository::partialMock(); + $field = field('published_at')->rules(['nullable', 'date', 'after:2020-01-01'])->resolveJsonSchema( + $schemaFactory, + $request, + $repository + ); + + $schema = $field->jsonSchema()->toArray(); + $this->assertSame('Date attribute, must be after: 2020-01-01', $schema['description']); + } + + public function test_field_json_schema_prioritize_user_description(): void + { + $request = new McpStoreRequest; + $schemaFactory = new JsonSchemaTypeFactory; + $repository = PostRepository::partialMock(); + $field = field('published_at')->rules(['nullable', 'date', 'after:2020-01-01']) + ->description('This is a custom description.') + ->resolveJsonSchema( + $schemaFactory, + $request, + $repository + ); + + $schema = $field->jsonSchema()->toArray(); + $this->assertSame('This is a custom description.', $schema['description']); + } + + public function test_can_validate_custom_rule(): void + { + $field = field('published_at')->rules([new UniqueClientCompanyNameRule]); + + $type = $field->guessFieldType(new McpStoreRequest); + $this->assertInstanceOf(StringType::class, $type); + } +} +class UniqueClientCompanyNameRule implements ValidationRule +{ + public function validate(string $attribute, mixed $value, \Closure $fail): void {} } diff --git a/tests/MCP/JsonSchemaFromRulesActionTest.php b/tests/MCP/JsonSchemaFromRulesActionTest.php new file mode 100644 index 000000000..4a92d8115 --- /dev/null +++ b/tests/MCP/JsonSchemaFromRulesActionTest.php @@ -0,0 +1,56 @@ + ['required', 'date', 'before:2025-12-31'], + ]; + + $result = $action($schema, $rules); + + $this->assertArrayHasKey('event_date', $result); + $this->assertInstanceOf(StringType::class, $result['event_date']); + + $serialized = $result['event_date']->toArray(); + + $this->assertEquals('string', $serialized['type']); + $this->assertArrayHasKey('description', $serialized); + $this->assertStringContainsString('Must be before: 2025-12-31', $serialized['description']); + } + + public function test_integer_rule_generates_correct_schema(): void + { + $action = new JsonSchemaFromRulesAction; + $schema = new JsonSchemaTypeFactory; + + $rules = [ + 'age' => ['required', 'integer', 'min:18'], + ]; + + $result = $action($schema, $rules); + + $this->assertArrayHasKey('age', $result); + $this->assertInstanceOf(IntegerType::class, $result['age']); + + $serialized = $result['age']->toArray(); + + $this->assertEquals('integer', $serialized['type']); + $this->assertEquals(18, $serialized['minimum']); + + $reflection = new \ReflectionProperty($result['age'], 'required'); + $this->assertTrue($reflection->getValue($result['age'])); + } +} diff --git a/tests/MCP/McpFieldsIntegrationTest.php b/tests/MCP/McpFieldsIntegrationTest.php index 145cbbd2b..4a31408a4 100644 --- a/tests/MCP/McpFieldsIntegrationTest.php +++ b/tests/MCP/McpFieldsIntegrationTest.php @@ -33,6 +33,14 @@ protected function getPackageProviders($app): array ]); } + protected function setUp(): void + { + parent::setUp(); + + // Clear any previously registered repositories to avoid test pollution + Restify::repositories([]); + } + public function test_repository_uses_mcp_specific_field_methods(): void { $repository = new class extends Repository @@ -293,9 +301,20 @@ public function mcpAllowsIndex(): bool $this->assertNotEmpty($resultContent['data']); $firstItem = $resultContent['data'][0]; - $this->assertArrayHasKey('attributes', $firstItem); - $attributes = $firstItem['attributes']; + // Check structure - JSON:API format has 'attributes' key + if (isset($firstItem['type']) && isset($firstItem['id'])) { + // JSON:API format - attributes should be in a sub-key + $this->assertArrayHasKey('attributes', $firstItem, + 'Expected JSON:API structure with attributes key. Found keys: '.implode(', ', array_keys($firstItem))); + $attributes = $firstItem['attributes']; + } elseif (isset($firstItem['attributes'])) { + // Has attributes key but not standard JSON:API + $attributes = $firstItem['attributes']; + } else { + // Flat structure + $attributes = $firstItem; + } // Assert MCP-specific fields that should only appear in MCP requests $this->assertArrayHasKey('mcp_metadata', $attributes);