Skip to content

Commit 103ae1e

Browse files
authored
[9.x] Introduce Laravel Precognition (#44339)
* Introduce Laravel Precognition * linting * fix test * support older PHP versions
1 parent eb7baf3 commit 103ae1e

23 files changed

+1338
-16
lines changed

src/Illuminate/Foundation/Http/FormRequest.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,14 @@ protected function getValidatorInstance()
109109
*/
110110
protected function createDefaultValidator(ValidationFactory $factory)
111111
{
112+
$rules = $this->container->call([$this, 'rules']);
113+
114+
if ($this->isPrecognitive()) {
115+
$rules = $this->filterPrecognitiveRules($rules);
116+
}
117+
112118
return $factory->make(
113-
$this->validationData(), $this->container->call([$this, 'rules']),
119+
$this->validationData(), $rules,
114120
$this->messages(), $this->attributes()
115121
)->stopOnFirstFailure($this->stopOnFirstFailure);
116122
}

src/Illuminate/Foundation/Http/Kernel.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class Kernel implements KernelContract
9191
* @var string[]
9292
*/
9393
protected $middlewarePriority = [
94+
\Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
9495
\Illuminate\Cookie\Middleware\EncryptCookies::class,
9596
\Illuminate\Session\Middleware\StartSession::class,
9697
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace Illuminate\Foundation\Http\Middleware;
4+
5+
use Illuminate\Container\Container;
6+
use Illuminate\Foundation\Routing\PrecognitionCallableDispatcher;
7+
use Illuminate\Foundation\Routing\PrecognitionControllerDispatcher;
8+
use Illuminate\Http\Response;
9+
use Illuminate\Routing\Contracts\CallableDispatcher as CallableDispatcherContract;
10+
use Illuminate\Routing\Contracts\ControllerDispatcher as ControllerDispatcherContract;
11+
12+
class HandlePrecognitiveRequests
13+
{
14+
/**
15+
*The container instance.
16+
*
17+
* @var \Illuminate\Container\Container
18+
*/
19+
protected $container;
20+
21+
/**
22+
* Create a new middleware instance.
23+
*
24+
* @param \Illuminate\Container\Container $container
25+
* @return void
26+
*/
27+
public function __construct(Container $container)
28+
{
29+
$this->container = $container;
30+
}
31+
32+
/**
33+
* Handle an incoming request.
34+
*
35+
* @param \Illuminate\Http\Request $request
36+
* @param \Closure $next
37+
* @return \Illuminate\Http\Response
38+
*/
39+
public function handle($request, $next)
40+
{
41+
if (! $request->isAttemptingPrecognition()) {
42+
return $this->appendVaryHeader($request, $next($request));
43+
}
44+
45+
$this->prepareForPrecognition($request);
46+
47+
return tap($next($request), function ($response) use ($request) {
48+
$this->appendVaryHeader($request, $response->header('Precognition', 'true'));
49+
});
50+
}
51+
52+
/**
53+
* Prepare to handle a precognitive request.
54+
*
55+
* @param \Illuminate\Http\Request $request
56+
* @return void
57+
*/
58+
protected function prepareForPrecognition($request)
59+
{
60+
$request->attributes->set('precognitive', true);
61+
62+
$this->container->bind(CallableDispatcherContract::class, fn ($app) => new PrecognitionCallableDispatcher($app));
63+
$this->container->bind(ControllerDispatcherContract::class, fn ($app) => new PrecognitionControllerDispatcher($app));
64+
}
65+
66+
/**
67+
* Append the appropriate "Vary" header to the given response.
68+
*
69+
* @param \Illuminate\Http\Request $request
70+
* @param \Illuminate\Http\Response $response
71+
* @return \Illuminate\Http\Response $response
72+
*/
73+
protected function appendVaryHeader($request, $response)
74+
{
75+
return $response->header('Vary', implode(', ', array_filter([
76+
$response->headers->get('Vary'),
77+
'Precognition',
78+
])));
79+
}
80+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace Illuminate\Foundation;
4+
5+
class Precognition
6+
{
7+
/**
8+
* Get the "after" validation hook that can be used for precognition requests.
9+
*
10+
* @param \Illuminate\Http\Request $request
11+
* @return \Closure
12+
*/
13+
public static function afterValidationHook($request)
14+
{
15+
return function ($validator) use ($request) {
16+
if ($validator->messages()->isEmpty() && $request->headers->has('Precognition-Validate-Only')) {
17+
abort(204);
18+
}
19+
};
20+
}
21+
}

src/Illuminate/Foundation/Providers/FoundationServiceProvider.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Foundation\Console\CliDumper;
77
use Illuminate\Foundation\Http\HtmlDumper;
88
use Illuminate\Foundation\MaintenanceModeManager;
9+
use Illuminate\Foundation\Precognition;
910
use Illuminate\Foundation\Vite;
1011
use Illuminate\Http\Request;
1112
use Illuminate\Log\Events\MessageLogged;
@@ -98,7 +99,15 @@ public function registerDumper()
9899
public function registerRequestValidation()
99100
{
100101
Request::macro('validate', function (array $rules, ...$params) {
101-
return validator()->validate($this->all(), $rules, ...$params);
102+
$rules = $this->isPrecognitive()
103+
? $this->filterPrecognitiveRules($rules)
104+
: $rules;
105+
106+
return tap(validator($this->all(), $rules, ...$params), function ($validator) {
107+
if ($this->isPrecognitive()) {
108+
$validator->after(Precognition::afterValidationHook($this));
109+
}
110+
})->validate();
102111
});
103112

104113
Request::macro('validateWithBag', function (string $errorBag, array $rules, ...$params) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Illuminate\Foundation\Routing;
4+
5+
use Illuminate\Routing\CallableDispatcher;
6+
use Illuminate\Routing\Route;
7+
8+
class PrecognitionCallableDispatcher extends CallableDispatcher
9+
{
10+
/**
11+
* Dispatch a request to a given callable.
12+
*
13+
* @param \Illuminate\Routing\Route $route
14+
* @param callable $callable
15+
* @return mixed
16+
*/
17+
public function dispatch(Route $route, $callable)
18+
{
19+
$this->resolveParameters($route, $callable);
20+
21+
abort(204);
22+
}
23+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace Illuminate\Foundation\Routing;
4+
5+
use Illuminate\Routing\ControllerDispatcher;
6+
use Illuminate\Routing\Route;
7+
use RuntimeException;
8+
9+
class PrecognitionControllerDispatcher extends ControllerDispatcher
10+
{
11+
/**
12+
* Dispatch a request to a given controller and method.
13+
*
14+
* @param \Illuminate\Routing\Route $route
15+
* @param mixed $controller
16+
* @param string $method
17+
* @return void
18+
*/
19+
public function dispatch(Route $route, $controller, $method)
20+
{
21+
$this->ensureMethodExists($controller, $method);
22+
23+
$this->resolveParameters($route, $controller, $method);
24+
25+
abort(204);
26+
}
27+
28+
/**
29+
* Ensure that the given method exists on the controller.
30+
*
31+
* @param object $controller
32+
* @param string $method
33+
* @return $this
34+
*/
35+
protected function ensureMethodExists($controller, $method)
36+
{
37+
if (method_exists($controller, $method)) {
38+
return $this;
39+
}
40+
41+
$class = $controller::class;
42+
43+
throw new RuntimeException("Attempting to predict the outcome of the [{$class}::{$method}()] method but the method is not defined.");
44+
}
45+
}

src/Illuminate/Foundation/Validation/ValidatesRequests.php

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Illuminate\Foundation\Validation;
44

55
use Illuminate\Contracts\Validation\Factory;
6+
use Illuminate\Foundation\Precognition;
67
use Illuminate\Http\Request;
78
use Illuminate\Validation\ValidationException;
89

@@ -22,10 +23,22 @@ public function validateWith($validator, Request $request = null)
2223
$request = $request ?: request();
2324

2425
if (is_array($validator)) {
25-
$validator = $this->getValidationFactory()->make($request->all(), $validator);
26+
$rules = $request->isPrecognitive()
27+
? $request->filterPrecognitiveRules($validator)
28+
: $validator;
29+
30+
$validator = $this->getValidationFactory()->make($request->all(), $rules);
31+
} elseif ($request->isPrecognitive()) {
32+
$validator->setRules(
33+
$request->filterPrecognitiveRules($validator->getRules())
34+
);
2635
}
2736

28-
return $validator->validate();
37+
return tap($validator, function ($validator) use ($request) {
38+
if ($request->isPrecognitive()) {
39+
$validator->after(Precognition::afterValidationHook($request));
40+
}
41+
})->validate();
2942
}
3043

3144
/**
@@ -42,9 +55,19 @@ public function validateWith($validator, Request $request = null)
4255
public function validate(Request $request, array $rules,
4356
array $messages = [], array $customAttributes = [])
4457
{
45-
return $this->getValidationFactory()->make(
58+
$rules = $request->isPrecognitive()
59+
? $request->filterPrecognitiveRules($rules)
60+
: $rules;
61+
62+
$validator = $this->getValidationFactory()->make(
4663
$request->all(), $rules, $messages, $customAttributes
47-
)->validate();
64+
);
65+
66+
return tap($validator, function ($validator) use ($request) {
67+
if ($request->isPrecognitive()) {
68+
$validator->after(Precognition::afterValidationHook($request));
69+
}
70+
})->validate();
4871
}
4972

5073
/**

src/Illuminate/Foundation/helpers.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Illuminate\Foundation\Mix;
1818
use Illuminate\Http\Exceptions\HttpResponseException;
1919
use Illuminate\Queue\CallQueuedClosure;
20+
use Illuminate\Routing\Router;
2021
use Illuminate\Support\Facades\Date;
2122
use Illuminate\Support\HtmlString;
2223
use Symfony\Component\HttpFoundation\Response;
@@ -601,6 +602,35 @@ function policy($class)
601602
}
602603
}
603604

605+
if (! function_exists('precognitive')) {
606+
/**
607+
* Handle a Precognition controller hook.
608+
*
609+
* @param null|callable $callable
610+
* @return mixed
611+
*/
612+
function precognitive($callable = null)
613+
{
614+
$callable ??= function () {
615+
//
616+
};
617+
618+
$payload = $callable(function ($default, $precognition = null) {
619+
$response = request()->isPrecognitive()
620+
? ($precognition ?? $default)
621+
: $default;
622+
623+
abort(Router::toResponse(request(), value($response)));
624+
});
625+
626+
if (request()->isPrecognitive()) {
627+
abort(204);
628+
}
629+
630+
return $payload;
631+
}
632+
}
633+
604634
if (! function_exists('public_path')) {
605635
/**
606636
* Get the path to the public folder.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace Illuminate\Http\Concerns;
4+
5+
use Illuminate\Support\Collection;
6+
7+
trait CanBePrecognitive
8+
{
9+
/**
10+
* Filter the given array of rules into an array of rules that are included in precognitive headers.
11+
*
12+
* @param array $rules
13+
* @return array
14+
*/
15+
public function filterPrecognitiveRules($rules)
16+
{
17+
if (! $this->headers->has('Precognition-Validate-Only')) {
18+
return $rules;
19+
}
20+
21+
return Collection::make($rules)
22+
->only(explode(',', $this->header('Precognition-Validate-Only')))
23+
->all();
24+
}
25+
26+
/**
27+
* Determine if the request is attempting to be precognitive.
28+
*
29+
* @return bool
30+
*/
31+
public function isAttemptingPrecognition()
32+
{
33+
return $this->header('Precognition') === 'true';
34+
}
35+
36+
/**
37+
* Determine if the request is precognitive.
38+
*
39+
* @return bool
40+
*/
41+
public function isPrecognitive()
42+
{
43+
return $this->attributes->get('precognitive', false);
44+
}
45+
}

0 commit comments

Comments
 (0)