diff --git a/src/Contracts/MiddlewarePipeline.php b/src/Contracts/MiddlewarePipeline.php index 5a2948c6..66146d61 100644 --- a/src/Contracts/MiddlewarePipeline.php +++ b/src/Contracts/MiddlewarePipeline.php @@ -4,6 +4,8 @@ namespace Saloon\Contracts; +use Saloon\Data\PipeOrder; + interface MiddlewarePipeline { /** @@ -12,7 +14,7 @@ interface MiddlewarePipeline * @param callable(\Saloon\Contracts\PendingRequest): (\Saloon\Contracts\PendingRequest|\Saloon\Contracts\FakeResponse|void) $callable * @return $this */ - public function onRequest(callable $callable, bool $prepend = false, ?string $name = null): static; + public function onRequest(callable $callable, ?string $name = null, ?PipeOrder $order = null): static; /** * Add a middleware after the request is sent @@ -20,7 +22,7 @@ public function onRequest(callable $callable, bool $prepend = false, ?string $na * @param callable(\Saloon\Contracts\Response): (\Saloon\Contracts\Response|void) $callable * @return $this */ - public function onResponse(callable $callable, bool $prepend = false, ?string $name = null): static; + public function onResponse(callable $callable, ?string $name = null, ?PipeOrder $order = null): static; /** * Process the request pipeline. diff --git a/src/Contracts/Pipeline.php b/src/Contracts/Pipeline.php index 414d127a..4a129ce7 100644 --- a/src/Contracts/Pipeline.php +++ b/src/Contracts/Pipeline.php @@ -4,6 +4,8 @@ namespace Saloon\Contracts; +use Saloon\Data\PipeOrder; + interface Pipeline { /** @@ -13,7 +15,7 @@ interface Pipeline * @return $this * @throws \Saloon\Exceptions\DuplicatePipeNameException */ - public function pipe(callable $callable, bool $prepend = false, ?string $name = null): static; + public function pipe(callable $callable, ?string $name = null, ?PipeOrder $order = null): static; /** * Process the pipeline. diff --git a/src/Data/Pipe.php b/src/Data/Pipe.php index 11bdd0f8..108808c4 100644 --- a/src/Data/Pipe.php +++ b/src/Data/Pipe.php @@ -18,6 +18,7 @@ class Pipe public function __construct( callable $callable, readonly public ?string $name = null, + readonly public ?PipeOrder $order = null, ) { $this->callable = $callable(...); } diff --git a/src/Data/PipeOrder.php b/src/Data/PipeOrder.php new file mode 100644 index 00000000..57bcc522 --- /dev/null +++ b/src/Data/PipeOrder.php @@ -0,0 +1,35 @@ +pipeExists($name)) { throw new DuplicatePipeNameException($name); } - $prepend === true - ? array_unshift($this->pipes, $pipe) - : $this->pipes[] = $pipe; + $this->pipes[] = $pipe; return $this; } @@ -44,13 +44,46 @@ public function pipe(callable $callable, bool $prepend = false, ?string $name = */ public function process(mixed $payload): mixed { - foreach ($this->pipes as $pipe) { + foreach ($this->sortPipes() as $pipe) { $payload = call_user_func($pipe->callable, $payload); } return $payload; } + /** + * Sort the pipes based on the "order" classes + */ + protected function sortPipes(): array + { + $pipes = $this->pipes; + + /** @var array<\Saloon\Data\PipeOrder> $pipeNames */ + $pipeOrders = array_map(static fn (Pipe $pipe) => $pipe->order, $pipes); + + // Now we'll iterate through the pipe orders and if a specific pipe + // requests to be placed at the top - we will move the pipe to the + // top of the array. If it wants to be at the bottom we can put it + // there too. + + foreach ($pipeOrders as $index => $order) { + if (is_null($order)) { + continue; + } + + $pipe = $pipes[$index]; + + unset($pipes[$index]); + + match (true) { + $order->type === Order::FIRST => array_unshift($pipes, $pipe), + $order->type === Order::LAST => $pipes[] = $pipe, + }; + } + + return $pipes; + } + /** * Set the pipes on the pipeline. * @@ -66,7 +99,7 @@ public function setPipes(array $pipes): static // so we can check if the name already exists. foreach ($pipes as $pipe) { - $this->pipe($pipe->callable, false, $pipe->name); + $this->pipe($pipe->callable, $pipe->name, $pipe->order); } return $this; diff --git a/src/Http/Middleware/DetermineMockResponse.php b/src/Http/Middleware/DetermineMockResponse.php index 1d8f5e47..87d1a86f 100644 --- a/src/Http/Middleware/DetermineMockResponse.php +++ b/src/Http/Middleware/DetermineMockResponse.php @@ -4,6 +4,7 @@ namespace Saloon\Http\Middleware; +use Saloon\Data\PipeOrder; use Saloon\Http\Faking\Fixture; use Saloon\Contracts\PendingRequest; use Saloon\Http\Faking\MockResponse; @@ -15,6 +16,7 @@ class DetermineMockResponse implements RequestMiddleware * Guess a mock response * * @throws \JsonException + * @throws \Saloon\Exceptions\FixtureException * @throws \Saloon\Exceptions\FixtureMissingException */ public function __invoke(PendingRequest $pendingRequest): PendingRequest|MockResponse @@ -49,7 +51,7 @@ public function __invoke(PendingRequest $pendingRequest): PendingRequest|MockRes // middleware on the response to record the response. if (is_null($mockResponse) && $mockObject instanceof Fixture) { - $pendingRequest->middleware()->onResponse(new RecordFixture($mockObject), true, 'recordFixture'); + $pendingRequest->middleware()->onResponse(new RecordFixture($mockObject), 'recordFixture', PipeOrder::first()); } return $pendingRequest; diff --git a/src/Http/PendingRequest.php b/src/Http/PendingRequest.php index 35ac626d..f558aa08 100644 --- a/src/Http/PendingRequest.php +++ b/src/Http/PendingRequest.php @@ -152,39 +152,52 @@ protected function registerAndExecuteMiddleware(): void { $middleware = $this->middleware(); - // New Middleware Order: + // We'll start with our core middleware like merging request properties, merging the + // body, delay and also running authenticators on the request. - // 1. Global (Laravel) - // 2. Plugin (Rate Limiter) - // 3. Deferred Authentication - // 4. Mock Response - // 5. User - // 6. Delay/Debugging/Event + $middleware + ->onRequest(new MergeRequestProperties, 'mergeRequestProperties') + ->onRequest(new MergeBody, 'mergeBody') + ->onRequest(new MergeDelay, 'mergeDelay') + ->onRequest(new AuthenticateRequest, 'authenticateRequest') + ->onRequest(new DetermineMockResponse, 'determineMockResponse'); - // Todo: Revisit middleware order + // Next, we'll merge in our "Global" middleware which can be middleware set by the + // user or set by Saloon's plugins like the Laravel Plugin. It's best that this + // middleware is run now because we want the user to still have an opportunity + // to overwrite anything applied by it. $middleware->merge(Config::middleware()); - // Now we'll queue te delay middleware and authenticator middleware - - $middleware - ->onRequest(new MergeRequestProperties, false, 'mergeRequestProperties') - ->onRequest(new MergeBody, false, 'mergeBody') - ->onRequest(new MergeDelay, false, 'mergeDelay') - ->onRequest(new AuthenticateRequest, false, 'authenticateRequest') - ->onRequest(new DetermineMockResponse, false, 'determineMockResponse'); + // Now we'll "boot" the connector and request. This is a hook that can be run after + // the core middleware that allows you to add your own properties that are a higher + // priority than anything else. $this->bootConnectorAndRequest(); + // Now we'll merge the middleware added on the connector and the request. This + // middleware will have almost the final object to play with and overwrite if + // they desire. + $middleware ->merge($this->connector->middleware()) - ->merge($this->request->middleware()) - ->onRequest(new DelayMiddleware, false, 'delayMiddleware') - ->onRequest(new DebugRequest, false, 'debugRequest') - ->onResponse(new DebugResponse, false, 'debugResponse'); + ->merge($this->request->middleware()); + + // Next, we'll delay the request if we need to. This will run before the final + // middleware. + + $middleware->onRequest(new DelayMiddleware, 'delayMiddleware'); + + // Finally, we'll apply our "final" middleware. This is a group of middleware + // that will run at the end, no matter what. This is useful for debugging and + // events where we can guarantee that the middleware will be run at the end. + + $middleware + ->onRequest(new DebugRequest, 'debugRequest') + ->onResponse(new DebugResponse, 'debugResponse'); // Next, we will execute the request middleware pipeline which will - // process any middleware added on the connector or the request. + // process the middleware in the order we added it. $middleware->executeRequestPipeline($this); } diff --git a/tests/Unit/MiddlewarePipelineTest.php b/tests/Unit/MiddlewarePipelineTest.php index 2217f20c..a39a1ae6 100644 --- a/tests/Unit/MiddlewarePipelineTest.php +++ b/tests/Unit/MiddlewarePipelineTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use Saloon\Data\Pipe; use Saloon\Http\Response; +use Saloon\Data\PipeOrder; use Saloon\Http\PendingRequest; use GuzzleHttp\Psr7\HttpFactory; use Saloon\Http\Faking\MockClient; @@ -36,7 +38,13 @@ $pipeline ->onRequest(function (PendingRequest $request) { $request->headers()->add('X-Pipe-One', 'Yee-Haw'); - }, false, 'YeeHawPipe'); + }, 'YeeHawPipe'); + + $pipe = $pipeline->getRequestPipeline()->getPipes()[0]; + + expect($pipe)->toBeInstanceOf(Pipe::class); + expect($pipe->name)->toEqual('YeeHawPipe'); + expect($pipe->order)->toBeNull(); $pendingRequest = connector()->createPendingRequest(new UserRequest); $pendingRequest = $pipeline->executeRequestPipeline($pendingRequest); @@ -52,7 +60,6 @@ callable: function (PendingRequest $request) { $request->headers()->add('X-Pipe-One', 'Yee-Haw'); }, - prepend: false, name: 'YeeHawPipe' ); @@ -64,7 +71,6 @@ callable: function (PendingRequest $request) { $request->headers()->add('X-Pipe-One', 'Yee-Haw'); }, - prepend: false, name: 'YeeHawPipe' ); }); @@ -77,7 +83,13 @@ $pipeline ->onResponse(function (Response $response) use (&$count) { $count++; - }, false, 'ResponsePipe'); + }, 'ResponsePipe'); + + $pipe = $pipeline->getResponsePipeline()->getPipes()[0]; + + expect($pipe)->toBeInstanceOf(Pipe::class); + expect($pipe->name)->toEqual('ResponsePipe'); + expect($pipe->order)->toBeNull(); $factory = new HttpFactory; @@ -95,7 +107,7 @@ $pipeline ->onResponse(function (Response $response) { // - }, false, 'ResponsePipe'); + }, 'ResponsePipe'); $this->expectException(DuplicatePipeNameException::class); $this->expectExceptionMessage('The "ResponsePipe" pipe already exists on the pipeline'); @@ -103,7 +115,7 @@ $pipeline ->onResponse(function (Response $response) { // - }, false, 'ResponsePipe'); + }, 'ResponsePipe'); }); test('if a request pipe returns a pending request, we will use that in the next step', function () { @@ -186,7 +198,7 @@ }) ->onResponse(function (Response $response) { return $response->throw(); - }, false, 'response'); + }, 'response'); expect($pipelineB->getRequestPipeline()->getPipes())->toBeEmpty(); expect($pipelineB->getResponsePipeline()->getPipes())->toBeEmpty(); @@ -203,8 +215,8 @@ $pipelineA = new MiddlewarePipeline; $pipelineB = new MiddlewarePipeline; - $pipelineA->onRequest(fn () => null, false, 'howdy'); - $pipelineB->onRequest(fn () => null, false, 'howdy'); + $pipelineA->onRequest(fn () => null, 'howdy'); + $pipelineB->onRequest(fn () => null, 'howdy'); $this->expectException(DuplicatePipeNameException::class); $this->expectExceptionMessage('The "howdy" pipe already exists on the pipeline'); @@ -241,13 +253,38 @@ }) ->onRequest(function (PendingRequest $request) use (&$names) { $names[] = 'Taylor'; - }, true); + }, order: PipeOrder::first()) + ->onRequest(function (PendingRequest $request) use (&$names) { + $names[] = 'Andrew'; + }); + + $pendingRequest = connector()->createPendingRequest(new UserRequest); + + $pipeline->executeRequestPipeline($pendingRequest); + + expect($names)->toEqual(['Taylor', 'Sam', 'Andrew']); +}); + +test('a request pipe can be added to the bottom of the pipeline', function () { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onRequest(function (PendingRequest $request) use (&$names) { + $names[] = 'Sam'; + }) + ->onRequest(function (PendingRequest $request) use (&$names) { + $names[] = 'Taylor'; + }, order: PipeOrder::last()) + ->onRequest(function (PendingRequest $request) use (&$names) { + $names[] = 'Andrew'; + }); $pendingRequest = connector()->createPendingRequest(new UserRequest); $pipeline->executeRequestPipeline($pendingRequest); - expect($names)->toEqual(['Taylor', 'Sam']); + expect($names)->toEqual(['Sam', 'Andrew', 'Taylor']); }); test('a response pipe is run in order of the pipes', function () { @@ -289,13 +326,43 @@ }) ->onResponse(function (Response $response) use (&$names) { $names[] = 'Taylor'; - }, true); + }, order: PipeOrder::first()) + ->onResponse(function (Response $response) use (&$names) { + $names[] = 'Andrew'; + }); $response = connector()->send(new UserRequest, $mockClient); $pipeline->executeResponsePipeline($response); - expect($names)->toEqual(['Taylor', 'Sam']); + expect($names)->toEqual(['Taylor', 'Sam', 'Andrew']); +}); + +test('a response pipe can be added to the bottom of the pipeline', function () { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $names = []; + + $pipeline = new MiddlewarePipeline; + + $pipeline + ->onResponse(function (Response $response) use (&$names) { + $names[] = 'Sam'; + }) + ->onResponse(function (Response $response) use (&$names) { + $names[] = 'Taylor'; + }, order: PipeOrder::last()) + ->onResponse(function (Response $response) use (&$names) { + $names[] = 'Andrew'; + }); + + $response = connector()->send(new UserRequest, $mockClient); + + $pipeline->executeResponsePipeline($response); + + expect($names)->toEqual(['Sam', 'Andrew', 'Taylor']); }); test('a middleware pipeline is correctly destructed when finished', function (): void { @@ -311,6 +378,9 @@ ->onRequest(function (PendingRequest $request) { // Doesn't really matter. }) + ->onResponse(function (PendingRequest $request) { + // Doesn't really matter. + }, order: PipeOrder::last()) ->onResponse(function (PendingRequest $request) { // Doesn't really matter. }); @@ -319,7 +389,7 @@ ->and($pipeline->getRequestPipeline())->toBeInstanceOf(\Saloon\Contracts\Pipeline::class) ->and($pipeline->getRequestPipeline()->getPipes())->toHaveCount(1) ->and($pipeline->getResponsePipeline())->toBeInstanceOf(\Saloon\Contracts\Pipeline::class) - ->and($pipeline->getResponsePipeline()->getPipes())->toHaveCount(1) + ->and($pipeline->getResponsePipeline()->getPipes())->toHaveCount(2) ->and($pipelineReference->get())->toEqual($pipeline); unset($pipeline);