Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion src/Actions/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

namespace Binaryk\LaravelRestify\Actions;

use Binaryk\LaravelRestify\Actions\Concerns\HasSchemaResolver;
use Binaryk\LaravelRestify\Http\Requests\ActionRequest;
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
use Binaryk\LaravelRestify\Models\Concerns\HasActionLogs;
use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction;
use Binaryk\LaravelRestify\Restify;
use Binaryk\LaravelRestify\Traits\AuthorizedToSee;
use Binaryk\LaravelRestify\Traits\Make;
Expand All @@ -16,6 +17,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\JsonSchema\JsonSchema;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use JsonSerializable;
Expand All @@ -29,6 +31,7 @@
abstract class Action implements JsonSerializable
{
use AuthorizedToSee;
use HasSchemaResolver;
use Make;
use ProxiesCanSeeToGate;
use Visibility;
Expand Down Expand Up @@ -58,11 +61,21 @@ public static function indexQuery(RestifyRequest $request, $query)
*/
public ?Closure $runCallback = null;

/**
* Action description, usually used in the UI or MCP.
*/
public string $description = '';

public function name()
{
return Restify::humanize($this);
}

public function description(RestifyRequest $request): string
{
return $this->description;
}

/**
* Get the URI key for the action.
*/
Expand Down Expand Up @@ -191,11 +204,17 @@ public function skipFieldFill(RestifyRequest $request): bool
return $this->skipFieldFill;
}

public function toolSchema(JsonSchema $schema): array
{
return app(JsonSchemaFromRulesAction::class)($schema, $this->rules());
}

#[ReturnTypeWillChange]
public function jsonSerialize()
{
return array_merge([
'name' => $this->name(),
'description' => $this->description(app(RestifyRequest::class)),
'destructive' => $this instanceof DestructiveAction,
'uriKey' => $this->uriKey(),
'payload' => $this->payload(),
Expand Down
59 changes: 59 additions & 0 deletions src/Actions/Concerns/HasSchemaResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Binaryk\LaravelRestify\Actions\Concerns;

use Binaryk\LaravelRestify\Actions\Action;
use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest;
use Illuminate\JsonSchema\JsonSchema;

/**
* @mixin Action
*/
trait HasSchemaResolver
{
protected function resolveActionSchema(JsonSchema $schema): array
{
$fields = [];

$allRules = $this->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();
}
}
}
7 changes: 6 additions & 1 deletion src/Commands/GraphqlGenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -334,14 +336,17 @@ protected function generateInputType(string $repositoryClass, string $typeName):
return "input {$typeName}Input {\n{$fieldsString}\n}";
}

/**
* @param Field $field
*/
protected function mapFieldToGraphQLType($field, bool $isInput = false): string
{
$fieldClass = get_class($field);
$fieldClassName = class_basename($fieldClass);

// 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':
Expand Down
69 changes: 18 additions & 51 deletions src/Fields/Concerns/CanMatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@

use Binaryk\LaravelRestify\Contracts\RestifySearchable;
use Binaryk\LaravelRestify\Filters\MatchFilter;
use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest;
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
use Illuminate\JsonSchema\Types\ArrayType;
use Illuminate\JsonSchema\Types\BooleanType;
use Illuminate\JsonSchema\Types\IntegerType;
use Illuminate\JsonSchema\Types\NumberType;

trait CanMatch
{
Expand Down Expand Up @@ -36,7 +41,10 @@ public function matchable(mixed $column = null, ?string $type = null): self
}

$this->matchableColumn = $column ?? $this->getAttribute();
$this->matchableType = $type ?? $this->guessMatchType();
$this->matchableType = $type ?? $this->guessMatchType(
// we'll use the store request to identify rules and guess types
app(RepositoryStoreRequest::class)
);

return $this;
}
Expand Down Expand Up @@ -118,56 +126,15 @@ public function getMatchType(RestifyRequest $request = null): ?string
return $this->matchableType;
}

protected function guessMatchType(): string
protected function guessMatchType(RestifyRequest $request): string
{
// Use field type detection from Field class if available
if (method_exists($this, 'guessFieldType')) {
$fieldType = $this->guessFieldType();

return match ($fieldType) {
'boolean' => RestifySearchable::MATCH_BOOL,
'number' => RestifySearchable::MATCH_INTEGER,
'array' => RestifySearchable::MATCH_ARRAY,
default => RestifySearchable::MATCH_TEXT,
};
}

// Fallback to attribute name patterns
$attribute = $this->getAttribute();

if (! is_string($attribute)) {
return RestifySearchable::MATCH_TEXT;
}

$attribute = strtolower($attribute);

// Boolean patterns
if (preg_match('/^(is_|has_|can_|should_|will_|was_|were_)/', $attribute) ||
in_array($attribute,
['active', 'enabled', 'disabled', 'verified', 'published', 'featured', 'public', 'private'])) {
return RestifySearchable::MATCH_BOOL;
}

// Number patterns
if (preg_match('/_(id|count|number|amount|price|cost|total|sum|quantity|qty)$/', $attribute) ||
in_array($attribute,
['id', 'age', 'year', 'month', 'day', 'hour', 'minute', 'second', 'weight', 'height', 'size'])) {
return RestifySearchable::MATCH_INTEGER;
}

// Date patterns
if (preg_match('/_(at|date|time)$/', $attribute) ||
in_array($attribute,
['created_at', 'updated_at', 'deleted_at', 'published_at', 'birthday', 'date_of_birth'])) {
return RestifySearchable::MATCH_DATETIME;
}

// Array patterns (JSON fields)
if (preg_match('/_(json|data|metadata|config|settings|options|tags)$/', $attribute)) {
return RestifySearchable::MATCH_ARRAY;
}

// Default to text matching
return RestifySearchable::MATCH_TEXT;
$fieldType = $this->guessFieldType($request);

return match (get_class($fieldType)) {
ArrayType::class => RestifySearchable::MATCH_ARRAY,
BooleanType::class => RestifySearchable::MATCH_BOOL,
IntegerType::class, NumberType::class => RestifySearchable::MATCH_INTEGER,
default => 'string',
};
}
}
Loading