Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions src/Commands/GraphqlGenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -344,23 +344,21 @@ protected function mapFieldToGraphQLType($field, bool $isInput = false): string
$fieldClass = get_class($field);
$fieldClassName = class_basename($fieldClass);

// Use the field's built-in type guessing if available
if (method_exists($field, 'guessFieldType')) {
// Use the field's built-in type guessing if available (Laravel 12+ only)
if (method_exists($field, 'guessFieldType') && method_exists($field, 'hasJsonSchemaSupport') && $field::hasJsonSchemaSupport()) {
$fieldType = $field->guessFieldType(app(RepositoryStoreRequest::class));

switch ($fieldType) {
case 'boolean':
return 'Boolean';
case 'number':
case 'integer':
return 'Int';
case 'array':
return $isInput ? '[String!]' : '[String!]';
case 'object':
return $isInput ? 'JSON' : 'JSON';
case 'string':
default:
return 'String';
if ($fieldType !== null) {
// Check the Type object class to determine GraphQL type
$typeClass = class_basename(get_class($fieldType));

return match ($typeClass) {
'BooleanType' => 'Boolean',
'NumberType', 'IntegerType' => 'Int',
'ArrayType' => $isInput ? '[String!]' : '[String!]',
'ObjectType' => $isInput ? 'JSON' : 'JSON',
default => 'String',
};
}
}

Expand Down
46 changes: 31 additions & 15 deletions src/MCP/Concerns/FieldMcpSchemaDetection.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,35 @@
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction;
use Binaryk\LaravelRestify\Repositories\Repository;
use Illuminate\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
use Illuminate\JsonSchema\Types\ArrayType;
use Illuminate\JsonSchema\Types\BooleanType;
use Illuminate\JsonSchema\Types\NumberType;
use Illuminate\JsonSchema\Types\Type;

/**
* @mixin \Binaryk\LaravelRestify\Fields\Field
*/
trait FieldMcpSchemaDetection
{
/**
* Check if Laravel 12+ JsonSchema classes are available.
*/
public static function hasJsonSchemaSupport(): bool
{
// Check for the interface first - this is what Laravel 12 provides
// The class_exists check on JsonSchemaTypeFactory would trigger autoloading
// which fails on Laravel 11 because the interface doesn't exist
return interface_exists(\Illuminate\Contracts\JsonSchema\JsonSchema::class);
}

/**
* Guess the field type based on validation rules, field class, and attribute patterns.
*
* @return \Illuminate\JsonSchema\Types\Type|null Returns Type on Laravel 12+, null on Laravel 11
*/
public function guessFieldType(RestifyRequest $request): Type
public function guessFieldType(RestifyRequest $request): mixed
{
$schema = new JsonSchemaTypeFactory;
if (! static::hasJsonSchemaSupport()) {
return null;
}

$schema = new \Illuminate\JsonSchema\JsonSchemaTypeFactory;

$rules = $this->getRulesForRequest($request);

Expand Down Expand Up @@ -84,8 +95,8 @@ public function getDescription(RestifyRequest $request, Repository $repository):
}
}

// Add examples based on field type and name
if ($this->jsonSchema instanceof Type) {
// Add examples based on field type and name (Laravel 12+ only)
if (static::hasJsonSchemaSupport() && $this->jsonSchema instanceof \Illuminate\JsonSchema\Types\Type) {
$examples = $this->generateFieldExamples($this->jsonSchema);

if (! empty($examples)) {
Expand Down Expand Up @@ -140,20 +151,22 @@ protected function formatValidationRules(array $rules): array

/**
* Generate examples for the field.
*
* @param \Illuminate\JsonSchema\JsonSchema $fieldType
*/
protected function generateFieldExamples(JsonSchema $fieldType): array
protected function generateFieldExamples(mixed $fieldType): array
{
$attribute = strtolower($this->attribute);

if ($fieldType instanceof BooleanType) {
if ($fieldType instanceof \Illuminate\JsonSchema\Types\BooleanType) {
return ['true', 'false'];
}

if ($fieldType instanceof NumberType) {
if ($fieldType instanceof \Illuminate\JsonSchema\Types\NumberType) {
return $this->getNumberExamples($attribute);
}

if ($fieldType instanceof ArrayType) {
if ($fieldType instanceof \Illuminate\JsonSchema\Types\ArrayType) {
return ['["item1", "item2"]', '{"key": "value"}'];
}

Expand Down Expand Up @@ -278,8 +291,11 @@ protected function hasAnyRule(array $ruleStrings, array $rulesToCheck): bool

/**
* Guess type from attribute name patterns.
*
* @param \Illuminate\JsonSchema\JsonSchema $schema
* @return \Illuminate\JsonSchema\Types\Type|null
*/
protected function guessTypeFromAttributeName(JsonSchema $schema): ?Type
protected function guessTypeFromAttributeName(mixed $schema): mixed
{
$attribute = $this->attribute;

Expand Down
44 changes: 25 additions & 19 deletions tests/MCP/FieldSchemaValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,59 +7,65 @@
use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository;
use Binaryk\LaravelRestify\Tests\IntegrationTestCase;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
use Illuminate\JsonSchema\Types\ArrayType;
use Illuminate\JsonSchema\Types\BooleanType;
use Illuminate\JsonSchema\Types\IntegerType;
use Illuminate\JsonSchema\Types\NumberType;
use Illuminate\JsonSchema\Types\StringType;

/**
* @requires Laravel 12+
*/
class FieldSchemaValidationTest extends IntegrationTestCase
{
protected function setUp(): void
{
parent::setUp();

if (! interface_exists(\Illuminate\Contracts\JsonSchema\JsonSchema::class)) {
$this->markTestSkipped('JsonSchema classes are only available in Laravel 12+');
}
}

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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\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(\Illuminate\JsonSchema\Types\NumberType::class, $ratingField->guessFieldType(new McpStoreRequest));

// Test required field detection
$requiredField = Field::make('name')->rules(['required', 'string']);
Expand All @@ -75,7 +81,7 @@ public function test_field_validation_rules_format(): void
public function test_field_json_schema_has_description(): void
{
$request = new McpStoreRequest;
$schemaFactory = new JsonSchemaTypeFactory;
$schemaFactory = new \Illuminate\JsonSchema\JsonSchemaTypeFactory;
$repository = PostRepository::partialMock();
$field = field('published_at')->rules(['nullable', 'date', 'after:2020-01-01'])->resolveJsonSchema(
$schemaFactory,
Expand All @@ -90,7 +96,7 @@ 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;
$schemaFactory = new \Illuminate\JsonSchema\JsonSchemaTypeFactory;
$repository = PostRepository::partialMock();
$field = field('published_at')->rules(['nullable', 'date', 'after:2020-01-01'])
->description('This is a custom description.')
Expand All @@ -109,7 +115,7 @@ 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);
$this->assertInstanceOf(\Illuminate\JsonSchema\Types\StringType::class, $type);
}
}
class UniqueClientCompanyNameRule implements ValidationRule
Expand Down
23 changes: 16 additions & 7 deletions tests/MCP/JsonSchemaFromRulesActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,25 @@

use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction;
use Binaryk\LaravelRestify\Tests\IntegrationTestCase;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
use Illuminate\JsonSchema\Types\IntegerType;
use Illuminate\JsonSchema\Types\StringType;

/**
* @requires Laravel 12+
*/
class JsonSchemaFromRulesActionTest extends IntegrationTestCase
{
protected function setUp(): void
{
parent::setUp();

if (! interface_exists(\Illuminate\Contracts\JsonSchema\JsonSchema::class)) {
$this->markTestSkipped('JsonSchema classes are only available in Laravel 12+');
}
}

public function test_before_date_rule_generates_correct_schema(): void
{
$action = new JsonSchemaFromRulesAction;
$schema = new JsonSchemaTypeFactory;
$schema = new \Illuminate\JsonSchema\JsonSchemaTypeFactory;

$rules = [
'event_date' => ['required', 'date', 'before:2025-12-31'],
Expand All @@ -22,7 +31,7 @@ public function test_before_date_rule_generates_correct_schema(): void
$result = $action($schema, $rules);

$this->assertArrayHasKey('event_date', $result);
$this->assertInstanceOf(StringType::class, $result['event_date']);
$this->assertInstanceOf(\Illuminate\JsonSchema\Types\StringType::class, $result['event_date']);

$serialized = $result['event_date']->toArray();

Expand All @@ -34,7 +43,7 @@ public function test_before_date_rule_generates_correct_schema(): void
public function test_integer_rule_generates_correct_schema(): void
{
$action = new JsonSchemaFromRulesAction;
$schema = new JsonSchemaTypeFactory;
$schema = new \Illuminate\JsonSchema\JsonSchemaTypeFactory;

$rules = [
'age' => ['required', 'integer', 'min:18'],
Expand All @@ -43,7 +52,7 @@ public function test_integer_rule_generates_correct_schema(): void
$result = $action($schema, $rules);

$this->assertArrayHasKey('age', $result);
$this->assertInstanceOf(IntegerType::class, $result['age']);
$this->assertInstanceOf(\Illuminate\JsonSchema\Types\IntegerType::class, $result['age']);

$serialized = $result['age']->toArray();

Expand Down
Loading