From e600b34d496bcaa54507b2eb395a33edd981a096 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sat, 4 Oct 2025 15:23:10 +0300 Subject: [PATCH 01/15] fix: actions --- .github/workflows/release.yml | 2 +- src/Actions/Action.php | 11 ++ src/Fields/Field.php | 129 ------------------ src/MCP/Concerns/FieldMcpSchemaDetection.php | 136 +++++++++++++++++++ src/MCP/Concerns/McpDestroyTool.php | 8 +- src/MCP/Tools/Operations/ActionTool.php | 117 +++++++++++++--- tests/MCP/FieldSchemaValidationTest.php | 2 +- 7 files changed, 256 insertions(+), 149 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2371de9d..1a4635f3 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 b0a80f86..dc50e141 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -58,11 +58,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. */ @@ -196,6 +206,7 @@ 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/Fields/Field.php b/src/Fields/Field.php index 71ea7092..ce943f00 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -769,135 +769,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. * diff --git a/src/MCP/Concerns/FieldMcpSchemaDetection.php b/src/MCP/Concerns/FieldMcpSchemaDetection.php index 845125a6..f9adfce5 100644 --- a/src/MCP/Concerns/FieldMcpSchemaDetection.php +++ b/src/MCP/Concerns/FieldMcpSchemaDetection.php @@ -7,6 +7,9 @@ use Illuminate\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; +/** + * @mixin \Binaryk\LaravelRestify\Fields\Field + */ trait FieldMcpSchemaDetection { /** @@ -51,6 +54,26 @@ public function resolveJsonSchema(JsonSchema $schema, Repository $repository): ? return $schemaField; } + /** + * 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'; + } + /** * Generate a comprehensive description for the field. */ @@ -213,4 +236,117 @@ 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(): ?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; + } } diff --git a/src/MCP/Concerns/McpDestroyTool.php b/src/MCP/Concerns/McpDestroyTool.php index 764b4aae..8f021fa8 100644 --- a/src/MCP/Concerns/McpDestroyTool.php +++ b/src/MCP/Concerns/McpDestroyTool.php @@ -16,12 +16,16 @@ public function deleteTool(McpDestroyRequest $request): array $model = static::query($request)->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/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 46045b61..3b8de4db 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -36,8 +36,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()) { @@ -65,26 +70,41 @@ public function schema(JsonSchema $schema): array $fields = []; + foreach($this->action->rules() as $field => $rules) { + $fieldType = $this->guessTypeFromValidationRules($rules); + + $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->action->isStandalone()) { - // Standalone actions don't need ID or repositories - $fields['include'] = $schema->string()->description('Comma-separated list of relationships to include'); + return $fields; + } + + if ($this->action->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 { - // 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'); - } + $fields['id'] = $schema->string() + ->description("The ID of the {$modelName} to run the {$actionName} action on.") + ->required(); } + return $fields; } @@ -121,4 +141,69 @@ public function parameter($key, $default = null) return Response::json($result); } + + protected function guessTypeFromValidationRules(array $rules): ?string + { + $ruleStrings = collect($rules)->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 field is required based on validation rules. + */ + protected function isRequired(array $rules): bool + { + return in_array('required', $rules) || + collect($rules)->contains(function ($rule) { + return is_string($rule) && str_starts_with($rule, 'required'); + }); + } + + 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; + } } diff --git a/tests/MCP/FieldSchemaValidationTest.php b/tests/MCP/FieldSchemaValidationTest.php index 9e6b3963..e64f1eae 100644 --- a/tests/MCP/FieldSchemaValidationTest.php +++ b/tests/MCP/FieldSchemaValidationTest.php @@ -39,7 +39,7 @@ public function test_field_rules_convert_to_correct_schema_types(): void // Test array field type detection $tagsField = Field::make('tags')->rules(['array']); - $this->assertEquals('string', $tagsField->guessFieldType()); // Arrays converted to strings + $this->assertEquals('array', $tagsField->guessFieldType()); // Arrays converted to strings // Test default type for custom validation $slugField = Field::make('slug')->rules(['required', 'unique:posts,slug']); From 3f24f49033e277e4bc86d26e22d6aa94d6ad86b9 Mon Sep 17 00:00:00 2001 From: binaryk Date: Sat, 4 Oct 2025 12:23:36 +0000 Subject: [PATCH 02/15] Fix styling --- src/MCP/Tools/Operations/ActionTool.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 3b8de4db..2e8c7fe8 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -36,7 +36,7 @@ public function name(): string public function description(): string { - if ($description = $this->action->description(app(McpActionRequest::class))){ + if ($description = $this->action->description(app(McpActionRequest::class))) { return $description; } @@ -70,7 +70,7 @@ public function schema(JsonSchema $schema): array $fields = []; - foreach($this->action->rules() as $field => $rules) { + foreach ($this->action->rules() as $field => $rules) { $fieldType = $this->guessTypeFromValidationRules($rules); $schemaField = match ($fieldType) { @@ -104,7 +104,6 @@ public function schema(JsonSchema $schema): array ->required(); } - return $fields; } From f039fad3a7818fdcbf6a762e4fbe56451ef91736 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 5 Oct 2025 12:54:12 +0300 Subject: [PATCH 03/15] fix: adding description and validations for actions and fields to inherit native laravel validations --- src/Actions/Action.php | 9 + src/Actions/Concerns/HasSchemaResolver.php | 59 + src/Commands/GraphqlGenerateCommand.php | 9 +- src/Fields/Concerns/CanMatch.php | 71 +- src/Fields/Field.php | 77 +- src/MCP/Actions/JsonSchemaFromRulesAction.php | 127 + src/MCP/Actions/SchemaAttributes.php | 2808 +++++++++++++++++ src/MCP/Concerns/FieldMcpSchemaDetection.php | 175 +- .../Concerns/JsonSchemaFromRulesResolver.php | 5 + src/MCP/Concerns/McpStoreTool.php | 5 +- src/MCP/Concerns/McpUpdateTool.php | 5 +- src/MCP/Tools/Operations/ActionTool.php | 106 +- src/MCP/Tools/Operations/IndexTool.php | 5 +- src/Repositories/Repository.php | 31 + src/Repositories/ValidatingTrait.php | 7 +- tests/Actions/FieldActionTest.php | 7 +- tests/MCP/FieldSchemaValidationTest.php | 72 +- tests/MCP/JsonSchemaFromRulesActionTest.php | 53 + tests/MCP/McpFieldsIntegrationTest.php | 23 +- 19 files changed, 3366 insertions(+), 288 deletions(-) create mode 100644 src/Actions/Concerns/HasSchemaResolver.php create mode 100644 src/MCP/Actions/JsonSchemaFromRulesAction.php create mode 100644 src/MCP/Actions/SchemaAttributes.php create mode 100644 src/MCP/Concerns/JsonSchemaFromRulesResolver.php create mode 100644 tests/MCP/JsonSchemaFromRulesActionTest.php diff --git a/src/Actions/Action.php b/src/Actions/Action.php index dc50e141..2edaed64 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -2,8 +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\MCP\Actions\JsonSchemaFromRulesAction; use Binaryk\LaravelRestify\Models\Concerns\HasActionLogs; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Traits\AuthorizedToSee; @@ -16,6 +18,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 +32,7 @@ abstract class Action implements JsonSerializable { use AuthorizedToSee; + use HasSchemaResolver; use Make; use ProxiesCanSeeToGate; use Visibility; @@ -201,6 +205,11 @@ 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() { diff --git a/src/Actions/Concerns/HasSchemaResolver.php b/src/Actions/Concerns/HasSchemaResolver.php new file mode 100644 index 00000000..2f26d576 --- /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 3c9fe14b..4205d01a 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,11 @@ protected function generateInputType(string $repositoryClass, string $typeName): return "input {$typeName}Input {\n{$fieldsString}\n}"; } + /** + * @param Field $field + * @param bool $isInput + * @return string + */ protected function mapFieldToGraphQLType($field, bool $isInput = false): string { $fieldClass = get_class($field); @@ -341,7 +348,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 6b46ef68..a0b1f25c 100644 --- a/src/Fields/Concerns/CanMatch.php +++ b/src/Fields/Concerns/CanMatch.php @@ -4,7 +4,14 @@ use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Filters\MatchFilter; +use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; +use Illuminate\JsonSchema\Types\ArrayType; +use Illuminate\JsonSchema\Types\BooleanType; +use Illuminate\JsonSchema\Types\IntegerType; +use Illuminate\JsonSchema\Types\NumberType; +use Illuminate\JsonSchema\Types\ObjectType; trait CanMatch { @@ -36,7 +43,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 +128,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 ce943f00..456bffac 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|null $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. * @@ -798,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/MCP/Actions/JsonSchemaFromRulesAction.php b/src/MCP/Actions/JsonSchemaFromRulesAction.php new file mode 100644 index 00000000..3c2e2f7b --- /dev/null +++ b/src/MCP/Actions/JsonSchemaFromRulesAction.php @@ -0,0 +1,127 @@ +> $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 ($type) { + $this->rulesSchema[$attribute] = $type; + } + } + + return data_get($this->rulesSchema, $attribute); + } + + public function buildTypeFromRule(JsonSchema $schema, string $attribute, $rule): ?Type + { + [$rule, $parameters] = ValidationRuleParser::parse($rule); + + if ($rule === '') { + return null; + } + + if ($rule instanceof Rule) { + $schemaType = match (true) { + $rule instanceof Email => $schema->string(), + $rule instanceof File => $schema->string(), + $rule instanceof Password => $schema->string(), + default => $schema->string(), + }; + + return $this->rulesSchema[$attribute] ?? $schemaType; + } + + $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', + }; + } +} diff --git a/src/MCP/Actions/SchemaAttributes.php b/src/MCP/Actions/SchemaAttributes.php new file mode 100644 index 00000000..71ac74c5 --- /dev/null +++ b/src/MCP/Actions/SchemaAttributes.php @@ -0,0 +1,2808 @@ +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; + } + + return $schema->number()->description('Must be a numeric value'); + } + + /** + * 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) + { + $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; + } + + return $schema->string()->description('Must be a string'); + } + + /** + * 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 f9adfce5..b3557b88 100644 --- a/src/MCP/Concerns/FieldMcpSchemaDetection.php +++ b/src/MCP/Concerns/FieldMcpSchemaDetection.php @@ -3,8 +3,15 @@ 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\MCP\Requests\McpRequest; 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; /** @@ -13,107 +20,81 @@ 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; - } - } - - // 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; - } - - $fieldType = $this->guessFieldType(); + $schema = new JsonSchemaTypeFactory(); - // 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() - }; + $rules = $this->getRulesForRequest($request); - // Add description - $description = $this->generateFieldDescription($repository); - $schemaField->description($description); + $ruleType = app(JsonSchemaFromRulesAction::class)->buildTypeFromRules( + $schema, + $this->attribute, + $rules, + ); - // Mark as required if field has required validation - if ($this->isRequired()) { - $schemaField->required(); - } - - return $schemaField; - } - - /** - * 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(); + $attributeType = $this->guessTypeFromAttributeName($schema); + if ($attributeType) { return $attributeType; } - // Default to string - return 'string'; + return $schema->string(); } - /** - * Generate a comprehensive description for the field. - */ - protected function generateFieldDescription(Repository $repository): string + public function getDescription(RestifyRequest $request, Repository $repository): string { + ray('getting description for '.$this->attribute); + 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; } @@ -130,17 +111,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. */ @@ -173,17 +143,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); } /** @@ -191,7 +167,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')) { @@ -269,7 +246,8 @@ protected function guessTypeFromValidationRules(): ?string return 'string'; } - if ($this->hasAnyRule($ruleStrings, ['date', 'date_format:', 'before:', 'after:', 'before_or_equal:', 'after_or_equal:'])) { + 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 } @@ -303,7 +281,7 @@ protected function hasAnyRule(array $ruleStrings, array $rulesToCheck): bool /** * Guess type from attribute name patterns. */ - protected function guessTypeFromAttributeName(): ?string + protected function guessTypeFromAttributeName(JsonSchema $schema): ?Type { $attribute = $this->attribute; @@ -315,36 +293,41 @@ protected function guessTypeFromAttributeName(): ?string // 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'; + 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 'number'; + 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 'string'; + 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 'string'; + return $schema->string(); } // Password pattern if (str_contains($attribute, 'password')) { - return 'string'; + return $schema->string(); } // Array patterns (JSON fields) if (preg_match('/_(json|data|metadata|config|settings|options)$/', $attribute) || str_contains($attribute, 'tags')) { - return 'array'; + 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 00000000..9f331d47 --- /dev/null +++ b/src/MCP/Concerns/JsonSchemaFromRulesResolver.php @@ -0,0 +1,5 @@ +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 1793bea8..2e09c8c7 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/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 3b8de4db..9b2b218f 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -36,7 +36,7 @@ public function name(): string public function description(): string { - if ($description = $this->action->description(app(McpActionRequest::class))){ + if ($description = $this->action->description(app(McpActionRequest::class))) { return $description; } @@ -68,44 +68,7 @@ public function schema(JsonSchema $schema): array $modelName = class_basename($repositoryClass::guessModelClassName()); $actionName = $this->action->name(); - $fields = []; - - foreach($this->action->rules() as $field => $rules) { - $fieldType = $this->guessTypeFromValidationRules($rules); - - $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->action->isStandalone()) { - return $fields; - } - - if ($this->action->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(); - } - - - return $fields; + return $this->action->toolSchema($schema); } public function handle(Request $request): Response @@ -141,69 +104,4 @@ public function parameter($key, $default = null) return Response::json($result); } - - protected function guessTypeFromValidationRules(array $rules): ?string - { - $ruleStrings = collect($rules)->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 field is required based on validation rules. - */ - protected function isRequired(array $rules): bool - { - return in_array('required', $rules) || - collect($rules)->contains(function ($rule) { - return is_string($rule) && str_starts_with($rule, 'required'); - }); - } - - 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; - } } diff --git a/src/MCP/Tools/Operations/IndexTool.php b/src/MCP/Tools/Operations/IndexTool.php index 543c455b..bcccef41 100644 --- a/src/MCP/Tools/Operations/IndexTool.php +++ b/src/MCP/Tools/Operations/IndexTool.php @@ -31,10 +31,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/Repositories/Repository.php b/src/Repositories/Repository.php index e3694daa..09d1fbe1 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,32 @@ public function subtitle(): ?string return null; } + /** + * This is the description used for the IndexTool MCP. + * + * @param RestifyRequest $request + * @return string + */ + 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)) { + $description .= " The model has the following attributes: {$potentialAttributesFromTable}."; + } + + return static::$description !== '' + ? static::$description + : $description; + } + public function filters(RestifyRequest $request): array { return []; diff --git a/src/Repositories/ValidatingTrait.php b/src/Repositories/ValidatingTrait.php index 213c40d9..61ba17a8 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/tests/Actions/FieldActionTest.php b/tests/Actions/FieldActionTest.php index e9d63b7f..f3553fd6 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 e64f1eae..47f81618 100644 --- a/tests/MCP/FieldSchemaValidationTest.php +++ b/tests/MCP/FieldSchemaValidationTest.php @@ -3,62 +3,62 @@ 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\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('array', $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 +70,36 @@ 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']); + } } diff --git a/tests/MCP/JsonSchemaFromRulesActionTest.php b/tests/MCP/JsonSchemaFromRulesActionTest.php new file mode 100644 index 00000000..5d74eccf --- /dev/null +++ b/tests/MCP/JsonSchemaFromRulesActionTest.php @@ -0,0 +1,53 @@ + ['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']); + } +} diff --git a/tests/MCP/McpFieldsIntegrationTest.php b/tests/MCP/McpFieldsIntegrationTest.php index 145cbbd2..4a31408a 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); From a1a1f34926962f0c0a46b8152e3e40a15a0559fb Mon Sep 17 00:00:00 2001 From: binaryk Date: Sun, 5 Oct 2025 09:59:20 +0000 Subject: [PATCH 04/15] Fix styling --- src/Commands/GraphqlGenerateCommand.php | 4 +- src/Fields/Concerns/CanMatch.php | 4 +- src/Fields/Field.php | 2 +- src/MCP/Actions/JsonSchemaFromRulesAction.php | 2 +- src/MCP/Concerns/FieldMcpSchemaDetection.php | 6 +-- src/Repositories/Repository.php | 5 +-- tests/MCP/FieldSchemaValidationTest.php | 38 +++++++++---------- 7 files changed, 26 insertions(+), 35 deletions(-) diff --git a/src/Commands/GraphqlGenerateCommand.php b/src/Commands/GraphqlGenerateCommand.php index 4205d01a..cd823695 100644 --- a/src/Commands/GraphqlGenerateCommand.php +++ b/src/Commands/GraphqlGenerateCommand.php @@ -337,9 +337,7 @@ protected function generateInputType(string $repositoryClass, string $typeName): } /** - * @param Field $field - * @param bool $isInput - * @return string + * @param Field $field */ protected function mapFieldToGraphQLType($field, bool $isInput = false): string { diff --git a/src/Fields/Concerns/CanMatch.php b/src/Fields/Concerns/CanMatch.php index a0b1f25c..0e24c70d 100644 --- a/src/Fields/Concerns/CanMatch.php +++ b/src/Fields/Concerns/CanMatch.php @@ -6,12 +6,10 @@ use Binaryk\LaravelRestify\Filters\MatchFilter; use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; use Illuminate\JsonSchema\Types\ArrayType; use Illuminate\JsonSchema\Types\BooleanType; use Illuminate\JsonSchema\Types\IntegerType; use Illuminate\JsonSchema\Types\NumberType; -use Illuminate\JsonSchema\Types\ObjectType; trait CanMatch { @@ -44,7 +42,7 @@ public function matchable(mixed $column = null, ?string $type = null): self $this->matchableColumn = $column ?? $this->getAttribute(); $this->matchableType = $type ?? $this->guessMatchType( - // we'll use the store request to identify rules and guess types + // we'll use the store request to identify rules and guess types app(RepositoryStoreRequest::class) ); diff --git a/src/Fields/Field.php b/src/Fields/Field.php index 456bffac..aff28d3f 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -159,7 +159,7 @@ class Field extends OrganicField implements JsonSerializable, Matchable, Sortabl /** * Closure to modify the generated field description. */ - public Closure|null $descriptionCallback = null; + public ?Closure $descriptionCallback = null; /** * This is the resolved JsonSchema for the field during the MCP requests. diff --git a/src/MCP/Actions/JsonSchemaFromRulesAction.php b/src/MCP/Actions/JsonSchemaFromRulesAction.php index ff2a91a0..c600ca36 100644 --- a/src/MCP/Actions/JsonSchemaFromRulesAction.php +++ b/src/MCP/Actions/JsonSchemaFromRulesAction.php @@ -26,7 +26,7 @@ class JsonSchemaFromRulesAction * * @param JsonSchema $schema The JSON Schema factory instance * @param array> $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 + * @return array Array of JSON Schema types keyed by attribute name */ public function __invoke(JsonSchema $schema, array $allRules): array { diff --git a/src/MCP/Concerns/FieldMcpSchemaDetection.php b/src/MCP/Concerns/FieldMcpSchemaDetection.php index b3557b88..7f1b727c 100644 --- a/src/MCP/Concerns/FieldMcpSchemaDetection.php +++ b/src/MCP/Concerns/FieldMcpSchemaDetection.php @@ -5,7 +5,6 @@ use Binaryk\LaravelRestify\Fields\File; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; -use Binaryk\LaravelRestify\MCP\Requests\McpRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\JsonSchema\JsonSchema; use Illuminate\JsonSchema\JsonSchemaTypeFactory; @@ -24,7 +23,7 @@ trait FieldMcpSchemaDetection */ public function guessFieldType(RestifyRequest $request): Type { - $schema = new JsonSchemaTypeFactory(); + $schema = new JsonSchemaTypeFactory; $rules = $this->getRulesForRequest($request); @@ -94,7 +93,6 @@ public function getDescription(RestifyRequest $request, Repository $repository): } } - return $description; } @@ -168,7 +166,7 @@ protected function generateFieldExamples(JsonSchema $fieldType): array protected function getNumberExamples(string $attribute): array { if (str_contains($attribute, 'price') || str_contains($attribute, 'cost') || str_contains($attribute, - 'amount')) { + 'amount')) { return ['99.99', '29.95']; } if (str_contains($attribute, 'age')) { diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 09d1fbe1..061c0b09 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -285,9 +285,6 @@ public function subtitle(): ?string /** * This is the description used for the IndexTool MCP. - * - * @param RestifyRequest $request - * @return string */ public static function description(RestifyRequest $request): string { @@ -296,7 +293,7 @@ public static function description(RestifyRequest $request): string // 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. "; + .'It provides functionalities such as listing, searching, sorting, filtering, and relationship management. '; $potentialAttributesFromTable = implode(', ', self::newModel()->getFillable()); diff --git a/tests/MCP/FieldSchemaValidationTest.php b/tests/MCP/FieldSchemaValidationTest.php index 47f81618..e0eb899a 100644 --- a/tests/MCP/FieldSchemaValidationTest.php +++ b/tests/MCP/FieldSchemaValidationTest.php @@ -19,46 +19,46 @@ public function test_field_rules_convert_to_correct_schema_types(): void { // Test string field type detection $titleField = Field::make('title')->rules(['required', 'string', 'max:255']); - $this->assertInstanceOf(StringType::class, $titleField->guessFieldType(new McpStoreRequest())); + $this->assertInstanceOf(StringType::class, $titleField->guessFieldType(new McpStoreRequest)); // Test integer field type detection $priorityField = Field::make('priority')->rules(['required', 'integer', 'min:1']); - $this->assertInstanceOf(IntegerType::class, $priorityField->guessFieldType(new McpStoreRequest())); + $this->assertInstanceOf(IntegerType::class, $priorityField->guessFieldType(new McpStoreRequest)); // Test boolean field type detection $publishedField = Field::make('is_published')->rules(['boolean']); - $this->assertInstanceOf(BooleanType::class, $publishedField->guessFieldType(new McpStoreRequest())); + $this->assertInstanceOf(BooleanType::class, $publishedField->guessFieldType(new McpStoreRequest)); // Test numeric field type detection $ratingField = Field::make('rating')->rules(['numeric', 'between:0,5']); - $this->assertInstanceOf(NumberType::class, $ratingField->guessFieldType(new McpStoreRequest())); + $this->assertInstanceOf(NumberType::class, $ratingField->guessFieldType(new McpStoreRequest)); // Test email field (should be string type) $emailField = Field::make('author_email')->rules(['required', 'email']); - $this->assertInstanceOf(StringType::class, $emailField->guessFieldType(new McpStoreRequest())); + $this->assertInstanceOf(StringType::class, $emailField->guessFieldType(new McpStoreRequest)); // Test array field type detection $tagsField = Field::make('tags')->rules(['array']); - $this->assertInstanceOf(ArrayType::class, $tagsField->guessFieldType(new McpStoreRequest())); + $this->assertInstanceOf(ArrayType::class, $tagsField->guessFieldType(new McpStoreRequest)); // Test default type for custom validation $slugField = Field::make('slug')->rules(['required', 'unique:posts,slug']); - $this->assertInstanceOf(StringType::class, $slugField->guessFieldType(new McpStoreRequest())); + $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->assertInstanceOf(StringType::class, $statusField->guessFieldType(new McpStoreRequest())); + $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->assertInstanceOf(IntegerType::class, $wordCountField->guessFieldType(new McpStoreRequest())); + $this->assertInstanceOf(IntegerType::class, $wordCountField->guessFieldType(new McpStoreRequest)); // Test numeric field with between rule $ratingField = Field::make('rating')->rules(['numeric', 'between:1,10']); - $this->assertInstanceOf(NumberType::class, $ratingField->guessFieldType(new McpStoreRequest())); + $this->assertInstanceOf(NumberType::class, $ratingField->guessFieldType(new McpStoreRequest)); // Test required field detection $requiredField = Field::make('name')->rules(['required', 'string']); @@ -73,8 +73,8 @@ public function test_field_validation_rules_format(): void public function test_field_json_schema_has_description(): void { - $request = new McpStoreRequest(); - $schemaFactory = new JsonSchemaTypeFactory(); + $request = new McpStoreRequest; + $schemaFactory = new JsonSchemaTypeFactory; $repository = PostRepository::partialMock(); $field = field('published_at')->rules(['nullable', 'date', 'after:2020-01-01'])->resolveJsonSchema( $schemaFactory, @@ -88,16 +88,16 @@ public function test_field_json_schema_has_description(): void public function test_field_json_schema_prioritize_user_description(): void { - $request = new McpStoreRequest(); - $schemaFactory = new JsonSchemaTypeFactory(); + $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.") + ->description('This is a custom description.') ->resolveJsonSchema( - $schemaFactory, - $request, - $repository - ); + $schemaFactory, + $request, + $repository + ); $schema = $field->jsonSchema()->toArray(); $this->assertSame('This is a custom description.', $schema['description']); From 64de9eee4cc66c4f4e7ded106d46f600b1e62913 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 5 Oct 2025 13:58:50 +0300 Subject: [PATCH 05/15] fix: wip --- src/Actions/Action.php | 9 ++- src/MCP/Actions/JsonSchemaFromRulesAction.php | 57 +++++++++++++++---- src/MCP/Actions/SchemaAttributes.php | 18 +++++- src/MCP/Concerns/FieldMcpSchemaDetection.php | 1 - src/MCP/Tools/Operations/ActionTool.php | 9 +-- src/MCP/Tools/Operations/IndexTool.php | 2 +- src/Repositories/Repository.php | 8 ++- tests/MCP/FieldSchemaValidationTest.php | 16 ++++++ tests/MCP/JsonSchemaFromRulesActionTest.php | 5 ++ 9 files changed, 101 insertions(+), 24 deletions(-) diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 2edaed64..11386de6 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -6,7 +6,6 @@ use Binaryk\LaravelRestify\Http\Requests\ActionRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; -use Binaryk\LaravelRestify\Models\Concerns\HasActionLogs; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Traits\AuthorizedToSee; use Binaryk\LaravelRestify\Traits\Make; @@ -65,16 +64,16 @@ public static function indexQuery(RestifyRequest $request, $query) /** * Action description, usually used in the UI or MCP. */ - public string $description = ''; + public static string $description = ''; public function name() { return Restify::humanize($this); } - public function description(RestifyRequest $request): string + public static function description(RestifyRequest $request): string { - return $this->description; + return static::$description; } /** @@ -169,7 +168,7 @@ public function handleRequest(ActionRequest $request) } if ($this->isStandalone()) { - return Transaction::run(fn () => $this->handle($request)); + return Transaction::run(fn() => $this->handle($request)); } $response = null; diff --git a/src/MCP/Actions/JsonSchemaFromRulesAction.php b/src/MCP/Actions/JsonSchemaFromRulesAction.php index ff2a91a0..c044ad83 100644 --- a/src/MCP/Actions/JsonSchemaFromRulesAction.php +++ b/src/MCP/Actions/JsonSchemaFromRulesAction.php @@ -2,7 +2,9 @@ namespace Binaryk\LaravelRestify\MCP\Actions; +use Closure; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\ArrayType; use Illuminate\JsonSchema\Types\BooleanType; @@ -21,6 +23,8 @@ class JsonSchemaFromRulesAction protected array $rulesSchema = []; + protected array $requiredAttributes = []; + /** * Convert Laravel validation rules to JSON Schema types. * @@ -48,6 +52,10 @@ public function buildTypeFromRules(JsonSchema $schema, string $attribute, array foreach ($rules as $rule) { $type = $this->buildTypeFromRule($schema, $attribute, $rule); + if ($this->isAttributeRequired($attribute)) { + $type?->required(); + } + if ($type) { $this->rulesSchema[$attribute] = $type; } @@ -58,21 +66,38 @@ public function buildTypeFromRules(JsonSchema $schema, string $attribute, array public function buildTypeFromRule(JsonSchema $schema, string $attribute, $rule): ?Type { - [$rule, $parameters] = ValidationRuleParser::parse($rule); + $existingType = $this->rulesSchema[$attribute] ?? null; - if ($rule === '') { - return null; - } + if ($rule instanceof Rule || $rule instanceof ValidationRule) { + $class = get_class($rule); + + if ($existingType) { + return $existingType; + } - if ($rule instanceof Rule) { $schemaType = match (true) { - $rule instanceof Email => $schema->string(), - $rule instanceof File => $schema->string(), - $rule instanceof Password => $schema->string(), - default => $schema->string(), + $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 $this->rulesSchema[$attribute] ?? $schemaType; + 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; @@ -123,4 +148,16 @@ public static function getPrimitiveTypeFromSchemaType(JsonSchema $schema): strin 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 index 71ac74c5..bfbb244c 100644 --- a/src/MCP/Actions/SchemaAttributes.php +++ b/src/MCP/Actions/SchemaAttributes.php @@ -1851,7 +1851,13 @@ public function validateNumeric(string $attribute, $schema, array $parameters) return $existing; } - return $schema->number()->description('Must be a numeric value'); + $newType = $schema->number()->description('Must be a numeric value'); + + if ($existing && property_exists($existing, 'isRequired') && $existing->isRequired) { + $newType->required(); + } + + return $newType; } /** @@ -1992,6 +1998,8 @@ public function validateNotRegex($attribute, $value, $parameters) */ 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'); @@ -2569,7 +2577,13 @@ public function validateString(string $attribute, $schema, array $parameters) return $existing; } - return $schema->string()->description('Must be a string'); + $newType = $schema->string()->description('Must be a string'); + + if ($existing && property_exists($existing, 'isRequired') && $existing->isRequired) { + $newType->required(); + } + + return $newType; } /** diff --git a/src/MCP/Concerns/FieldMcpSchemaDetection.php b/src/MCP/Concerns/FieldMcpSchemaDetection.php index b3557b88..2af432ce 100644 --- a/src/MCP/Concerns/FieldMcpSchemaDetection.php +++ b/src/MCP/Concerns/FieldMcpSchemaDetection.php @@ -50,7 +50,6 @@ public function guessFieldType(RestifyRequest $request): Type public function getDescription(RestifyRequest $request, Repository $repository): string { - ray('getting description for '.$this->attribute); if (is_callable($this->descriptionCallback)) { $description = call_user_func($this->descriptionCallback, $this, $repository); diff --git a/src/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index eaedd2c4..4f922f84 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -36,7 +36,7 @@ public function name(): string public function description(): string { - if ($description = $this->action->description(app(McpActionRequest::class))) { + if ($description = $this->action::description(app(McpActionRequest::class))) { return $description; } @@ -84,9 +84,10 @@ public function handle(Request $request): Response // 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) {} + return new class($id) { + public function __construct(private $id) + { + } public function parameter($key, $default = null) { diff --git a/src/MCP/Tools/Operations/IndexTool.php b/src/MCP/Tools/Operations/IndexTool.php index bcccef41..0ae28175 100644 --- a/src/MCP/Tools/Operations/IndexTool.php +++ b/src/MCP/Tools/Operations/IndexTool.php @@ -31,7 +31,7 @@ public function name(): string public function description(): string { - return $this->repository->description(app(McpIndexRequest::class)); + return $this->repository::description(app(McpIndexRequest::class)); } public function schema(JsonSchema $schema): array diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 09d1fbe1..306c6078 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -300,8 +300,14 @@ public static function description(RestifyRequest $request): string $potentialAttributesFromTable = implode(', ', self::newModel()->getFillable()); + if (empty($potentialAttributesFromTable)) { + $potentialAttributesFromTable = implode(', ', self::newModel()->getConnection() + ->getSchemaBuilder() + ->getColumnListing($table)); + } + if (! empty($potentialAttributesFromTable)) { - $description .= " The model has the following attributes: {$potentialAttributesFromTable}."; + $description .= " The model/table has the following attributes: {$potentialAttributesFromTable}."; } return static::$description !== '' diff --git a/tests/MCP/FieldSchemaValidationTest.php b/tests/MCP/FieldSchemaValidationTest.php index 47f81618..e4a3a57b 100644 --- a/tests/MCP/FieldSchemaValidationTest.php +++ b/tests/MCP/FieldSchemaValidationTest.php @@ -3,9 +3,11 @@ namespace Binaryk\LaravelRestify\Tests\MCP; use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; 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; @@ -102,4 +104,18 @@ public function test_field_json_schema_prioritize_user_description(): void $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 index 5d74eccf..18e4d2ac 100644 --- a/tests/MCP/JsonSchemaFromRulesActionTest.php +++ b/tests/MCP/JsonSchemaFromRulesActionTest.php @@ -4,6 +4,8 @@ use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; use Binaryk\LaravelRestify\Tests\IntegrationTestCase; +use Closure; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\JsonSchema\JsonSchemaTypeFactory; use Illuminate\JsonSchema\Types\IntegerType; use Illuminate\JsonSchema\Types\StringType; @@ -49,5 +51,8 @@ public function test_integer_rule_generates_correct_schema(): void $this->assertEquals('integer', $serialized['type']); $this->assertEquals(18, $serialized['minimum']); + + $reflection = new \ReflectionProperty($result['age'], 'required'); + $this->assertTrue($reflection->getValue($result['age'])); } } From 81c538213da4ae77c48c9686f37ad0481959e329 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 5 Oct 2025 13:59:28 +0300 Subject: [PATCH 06/15] fix: wip --- src/Actions/Action.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 11386de6..7ee35332 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -214,7 +214,7 @@ public function jsonSerialize() { return array_merge([ 'name' => $this->name(), - 'description' => $this->description(app(RestifyRequest::class)), + 'description' => static::description(app(RestifyRequest::class)), 'destructive' => $this instanceof DestructiveAction, 'uriKey' => $this->uriKey(), 'payload' => $this->payload(), From dc10bf5405be4987bf52e855bcedc6c9f6f9cc97 Mon Sep 17 00:00:00 2001 From: binaryk Date: Sun, 5 Oct 2025 11:00:02 +0000 Subject: [PATCH 07/15] Fix styling --- src/Actions/Action.php | 2 +- src/MCP/Actions/JsonSchemaFromRulesAction.php | 8 ++++---- src/MCP/Tools/Operations/ActionTool.php | 7 +++---- tests/MCP/FieldSchemaValidationTest.php | 7 ++----- tests/MCP/JsonSchemaFromRulesActionTest.php | 2 -- 5 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 7ee35332..7564d3b3 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -168,7 +168,7 @@ public function handleRequest(ActionRequest $request) } if ($this->isStandalone()) { - return Transaction::run(fn() => $this->handle($request)); + return Transaction::run(fn () => $this->handle($request)); } $response = null; diff --git a/src/MCP/Actions/JsonSchemaFromRulesAction.php b/src/MCP/Actions/JsonSchemaFromRulesAction.php index 491c51d5..324a4314 100644 --- a/src/MCP/Actions/JsonSchemaFromRulesAction.php +++ b/src/MCP/Actions/JsonSchemaFromRulesAction.php @@ -77,11 +77,11 @@ public function buildTypeFromRule(JsonSchema $schema, string $attribute, $rule): $schemaType = match (true) { $rule instanceof Email => $schema->string() - ->description("This field must be a valid email address."), + ->description('This field must be a valid email address.'), $rule instanceof File => $schema->string() - ->description("This field must be a valid file path."), + ->description('This field must be a valid file path.'), $rule instanceof Password => $schema->string() - ->description("This field must be a valid password."), + ->description('This field must be a valid password.'), default => $schema->string() ->description("This field uses a custom validation rule: {$class}."), }; @@ -91,7 +91,7 @@ public function buildTypeFromRule(JsonSchema $schema, string $attribute, $rule): if ($rule instanceof Closure) { return $existingType ?? $schema->string() - ->description("This field uses a custom validation closure."); + ->description('This field uses a custom validation closure.'); } [$rule, $parameters] = ValidationRuleParser::parse($rule); diff --git a/src/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 4f922f84..41eeb6b1 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -84,10 +84,9 @@ public function handle(Request $request): Response // 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) - { - } + return new class($id) + { + public function __construct(private $id) {} public function parameter($key, $default = null) { diff --git a/tests/MCP/FieldSchemaValidationTest.php b/tests/MCP/FieldSchemaValidationTest.php index 4dc0229f..8e6f93b5 100644 --- a/tests/MCP/FieldSchemaValidationTest.php +++ b/tests/MCP/FieldSchemaValidationTest.php @@ -3,7 +3,6 @@ namespace Binaryk\LaravelRestify\Tests\MCP; use Binaryk\LaravelRestify\Fields\Field; -use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; use Binaryk\LaravelRestify\MCP\Requests\McpStoreRequest; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\IntegrationTestCase; @@ -109,13 +108,11 @@ public function test_can_validate_custom_rule(): void { $field = field('published_at')->rules([new UniqueClientCompanyNameRule]); - $type = $field->guessFieldType(new McpStoreRequest()); + $type = $field->guessFieldType(new McpStoreRequest); $this->assertInstanceOf(StringType::class, $type); } } class UniqueClientCompanyNameRule implements ValidationRule { - public function validate(string $attribute, mixed $value, \Closure $fail): void - { - } + public function validate(string $attribute, mixed $value, \Closure $fail): void {} } diff --git a/tests/MCP/JsonSchemaFromRulesActionTest.php b/tests/MCP/JsonSchemaFromRulesActionTest.php index 18e4d2ac..4a92d811 100644 --- a/tests/MCP/JsonSchemaFromRulesActionTest.php +++ b/tests/MCP/JsonSchemaFromRulesActionTest.php @@ -4,8 +4,6 @@ use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction; use Binaryk\LaravelRestify\Tests\IntegrationTestCase; -use Closure; -use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\JsonSchema\JsonSchemaTypeFactory; use Illuminate\JsonSchema\Types\IntegerType; use Illuminate\JsonSchema\Types\StringType; From 725d253782532a2adfa0bb164398d83e0eb0dbbb Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 5 Oct 2025 14:24:41 +0300 Subject: [PATCH 08/15] fix: wip --- src/Actions/Action.php | 8 ++--- src/Getters/Getter.php | 25 ++++++++++++++ src/MCP/Tools/Operations/ActionTool.php | 27 ++++++++++++++-- src/MCP/Tools/Operations/GetterTool.php | 43 +++++++++++++++---------- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 7ee35332..ea6013d9 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -64,16 +64,16 @@ public static function indexQuery(RestifyRequest $request, $query) /** * Action description, usually used in the UI or MCP. */ - public static string $description = ''; + public string $description = ''; public function name() { return Restify::humanize($this); } - public static function description(RestifyRequest $request): string + public function description(RestifyRequest $request): string { - return static::$description; + return $this->description; } /** @@ -214,7 +214,7 @@ public function jsonSerialize() { return array_merge([ 'name' => $this->name(), - 'description' => static::description(app(RestifyRequest::class)), + 'description' => $this->description(app(RestifyRequest::class)), 'destructive' => $this instanceof DestructiveAction, 'uriKey' => $this->uriKey(), 'payload' => $this->payload(), diff --git a/src/Getters/Getter.php b/src/Getters/Getter.php index c9b9f342..72003fa6 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/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 4f922f84..e96d7b9a 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; @@ -36,7 +37,7 @@ public function name(): string public function description(): string { - if ($description = $this->action::description(app(McpActionRequest::class))) { + if ($description = $this->action->description(app(McpActionRequest::class))) { return $description; } @@ -64,7 +65,29 @@ public function description(): string public function schema(JsonSchema $schema): array { - return $this->action->toolSchema($schema); + $validationSchema = []; + + $modelName = class_basename($this->repository::guessModelClassName()); + + 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(); + } else if ($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(); + } + + $rulesSchema = $this->action->toolSchema($schema); + + return array_merge($rulesSchema, $validationSchema); } public function handle(Request $request): Response diff --git a/src/MCP/Tools/Operations/GetterTool.php b/src/MCP/Tools/Operations/GetterTool.php index 91aba82d..6e14aa1a 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; @@ -32,6 +33,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,27 +56,31 @@ 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(); + } else if ($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; + $querySchema = $this->repository::indexToolSchema($schema); + + $rulesSchema = $this->getter->toolSchema($schema); + + return array_merge($querySchema, $rulesSchema, $validationSchema); } public function handle(Request $request): Response From 9eae63b561b2ef6b8251a998f3144cc407fe7ed5 Mon Sep 17 00:00:00 2001 From: binaryk Date: Sun, 5 Oct 2025 11:25:21 +0000 Subject: [PATCH 09/15] Fix styling --- src/MCP/Tools/Operations/ActionTool.php | 2 +- src/MCP/Tools/Operations/GetterTool.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 3865ccbb..55e18835 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -78,7 +78,7 @@ public function schema(JsonSchema $schema): array ->title('resources') ->description("The ids of the resources {$modelName} to perform the action on. Use string 'all' to select all resources.") ->required(); - } else if ($this->action->isShownOnShow(app(RestifyRequest::class), $this->repository)) { + } 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.') diff --git a/src/MCP/Tools/Operations/GetterTool.php b/src/MCP/Tools/Operations/GetterTool.php index 6e14aa1a..1c98c135 100644 --- a/src/MCP/Tools/Operations/GetterTool.php +++ b/src/MCP/Tools/Operations/GetterTool.php @@ -64,12 +64,12 @@ public function schema(JsonSchema $schema): array $validationSchema['resources'] = $schema->array() ->items( $schema->string() - ->description("The ID of the resource to perform the getter on.") + ->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(); - } else if ($this->getter->isShownOnShow(app(RestifyRequest::class), $this->repository)) { + } 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.") From 0dac6bfc425aa805d0bf9348984a31a1c411205d Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 5 Oct 2025 14:47:19 +0300 Subject: [PATCH 10/15] fix: wip --- src/Http/Requests/GetterRequest.php | 5 ++++ src/MCP/Tools/Operations/GetterTool.php | 40 +++++++++++++++++++++---- src/Repositories/Repository.php | 2 -- src/Traits/AuthorizedToRun.php | 2 +- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/Http/Requests/GetterRequest.php b/src/Http/Requests/GetterRequest.php index 5fd5a98a..aee74c88 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/Tools/Operations/GetterTool.php b/src/MCP/Tools/Operations/GetterTool.php index 6e14aa1a..fc88d53a 100644 --- a/src/MCP/Tools/Operations/GetterTool.php +++ b/src/MCP/Tools/Operations/GetterTool.php @@ -13,6 +13,9 @@ class GetterTool extends Tool { + /** + * @var Repository|\Illuminate\Foundation\Application|mixed|object|string + */ protected Repository $repository; protected Getter $getter; @@ -69,11 +72,13 @@ public function schema(JsonSchema $schema): array ->title('resources') ->description("The ids of the resources {$modelName} to perform the getter on. Use string 'all' to select all resources.") ->required(); - } else if ($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(); + } else { + if ($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(); + } } $querySchema = $this->repository::indexToolSchema($schema); @@ -87,8 +92,31 @@ 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/Repositories/Repository.php b/src/Repositories/Repository.php index 28b8ce09..86f09cf3 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -1177,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/Traits/AuthorizedToRun.php b/src/Traits/AuthorizedToRun.php index 5bb00916..d4bddd34 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. From f364e4c7c9c37c70e6c17b4630725c995f570a21 Mon Sep 17 00:00:00 2001 From: binaryk Date: Sun, 5 Oct 2025 12:01:44 +0000 Subject: [PATCH 11/15] Fix styling --- src/MCP/Tools/Operations/GetterTool.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/MCP/Tools/Operations/GetterTool.php b/src/MCP/Tools/Operations/GetterTool.php index fc88d53a..c423a64a 100644 --- a/src/MCP/Tools/Operations/GetterTool.php +++ b/src/MCP/Tools/Operations/GetterTool.php @@ -67,7 +67,7 @@ public function schema(JsonSchema $schema): array $validationSchema['resources'] = $schema->array() ->items( $schema->string() - ->description("The ID of the resource to perform the getter on.") + ->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.") @@ -105,10 +105,9 @@ public function handle(Request $request): Response // 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) - { - } + return new class($id) + { + public function __construct(private $id) {} public function parameter($key, $default = null) { From f711200689362b303c3e553dddd3f13e910e46b0 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 5 Oct 2025 15:47:14 +0300 Subject: [PATCH 12/15] fix: wip --- src/MCP/RestifyServer.php | 6 ------ src/MCP/Tools/Operations/ActionTool.php | 5 +++++ src/MCP/Tools/Operations/DeleteTool.php | 5 +++++ src/MCP/Tools/Operations/GetterTool.php | 5 +++++ src/MCP/Tools/Operations/IndexTool.php | 5 +++++ src/MCP/Tools/Operations/ProfileTool.php | 5 +++++ src/MCP/Tools/Operations/ShowTool.php | 5 +++++ src/MCP/Tools/Operations/StoreTool.php | 5 +++++ src/MCP/Tools/Operations/UpdateTool.php | 5 +++++ 9 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index 02fa0dcd..23634d40 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -165,9 +165,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 +177,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) diff --git a/src/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 55e18835..d885eadf 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -27,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(); diff --git a/src/MCP/Tools/Operations/DeleteTool.php b/src/MCP/Tools/Operations/DeleteTool.php index 424c5ddd..f13f0237 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 fc88d53a..11048fdc 100644 --- a/src/MCP/Tools/Operations/GetterTool.php +++ b/src/MCP/Tools/Operations/GetterTool.php @@ -26,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(); diff --git a/src/MCP/Tools/Operations/IndexTool.php b/src/MCP/Tools/Operations/IndexTool.php index 0ae28175..156ec2b4 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(); diff --git a/src/MCP/Tools/Operations/ProfileTool.php b/src/MCP/Tools/Operations/ProfileTool.php index 52b273d2..4885dd48 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 1e447192..d9d039d4 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 ba92f9d2..456c28a2 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 4cd90ff8..88b4234b 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(); From 97c5189a3282f460c57e1ccf654dac8568c03cbe Mon Sep 17 00:00:00 2001 From: binaryk Date: Sun, 5 Oct 2025 12:48:02 +0000 Subject: [PATCH 13/15] Fix styling --- src/MCP/Tools/Operations/DeleteTool.php | 2 +- src/MCP/Tools/Operations/IndexTool.php | 2 +- src/MCP/Tools/Operations/ShowTool.php | 2 +- src/MCP/Tools/Operations/StoreTool.php | 2 +- src/MCP/Tools/Operations/UpdateTool.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/MCP/Tools/Operations/DeleteTool.php b/src/MCP/Tools/Operations/DeleteTool.php index f13f0237..7abba2a4 100644 --- a/src/MCP/Tools/Operations/DeleteTool.php +++ b/src/MCP/Tools/Operations/DeleteTool.php @@ -24,7 +24,7 @@ public function __construct(string $repositoryClass) public function title(): string { - return $this->repository::label() . ' Delete'; + return $this->repository::label().' Delete'; } public function name(): string diff --git a/src/MCP/Tools/Operations/IndexTool.php b/src/MCP/Tools/Operations/IndexTool.php index 156ec2b4..d901a1f5 100644 --- a/src/MCP/Tools/Operations/IndexTool.php +++ b/src/MCP/Tools/Operations/IndexTool.php @@ -24,7 +24,7 @@ public function __construct(string $repositoryClass) public function title(): string { - return $this->repository::label() . ' Index'; + return $this->repository::label().' Index'; } public function name(): string diff --git a/src/MCP/Tools/Operations/ShowTool.php b/src/MCP/Tools/Operations/ShowTool.php index d9d039d4..efb97aa4 100644 --- a/src/MCP/Tools/Operations/ShowTool.php +++ b/src/MCP/Tools/Operations/ShowTool.php @@ -24,7 +24,7 @@ public function __construct(string $repositoryClass) public function title(): string { - return $this->repository::label() . ' Show'; + return $this->repository::label().' Show'; } public function name(): string diff --git a/src/MCP/Tools/Operations/StoreTool.php b/src/MCP/Tools/Operations/StoreTool.php index 456c28a2..ff929d6c 100644 --- a/src/MCP/Tools/Operations/StoreTool.php +++ b/src/MCP/Tools/Operations/StoreTool.php @@ -24,7 +24,7 @@ public function __construct(string $repositoryClass) public function title(): string { - return $this->repository::label() . ' Create'; + return $this->repository::label().' Create'; } public function name(): string diff --git a/src/MCP/Tools/Operations/UpdateTool.php b/src/MCP/Tools/Operations/UpdateTool.php index 88b4234b..000f2975 100644 --- a/src/MCP/Tools/Operations/UpdateTool.php +++ b/src/MCP/Tools/Operations/UpdateTool.php @@ -24,7 +24,7 @@ public function __construct(string $repositoryClass) public function title(): string { - return $this->repository::label() . ' Update'; + return $this->repository::label().' Update'; } public function name(): string From 2467e3c4fdcd711da6d8f9406452f75f19fd3898 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 5 Oct 2025 23:04:23 +0300 Subject: [PATCH 14/15] fix: wip --- src/MCP/RestifyServer.php | 36 ++++++++++++++----------- src/MCP/Tools/Operations/GetterTool.php | 16 +++++------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index 23634d40..05811154 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. @@ -69,10 +74,10 @@ class RestifyServer extends Server protected function boot(): void { - collect($this->discoverTools())->each(fn (string $tool): string => $this->tools[] = $tool); + collect($this->discoverTools())->each(fn(string $tool): string => $this->tools[] = $tool); $this->discoverRepositoryTools(); - collect($this->discoverResources())->each(fn (string $resource): string => $this->resources[] = $resource); - collect($this->discoverPrompts())->each(fn (string $prompt): string => $this->prompts[] = $prompt); + collect($this->discoverResources())->each(fn(string $resource): string => $this->resources[] = $resource); + collect($this->discoverPrompts())->each(fn(string $prompt): string => $this->prompts[] = $prompt); } /** @@ -167,11 +172,11 @@ protected function discoverActionsForRepository(string $repositoryClass, Reposit $actionRequest = app(McpActionRequest::class); $repositoryInstance->resolveActions($actionRequest) - ->filter(fn ($action) => $action instanceof Action) - ->filter(fn (Action $action) => $action->isShownOnMcp($actionRequest, $repositoryInstance)) - ->filter(fn (Action $action) => $action->authorizedToSee($actionRequest)) - ->unique(fn (Action $action) => $action->uriKey()) // Avoid duplicates - ->each(fn (Action $action) => $this->tools[] = new ActionTool($repositoryClass, $action)); + ->filter(fn($action) => $action instanceof Action) + ->filter(fn(Action $action) => $action->isShownOnMcp($actionRequest, $repositoryInstance)) + ->filter(fn(Action $action) => $action->authorizedToSee($actionRequest)) + ->unique(fn(Action $action) => $action->uriKey()) // Avoid duplicates + ->each(fn(Action $action) => $this->tools[] = new ActionTool($repositoryClass, $action)); } protected function discoverGettersForRepository(string $repositoryClass, Repository $repositoryInstance): void @@ -179,11 +184,11 @@ protected function discoverGettersForRepository(string $repositoryClass, Reposit $getterRequest = app(McpGetterRequest::class); $repositoryInstance->resolveGetters($getterRequest) - ->filter(fn ($getter) => $getter instanceof Getter) - ->filter(fn (Getter $getter) => $getter->isShownOnMcp($getterRequest, $repositoryInstance)) - ->filter(fn (Getter $getter) => $getter->authorizedToSee($getterRequest)) - ->unique(fn (Getter $getter) => $getter->uriKey()) // Avoid duplicates - ->each(fn (Getter $getter) => $this->tools[] = new GetterTool($repositoryClass, $getter)); + ->filter(fn($getter) => $getter instanceof Getter) + ->filter(fn(Getter $getter) => $getter->isShownOnMcp($getterRequest, $repositoryInstance)) + ->filter(fn(Getter $getter) => $getter->authorizedToSee($getterRequest)) + ->unique(fn(Getter $getter) => $getter->uriKey()) // Avoid duplicates + ->each(fn(Getter $getter) => $this->tools[] = new GetterTool($repositoryClass, $getter)); } /** @@ -198,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/GetterTool.php b/src/MCP/Tools/Operations/GetterTool.php index 151393db..a39cb4a7 100644 --- a/src/MCP/Tools/Operations/GetterTool.php +++ b/src/MCP/Tools/Operations/GetterTool.php @@ -77,20 +77,16 @@ public function schema(JsonSchema $schema): array ->title('resources') ->description("The ids of the resources {$modelName} to perform the getter on. Use string 'all' to select all resources.") ->required(); - } else { - if ($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(); - } + } 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(); } - $querySchema = $this->repository::indexToolSchema($schema); - $rulesSchema = $this->getter->toolSchema($schema); - return array_merge($querySchema, $rulesSchema, $validationSchema); + return array_merge($rulesSchema, $validationSchema); } public function handle(Request $request): Response From a4c3372f89cb2b1c7e6a5f5acbd4b7fdd52ef20d Mon Sep 17 00:00:00 2001 From: binaryk Date: Sun, 5 Oct 2025 20:04:58 +0000 Subject: [PATCH 15/15] Fix styling --- src/MCP/RestifyServer.php | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index 05811154..f65ddd30 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -74,10 +74,10 @@ class RestifyServer extends Server protected function boot(): void { - collect($this->discoverTools())->each(fn(string $tool): string => $this->tools[] = $tool); + collect($this->discoverTools())->each(fn (string $tool): string => $this->tools[] = $tool); $this->discoverRepositoryTools(); - collect($this->discoverResources())->each(fn(string $resource): string => $this->resources[] = $resource); - collect($this->discoverPrompts())->each(fn(string $prompt): string => $this->prompts[] = $prompt); + collect($this->discoverResources())->each(fn (string $resource): string => $this->resources[] = $resource); + collect($this->discoverPrompts())->each(fn (string $prompt): string => $this->prompts[] = $prompt); } /** @@ -172,11 +172,11 @@ protected function discoverActionsForRepository(string $repositoryClass, Reposit $actionRequest = app(McpActionRequest::class); $repositoryInstance->resolveActions($actionRequest) - ->filter(fn($action) => $action instanceof Action) - ->filter(fn(Action $action) => $action->isShownOnMcp($actionRequest, $repositoryInstance)) - ->filter(fn(Action $action) => $action->authorizedToSee($actionRequest)) - ->unique(fn(Action $action) => $action->uriKey()) // Avoid duplicates - ->each(fn(Action $action) => $this->tools[] = new ActionTool($repositoryClass, $action)); + ->filter(fn ($action) => $action instanceof Action) + ->filter(fn (Action $action) => $action->isShownOnMcp($actionRequest, $repositoryInstance)) + ->filter(fn (Action $action) => $action->authorizedToSee($actionRequest)) + ->unique(fn (Action $action) => $action->uriKey()) // Avoid duplicates + ->each(fn (Action $action) => $this->tools[] = new ActionTool($repositoryClass, $action)); } protected function discoverGettersForRepository(string $repositoryClass, Repository $repositoryInstance): void @@ -184,11 +184,11 @@ protected function discoverGettersForRepository(string $repositoryClass, Reposit $getterRequest = app(McpGetterRequest::class); $repositoryInstance->resolveGetters($getterRequest) - ->filter(fn($getter) => $getter instanceof Getter) - ->filter(fn(Getter $getter) => $getter->isShownOnMcp($getterRequest, $repositoryInstance)) - ->filter(fn(Getter $getter) => $getter->authorizedToSee($getterRequest)) - ->unique(fn(Getter $getter) => $getter->uriKey()) // Avoid duplicates - ->each(fn(Getter $getter) => $this->tools[] = new GetterTool($repositoryClass, $getter)); + ->filter(fn ($getter) => $getter instanceof Getter) + ->filter(fn (Getter $getter) => $getter->isShownOnMcp($getterRequest, $repositoryInstance)) + ->filter(fn (Getter $getter) => $getter->authorizedToSee($getterRequest)) + ->unique(fn (Getter $getter) => $getter->uriKey()) // Avoid duplicates + ->each(fn (Getter $getter) => $this->tools[] = new GetterTool($repositoryClass, $getter)); } /** @@ -204,7 +204,7 @@ protected function discoverResources(): array 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) { + true) && $fqdn !== ApplicationInfo::class) { $resources[] = $fqdn; } }