diff --git a/src/api-client/src/ApiClient.php b/src/api-client/src/ApiClient.php index 696f6cbdc..ed2e0b235 100644 --- a/src/api-client/src/ApiClient.php +++ b/src/api-client/src/ApiClient.php @@ -26,12 +26,12 @@ class ApiClient protected bool $enableMiddleware = true; /** - * @var array + * @var array */ protected array $requestMiddleware = []; /** - * @var array + * @var array */ protected array $responseMiddleware = []; diff --git a/src/api-client/src/ApiRequest.php b/src/api-client/src/ApiRequest.php index 7cc67414d..44f92a0aa 100644 --- a/src/api-client/src/ApiRequest.php +++ b/src/api-client/src/ApiRequest.php @@ -11,6 +11,8 @@ class ApiRequest extends HttpClientRequest { + use HasContext; + /** * Determine if the request data has changed. */ @@ -180,6 +182,7 @@ public function withoutHeaders(array $headers): static public function withBody(string $body): static { $this->request = $this->request->withBody(new Stream($body)); + $this->dataChanged = false; return $this; } diff --git a/src/api-client/src/ApiResource.php b/src/api-client/src/ApiResource.php index 8ce12494d..f726897c0 100644 --- a/src/api-client/src/ApiResource.php +++ b/src/api-client/src/ApiResource.php @@ -9,8 +9,6 @@ use Hyperf\Contract\Arrayable; use Hyperf\Contract\Jsonable; use Hyperf\Support\Traits\ForwardsCalls; -use Hypervel\HttpClient\Request; -use Hypervel\HttpClient\Response; use JsonSerializable; use Stringable; @@ -25,8 +23,8 @@ class ApiResource implements Stringable, ArrayAccess, JsonSerializable, Arrayabl * Create a new resource instance. */ public function __construct( - protected Response $response, - protected Request $request + protected ApiResponse $response, + protected ApiRequest $request ) { } @@ -73,12 +71,12 @@ public function __call(string $method, array $parameters): mixed return $this->forwardCallTo($this->response, $method, $parameters); } - public function getResponse(): Response + public function getResponse(): ApiResponse { return $this->response; } - public function getRequest(): Request + public function getRequest(): ApiRequest { return $this->request; } diff --git a/src/api-client/src/ApiResponse.php b/src/api-client/src/ApiResponse.php index 831a5f47e..bcc8a4f0d 100644 --- a/src/api-client/src/ApiResponse.php +++ b/src/api-client/src/ApiResponse.php @@ -5,7 +5,63 @@ namespace Hypervel\ApiClient; use Hypervel\HttpClient\Response as HttpClientResponse; +use Psr\Http\Message\StreamInterface; class ApiResponse extends HttpClientResponse { + use HasContext; + + public function withStatus(int $code, string $reasonPhrase = ''): static + { + $this->response = $this->toPsrResponse() + ->withStatus($code, $reasonPhrase); + + return $this; + } + + public function withProtocolVersion(string $version): static + { + $this->response = $this->toPsrResponse() + ->withProtocolVersion($version); + + return $this; + } + + public function hasHeader(string $name): bool + { + return $this->toPsrResponse() + ->hasHeader($name); + } + + public function withHeader(string $name, mixed $value): static + { + $this->response = $this->toPsrResponse() + ->withHeader($name, $value); + + return $this; + } + + public function withAddedHeader(string $name, mixed $value): static + { + $this->response = $this->toPsrResponse() + ->withAddedHeader($name, $value); + + return $this; + } + + public function withoutHeader(string $name): static + { + $this->response = $this->toPsrResponse() + ->withoutHeader($name); + + return $this; + } + + public function withBody(StreamInterface $body): static + { + $this->response = $this->toPsrResponse() + ->withBody($body); + + return $this; + } } diff --git a/src/api-client/src/HasContext.php b/src/api-client/src/HasContext.php new file mode 100644 index 000000000..72df6b7a5 --- /dev/null +++ b/src/api-client/src/HasContext.php @@ -0,0 +1,38 @@ +context = array_merge($this->context, $key); + + return $this; + } + + $this->context[$key] = $value; + + return $this; + } + + /** + * Get the API request/response context. + */ + public function context(?string $key = null): mixed + { + if ($key !== null) { + return $this->context[$key] ?? null; + } + + return $this->context; + } +} diff --git a/src/api-client/src/PendingRequest.php b/src/api-client/src/PendingRequest.php index 50c7aa5b0..4e545ce9c 100644 --- a/src/api-client/src/PendingRequest.php +++ b/src/api-client/src/PendingRequest.php @@ -9,6 +9,7 @@ use Hypervel\HttpClient\PendingRequest as ClientPendingRequest; use Hypervel\HttpClient\Request; use Hypervel\Support\Facades\Http; +use Hypervel\Support\Pipeline; use Hypervel\Support\Traits\Conditionable; use InvalidArgumentException; use JsonSerializable; @@ -34,24 +35,30 @@ class PendingRequest protected array $guzzleOptions = []; /** - * @var array + * @var array */ protected array $requestMiddleware = []; /** - * @var array + * @var array */ protected array $responseMiddleware = []; protected ?ClientPendingRequest $request = null; + protected Pipeline $pipeline; + + protected static $cachedMiddleware = []; + public function __construct( protected ApiClient $client, + ?Pipeline $pipeline = null, ) { $this->resource = $this->client->getResource(); $this->enableMiddleware = $this->client->getEnableMiddleware(); $this->requestMiddleware = $this->client->getRequestMiddleware(); $this->responseMiddleware = $this->client->getResponseMiddleware(); + $this->pipeline = $pipeline ?? Pipeline::make(); } /** @@ -236,6 +243,14 @@ public function send(string $method, string $url, array $options = []): ApiResou return $this->sendRequest('send', $method, $url, $options); } + /** + * Flush the cached middleware instances. + */ + public function flushCache(): void + { + static::$cachedMiddleware = []; + } + /** * Provide a dynamic method to pass calls to the pending request. */ @@ -255,17 +270,43 @@ protected function sendRequest(): ApiResource $request = null; $response = $this->getClient() ->beforeSending(function (Request $httpRequest) use (&$request) { - $request = $httpRequest; + $request = new ApiRequest($httpRequest->toPsrRequest()); + if ($this->enableMiddleware) { + $request = $this->handleMiddlewareRequest($request); + } + return $request->toPsrRequest(); })->{$method}(...$arguments); if ($response instanceof PromiseInterface) { throw new InvalidArgumentException('Api client does not support async requests'); } + $response = new ApiResponse($response->toPsrResponse()); + + if ($this->enableMiddleware) { + $response = $this->handleMiddlewareResponse($response); + } + return $this->resource::make($response, $request); } - protected function createMiddleware(array $middlewareClasses, array $options): array + protected function handleMiddlewareRequest(ApiRequest $request): ApiRequest + { + return $this->pipeline + ->send($request->withContext('options', $this->middlewareOptions)) + ->through($this->createMiddleware($this->requestMiddleware)) + ->thenReturn(); + } + + protected function handleMiddlewareResponse(ApiResponse $response): ApiResponse + { + return $this->pipeline + ->send($response) + ->through($this->createMiddleware($this->responseMiddleware)) + ->thenReturn(); + } + + protected function createMiddleware(array $middlewareClasses): array { $middleware = []; foreach ($middlewareClasses as $value) { @@ -274,8 +315,11 @@ protected function createMiddleware(array $middlewareClasses, array $options): a sprintf('Middleware class `%s` does not exist', $value) ); } - - $middleware[] = new $value($this->client->getConfig(), $options); + if ($cache = static::$cachedMiddleware[$value] ?? null) { + $middleware[] = $cache; + continue; + } + $middleware[] = static::$cachedMiddleware[$value] = new $value($this->client->getConfig()); } return $middleware; @@ -293,18 +337,6 @@ protected function getClient(): ClientPendingRequest $request->withOptions($this->guzzleOptions); } - if (! $this->enableMiddleware) { - return $request; - } - - foreach ($this->createMiddleware($this->requestMiddleware, $this->middlewareOptions) as $middleware) { - $request->withRequestMiddleware($middleware); - } - - foreach ($this->createMiddleware($this->responseMiddleware, $this->middlewareOptions) as $middleware) { - $request->withResponseMiddleware($middleware); - } - return $this->request = $request; } } diff --git a/src/api-client/src/RequestMiddleware.php b/src/api-client/src/RequestMiddleware.php deleted file mode 100644 index 4ff9ba2aa..000000000 --- a/src/api-client/src/RequestMiddleware.php +++ /dev/null @@ -1,18 +0,0 @@ -handle(new ApiRequest($request)) - ->toPsrRequest(); - } - - abstract public function handle(ApiRequest $request): ApiRequest; -} diff --git a/src/api-client/src/ResponseMiddleware.php b/src/api-client/src/ResponseMiddleware.php deleted file mode 100644 index 5b949069d..000000000 --- a/src/api-client/src/ResponseMiddleware.php +++ /dev/null @@ -1,18 +0,0 @@ -handle(new ApiResponse($response)) - ->toPsrResponse(); - } - - abstract public function handle(ApiResponse $response): ApiResponse; -} diff --git a/tests/ApiClient/ApiResourceTest.php b/tests/ApiClient/ApiResourceTest.php index 830d8d158..f180b1ee4 100644 --- a/tests/ApiClient/ApiResourceTest.php +++ b/tests/ApiClient/ApiResourceTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\ApiClient; use BadMethodCallException; +use Hypervel\ApiClient\ApiRequest; use Hypervel\ApiClient\ApiResource; -use Hypervel\HttpClient\Request; -use Hypervel\HttpClient\Response; +use Hypervel\ApiClient\ApiResponse; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -18,12 +18,12 @@ class ApiResourceTest extends TestCase { /** - * @var MockObject&Response + * @var ApiResponse&MockObject */ private $response; /** - * @var MockObject&Request + * @var ApiRequest&MockObject */ private $request; @@ -34,8 +34,8 @@ protected function setUp(): void parent::setUp(); // Create mock objects for the Response and Request - $this->response = $this->createMock(Response::class); - $this->request = $this->createMock(Request::class); + $this->response = $this->createMock(ApiResponse::class); + $this->request = $this->createMock(ApiRequest::class); // Create the resource with our mocks $this->resource = new ApiResource($this->response, $this->request); diff --git a/tests/ApiClient/PendingRequestTest.php b/tests/ApiClient/PendingRequestTest.php new file mode 100644 index 000000000..40e330f04 --- /dev/null +++ b/tests/ApiClient/PendingRequestTest.php @@ -0,0 +1,605 @@ +withRequestMiddleware($middleware); + + Http::fake(['test' => Http::response('{"data": "test"}')]); + $pending->get('test'); + + $this->assertTrue(TestRequestMiddleware::$called); + TestRequestMiddleware::reset(); + } + + public function testWithAddedRequestMiddlewareAppendsMiddleware(): void + { + $client = new ApiClient(); + $middlewareA = [TestRequestMiddleware::class]; + $middlewareB = [AnotherRequestMiddleware::class]; + + $pending = $client + ->withRequestMiddleware($middlewareA) + ->withAddedRequestMiddleware($middlewareB); + + Http::fake(['test' => Http::response('{"data": "test"}')]); + $pending->get('test'); + + $this->assertTrue(TestRequestMiddleware::$called); + $this->assertTrue(AnotherRequestMiddleware::$called); + TestRequestMiddleware::reset(); + AnotherRequestMiddleware::reset(); + } + + public function testWithResponseMiddlewareSetsMiddleware(): void + { + $client = new ApiClient(); + $middleware = [TestResponseMiddleware::class]; + + $pending = $client->withResponseMiddleware($middleware); + + Http::fake(['test' => Http::response('{"data": "test"}')]); + $pending->get('test'); + + $this->assertTrue(TestResponseMiddleware::$called); + TestResponseMiddleware::reset(); + } + + public function testWithAddedResponseMiddlewareAppendsMiddleware(): void + { + $client = new ApiClient(); + $middlewareA = [TestResponseMiddleware::class]; + $middlewareB = [AnotherResponseMiddleware::class]; + + $pending = $client + ->withResponseMiddleware($middlewareA) + ->withAddedResponseMiddleware($middlewareB); + + Http::fake(['test' => Http::response('{"data": "test"}')]); + $pending->get('test'); + + $this->assertTrue(TestResponseMiddleware::$called); + $this->assertTrue(AnotherResponseMiddleware::$called); + TestResponseMiddleware::reset(); + AnotherResponseMiddleware::reset(); + } + + public function testRequestMiddlewareCanModifyRequest(): void + { + $client = new ApiClient(); + + $pending = $client->withRequestMiddleware([AddHeaderRequestMiddleware::class]); + + Http::fake(['test' => Http::response('{"success": true}')]); + $pending->get('test'); + + Http::assertSent(function (Request $request) { + return $request->hasHeader('X-Custom-Header') + && $request->header('X-Custom-Header')[0] === 'middleware-value'; + }); + } + + public function testResponseMiddlewareCanModifyResponse(): void + { + $client = new ApiClient(); + + $pending = $client->withResponseMiddleware([AddHeaderResponseMiddleware::class]); + + Http::fake(['test' => Http::response('{"success": true}')]); + $response = $pending->get('test'); + + $this->assertTrue($response->hasHeader('X-Response-Header')); + $this->assertEquals( + 'response-value', + $response->header('X-Response-Header') + ); + } + + public function testMiddlewareExecutionOrder(): void + { + $client = new ApiClient(); + + OrderTrackingMiddleware::reset(); + + $pending = $client->withRequestMiddleware([ + OrderTrackingMiddleware::class, + SecondOrderTrackingMiddleware::class, + ]); + + Http::fake(['test' => Http::response('{"success": true}')]); + $pending->get('test'); + + $this->assertEquals([1, 2], OrderTrackingMiddleware::$order); + OrderTrackingMiddleware::reset(); + } + + public function testMiddlewareCanBeDisabled(): void + { + $client = new ApiClient(); + + $pending = $client + ->withRequestMiddleware([TestRequestMiddleware::class]) + ->disableMiddleware(); + + Http::fake(['test' => Http::response('{"data": "test"}')]); + $pending->get('test'); + + $this->assertFalse(TestRequestMiddleware::$called); + TestRequestMiddleware::reset(); + } + + public function testMiddlewareCanBeEnabled(): void + { + $client = new ApiClient(); + + $pending = $client + ->withRequestMiddleware([TestRequestMiddleware::class]) + ->disableMiddleware() + ->enableMiddleware(); + + Http::fake(['test' => Http::response('{"data": "test"}')]); + $pending->get('test'); + + $this->assertTrue(TestRequestMiddleware::$called); + TestRequestMiddleware::reset(); + } + + public function testWithMiddlewareOptionsPassesOptionsToMiddleware(): void + { + $client = new ApiClient(); + $options = ['key' => 'value', 'timeout' => 30]; + + $pending = $client + ->withMiddlewareOptions($options) + ->withRequestMiddleware([OptionsCheckingMiddleware::class]); + + Http::fake(['test' => Http::response('{"success": true}')]); + $pending->get('test'); + + $this->assertEquals($options, OptionsCheckingMiddleware::$receivedOptions); + OptionsCheckingMiddleware::reset(); + } + + public function testMiddlewareCaching(): void + { + $client = new ApiClient(); + + // First request creates middleware instance + $pendingA = $client->withRequestMiddleware([CachingTestMiddleware::class]); + Http::fake(['test1' => Http::response('{"data": "test1"}')]); + $pendingA->get('test1'); + + $firstInstanceId = CachingTestMiddleware::$instanceId; + + // Second request should reuse cached instance + $pendingB = $client->withRequestMiddleware([CachingTestMiddleware::class]); + Http::fake(['test2' => Http::response('{"data": "test2"}')]); + $pendingB->get('test2'); + + $this->assertEquals($firstInstanceId, CachingTestMiddleware::$instanceId); + CachingTestMiddleware::reset(); + } + + public function testFlushCacheClearsMiddlewareCache(): void + { + $client = new ApiClient(); + + // First request creates middleware instance + $pendingA = $client->withRequestMiddleware([CachingTestMiddleware::class]); + Http::fake(['test1' => Http::response('{"data": "test1"}')]); + $pendingA->get('test1'); + + $firstInstanceId = CachingTestMiddleware::$instanceId; + + // Flush cache + $pendingA->flushCache(); + + // Second request creates new instance + $pendingB = $client->withRequestMiddleware([CachingTestMiddleware::class]); + Http::fake(['test2' => Http::response('{"data": "test2"}')]); + $pendingB->get('test2'); + + $this->assertNotEquals($firstInstanceId, CachingTestMiddleware::$instanceId); + CachingTestMiddleware::reset(); + } + + public function testInvalidMiddlewareClassThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Middleware class `NonExistentMiddleware` does not exist'); + + $client = new ApiClient(); + $pending = $client->withRequestMiddleware(['NonExistentMiddleware']); + + Http::fake(['test' => Http::response('{"data": "test"}')]); + $pending->get('test'); + } + + public function testRequestMiddlewarePipelineFlow(): void + { + $client = new ApiClient(); + + PipelineTestMiddleware::reset(); + + $pending = $client->withRequestMiddleware([ + FirstPipelineMiddleware::class, + SecondPipelineMiddleware::class, + ]); + + Http::fake(['test' => Http::response('{"success": true}')]); + $pending->get('test'); + + // Verify both middleware were called in correct order + $this->assertEquals(['first', 'second'], PipelineTestMiddleware::$calls); + PipelineTestMiddleware::reset(); + } + + public function testResponseMiddlewarePipelineFlow(): void + { + $client = new ApiClient(); + + PipelineTestMiddleware::reset(); + + $pending = $client->withResponseMiddleware([ + FirstResponsePipelineMiddleware::class, + SecondResponsePipelineMiddleware::class, + ]); + + Http::fake(['test' => Http::response('{"success": true}')]); + $pending->get('test'); + + // Verify both middleware were called in correct order + $this->assertEquals(['first-response', 'second-response'], PipelineTestMiddleware::$calls); + PipelineTestMiddleware::reset(); + } + + public function testMiddlewareReceivesClientConfig(): void + { + $client = new FooApiClient($config = [ + 'api_key' => 'test-key', + 'base_url' => 'https://api.test.com', + ]); + + $pending = $client->withRequestMiddleware([ConfigCheckingMiddleware::class]); + + Http::fake(['test' => Http::response('{"success": true}')]); + $pending->get('test'); + + $this->assertEquals($config, ConfigCheckingMiddleware::$receivedConfig?->toArray()); + ConfigCheckingMiddleware::reset(); + } + + public function testCombinedRequestAndResponseMiddleware(): void + { + $client = new ApiClient(); + + $pending = $client + ->withRequestMiddleware([AddHeaderRequestMiddleware::class]) + ->withResponseMiddleware([AddHeaderResponseMiddleware::class]); + + Http::fake(['test' => Http::response('{"success": true}')]); + $response = $pending->get('test'); + + // Verify request middleware was applied + Http::assertSent(function (Request $request) { + return $request->hasHeader('X-Custom-Header') + && $request->header('X-Custom-Header')[0] === 'middleware-value'; + }); + + // Verify response middleware was applied + $this->assertTrue($response->hasHeader('X-Response-Header')); + $this->assertEquals( + 'response-value', + $response->header('X-Response-Header') + ); + } +} + +// Test middleware classes +class TestRequestMiddleware +{ + public static bool $called = false; + + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + self::$called = true; + return $next($request); + } + + public static function reset(): void + { + self::$called = false; + } +} + +class AnotherRequestMiddleware +{ + public static bool $called = false; + + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + self::$called = true; + return $next($request); + } + + public static function reset(): void + { + self::$called = false; + } +} + +class TestResponseMiddleware +{ + public static bool $called = false; + + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiResponse $response, callable $next): ApiResponse + { + self::$called = true; + return $next($response); + } + + public static function reset(): void + { + self::$called = false; + } +} + +class AnotherResponseMiddleware +{ + public static bool $called = false; + + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiResponse $response, callable $next): ApiResponse + { + self::$called = true; + return $next($response); + } + + public static function reset(): void + { + self::$called = false; + } +} + +class AddHeaderRequestMiddleware +{ + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + $request = $request->withHeader('X-Custom-Header', 'middleware-value'); + return $next($request); + } +} + +class AddHeaderResponseMiddleware +{ + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiResponse $response, callable $next): ApiResponse + { + $response = $response->withHeader('X-Response-Header', 'response-value'); + return $next($response); + } +} + +class OrderTrackingMiddleware +{ + public static array $order = []; + + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + self::$order[] = 1; + return $next($request); + } + + public static function reset(): void + { + self::$order = []; + } +} + +class SecondOrderTrackingMiddleware +{ + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + OrderTrackingMiddleware::$order[] = 2; + return $next($request); + } +} + +class OptionsCheckingMiddleware +{ + public static ?array $receivedOptions = null; + + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + self::$receivedOptions = $request->context('options'); + return $next($request); + } + + public static function reset(): void + { + self::$receivedOptions = null; + } +} + +class CachingTestMiddleware +{ + public static ?string $instanceId = null; + + private string $id; + + public function __construct(protected ?array $config = null) + { + $this->id = uniqid('middleware_', true); + self::$instanceId = $this->id; + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + return $next($request); + } + + public static function reset(): void + { + self::$instanceId = null; + } +} + +class PipelineTestMiddleware +{ + public static array $calls = []; + + public static function reset(): void + { + self::$calls = []; + } +} + +class FirstPipelineMiddleware +{ + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + PipelineTestMiddleware::$calls[] = 'first'; + return $next($request); + } +} + +class SecondPipelineMiddleware +{ + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + PipelineTestMiddleware::$calls[] = 'second'; + return $next($request); + } +} + +class FirstResponsePipelineMiddleware +{ + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiResponse $response, callable $next): ApiResponse + { + PipelineTestMiddleware::$calls[] = 'first-response'; + return $next($response); + } +} + +class SecondResponsePipelineMiddleware +{ + public function __construct(protected ?array $config = null) + { + } + + public function handle(ApiResponse $response, callable $next): ApiResponse + { + PipelineTestMiddleware::$calls[] = 'second-response'; + return $next($response); + } +} + +class ConfigCheckingMiddleware +{ + public static ?ConfigDataObject $receivedConfig = null; + + public function __construct(protected ?ConfigDataObject $config = null) + { + self::$receivedConfig = $config; + } + + public function handle(ApiRequest $request, callable $next): ApiRequest + { + return $next($request); + } + + public static function reset(): void + { + self::$receivedConfig = null; + } +} + +class ConfigDataObject extends DataObject +{ + public function __construct( + public string $apiKey, + public string $baseUrl, + ) { + } +} + +class FooApiClient extends ApiClient +{ + public function __construct(array $config = []) + { + $this->config = ConfigDataObject::make($config); + } +}