diff --git a/src/ActionRequest.php b/src/ActionRequest.php index 44c695a..c812142 100644 --- a/src/ActionRequest.php +++ b/src/ActionRequest.php @@ -11,7 +11,10 @@ class ActionRequest extends FormRequest public function validateResolved(): void { - // Cancel the auto-resolution trait. + // Only run auto-resolution trait for precognitive requests. + if (request()->isPrecognitive()) { + parent::validateResolved(); + } } public function getDefaultValidationData(): array diff --git a/src/Concerns/ValidateActions.php b/src/Concerns/ValidateActions.php index 3b788fd..cf1cfdf 100644 --- a/src/Concerns/ValidateActions.php +++ b/src/Concerns/ValidateActions.php @@ -6,6 +6,7 @@ use Illuminate\Auth\Access\Response; use Illuminate\Contracts\Validation\Factory as ValidationFactory; use Illuminate\Contracts\Validation\Validator; +use Illuminate\Http\Concerns\CanBePrecognitive; use Illuminate\Routing\Redirector; use Illuminate\Validation\ValidationException; @@ -67,9 +68,13 @@ protected function getValidatorInstance(): Validator protected function createDefaultValidator(ValidationFactory $factory): Validator { - return $factory->make( + $rules = $this->rules(); + if (in_array(CanBePrecognitive::class, class_uses_recursive($this)) && $this->isPrecognitive()) + $rules = $this->filterPrecognitiveRules($rules); + + return $factory->make( $this->validationData(), - $this->rules(), + $rules, $this->messages(), $this->attributes() ); diff --git a/src/Decorators/ControllerDecorator.php b/src/Decorators/ControllerDecorator.php index a8d7696..449b1f3 100644 --- a/src/Decorators/ControllerDecorator.php +++ b/src/Decorators/ControllerDecorator.php @@ -3,12 +3,14 @@ namespace Lorisleiva\Actions\Decorators; use Illuminate\Container\Container; +use Illuminate\Foundation\Routing\PrecognitionControllerDispatcher; use Illuminate\Routing\Route; use Illuminate\Routing\RouteDependencyResolverTrait; use Illuminate\Support\Str; use Lorisleiva\Actions\ActionRequest; use Lorisleiva\Actions\Concerns\DecorateActions; use Lorisleiva\Actions\Concerns\WithAttributes; +use Lorisleiva\Actions\Routing\PrecognitionActionControllerDispatcher; class ControllerDecorator { @@ -33,6 +35,8 @@ public function __construct($action, Route $route) if ($this->hasMethod('getControllerMiddleware')) { $this->middleware = $this->resolveAndCallMethod('getControllerMiddleware'); } + app()->bind(PrecognitionControllerDispatcher::class, PrecognitionActionControllerDispatcher::class); + app()->extend(ActionRequest::class, fn(ActionRequest $request) => $request->setAction($action)); } public function getRoute(): Route @@ -58,7 +62,7 @@ public function callAction($method, $parameters) public function __invoke(string $method) { $this->refreshAction(); - $request = $this->refreshRequest(); + $request = app(ActionRequest::class); if ($this->shouldValidateRequest($method)) { $request->validate(); @@ -84,18 +88,6 @@ protected function refreshAction(): void $this->executedAtLeastOne = true; } - protected function refreshRequest(): ActionRequest - { - app()->forgetInstance(ActionRequest::class); - - /** @var ActionRequest $request */ - $request = app(ActionRequest::class); - $request->setAction($this->action); - app()->instance(ActionRequest::class, $request); - - return $request; - } - protected function replaceRouteMethod(): void { if (! isset($this->route->action['uses'])) { diff --git a/src/Routing/Middleware/HandlePrecognitiveActionRequests.php b/src/Routing/Middleware/HandlePrecognitiveActionRequests.php new file mode 100644 index 0000000..e5991a7 --- /dev/null +++ b/src/Routing/Middleware/HandlePrecognitiveActionRequests.php @@ -0,0 +1,24 @@ +<?php + +namespace Lorisleiva\Actions\Routing\Middleware; + +use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests; +use Illuminate\Routing\Contracts\ControllerDispatcher; +use Lorisleiva\Actions\Routing\PrecognitionActionControllerDispatcher; + +class HandlePrecognitiveActionRequests extends HandlePrecognitiveRequests +{ + + /** + * Prepare to handle a precognitive request. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function prepareForPrecognition($request) + { + parent::prepareForPrecognition($request); + + $this->container->bind(ControllerDispatcher::class, PrecognitionActionControllerDispatcher::class); + } +} diff --git a/src/Routing/PrecognitionActionControllerDispatcher.php b/src/Routing/PrecognitionActionControllerDispatcher.php new file mode 100644 index 0000000..69a1c8a --- /dev/null +++ b/src/Routing/PrecognitionActionControllerDispatcher.php @@ -0,0 +1,28 @@ +<?php + +namespace Lorisleiva\Actions\Routing; + +use Illuminate\Foundation\Routing\PrecognitionControllerDispatcher; +use Illuminate\Routing\Route; +use Lorisleiva\Actions\Decorators\ControllerDecorator; + +class PrecognitionActionControllerDispatcher extends PrecognitionControllerDispatcher +{ + /** + * Dispatch a request to a given controller and method. + * + * @param \Illuminate\Routing\Route $route + * @param mixed $controller + * @param string $method + * @return void + */ + public function dispatch(Route $route, $controller, $method) + { + // In order to work with Laravel Actions + // we need the controller class from the route action + if ($controller instanceof ControllerDecorator) { + $controller = $route->action["controller"]; + } + parent::dispatch($route, $controller, $method); + } +} diff --git a/tests/AsControllerWithPrecognitionTest.php b/tests/AsControllerWithPrecognitionTest.php new file mode 100644 index 0000000..445e719 --- /dev/null +++ b/tests/AsControllerWithPrecognitionTest.php @@ -0,0 +1,107 @@ +<?php + +namespace Lorisleiva\Actions\Tests; + +use Illuminate\Support\Facades\Route; +use Lorisleiva\Actions\ActionRequest; +use Lorisleiva\Actions\Concerns\AsController; +use Lorisleiva\Actions\Concerns\AsFake; +use Lorisleiva\Actions\Routing\Middleware\HandlePrecognitiveActionRequests; + +class AsControllerWithPrecognitionTest +{ + use AsController, AsFake; + + public function authorize(ActionRequest $request): bool + { + return $request->header('auth') !== 'unauthorized'; + } + + public function rules(): array + { + return [ + 'requiredString' => ['required', 'string'], + 'optionalNumber' => ['numeric'], + ]; + } + + public function handle(ActionRequest $request) + { + return $request->get('requiredString'); + } +} + +beforeEach(function () { + Route::post('/normal', AsControllerWithPrecognitionTest::class); + Route::middleware(HandlePrecognitiveActionRequests::class)->post( + '/precognition', + AsControllerWithPrecognitionTest::class, + ); +}); + +it('correctly handles successful precognitive requests', function () { + AsControllerWithPrecognitionTest::partialMock()->shouldNotReceive('handle'); + $request = $this->withHeader('Precognition', 'true') + ->postJson('/precognition', ['requiredString' => 'test']) + ->assertNoContent() + ->assertHeader('Precognition', true); + // only present on Laravel 10.11.0+ (#47081) + if (version_compare(app()->version(), '10.11.0', '>=')) + $request->assertHeader('Precognition-Success', true); +}); + +it('correctly handles successful precognitive validate only requests', function () { + AsControllerWithPrecognitionTest::partialMock()->shouldNotReceive('handle'); + $request = $this->withHeader('Precognition', 'true') + ->withHeader('Precognition-Validate-Only', 'optionalNumber') + ->postJson('/precognition', ['optionalNumber' => '5']) + ->assertNoContent() + ->assertHeader('Precognition', true); + // only present on Laravel 10.11.0+ (#47081) + if (version_compare(app()->version(), '10.11.0', '>=')) + $request->assertHeader('Precognition-Success', true); +}); + +it('correctly handles unsuccessful precognitive requests', function () { + AsControllerWithPrecognitionTest::partialMock()->shouldNotReceive('handle'); + $this->withHeader('Precognition', 'true') + ->postJson('/precognition', ['optionalNumber' => 'NaN']) + ->assertUnprocessable() + ->assertJsonValidationErrorFor('requiredString') + ->assertJsonValidationErrorFor('optionalNumber') + ->assertHeader('Precognition', true) + ->assertHeaderMissing('Precognition-Success'); +}); + +it('correctly handles unsuccessful precognitive validate only requests', function () { + AsControllerWithPrecognitionTest::partialMock()->shouldNotReceive('handle'); + $this->withHeader('Precognition', 'true') + ->withHeader('Precognition-Validate-Only', 'optionalNumber') + ->postJson('/precognition', ['optionalNumber' => 'NaN']) + ->assertUnprocessable() + ->assertJsonValidationErrorFor('optionalNumber') + ->assertJsonMissingValidationErrors('requiredString') + ->assertHeader('Precognition', true) + ->assertHeaderMissing('Precognition-Success'); +}); + +it('does not mistakenly make non-precognition actions precognitive', function () { + $string = fake()->text(); + $this->withHeader('Precognition', 'true') + ->postJson('/normal', ['requiredString' => $string, 'optionalNumber' => '5']) + ->assertContent($string) + ->assertOk() + ->assertHeaderMissing('Precognition') + ->assertHeaderMissing('Precognition-Success'); +}); + +it('does not mistakenly make non-precognition actions precognitive with validate only', function () { + $this->withHeader('Precognition', 'true') + ->withHeader('Precognition-Validate-Only', 'optionalNumber') + ->postJson('/normal', ['optionalNumber' => 'NaN']) + ->assertUnprocessable() + ->assertJsonValidationErrorFor('optionalNumber') + ->assertJsonValidationErrorFor('requiredString') + ->assertHeaderMissing('Precognition') + ->assertHeaderMissing('Precognition-Success'); +});